Compare commits

...

152 Commits

Author SHA1 Message Date
Brendan Allan a0f4e8546c remove console log 2026-06-24 12:13:30 +08:00
Brendan Allan d8c5c6f877 refactor(app): simplify layout hierarchy
Remove TargetServerLayout and TargetDirectoryLayout, moving directory
resolution and SDK providers into route-specific components. NewLayout
now renders from the router root so it stays mounted across home,
draft, and session routes. Titlebar resolves session metadata from
LayoutRoute.server instead of the surrounding server provider.
2026-06-24 12:04:37 +08:00
Brendan Allan 5303ab09f8 fix(app): route new sessions through tabs (#33582) 2026-06-24 03:48:33 +00:00
Brendan Allan 51eb87b256 fix(app): clear viewed session notifications (#33574) 2026-06-24 11:37:44 +08:00
opencode-agent[bot] 556337d6dd chore: generate 2026-06-24 03:37:05 +00:00
Aiden Cline c416ede20d fix(tui): sort model picker by release date (#33558) 2026-06-23 22:35:47 -05:00
Dax Raad 784eea3911 feat(cli): add API request command 2026-06-23 23:35:20 -04:00
Brendan Allan a4b9351c0f fix(app): create scoped drafts from home (#33578) 2026-06-24 11:24:38 +08:00
Kit Langton 57895586c2 refactor(core): remove schema forwarding facades (#33577) 2026-06-24 02:52:58 +00:00
Brendan Allan 6298db7a77 fix(app): throttle directory tree loading (#33576) 2026-06-24 02:51:26 +00:00
opencode-agent[bot] 5202bf1bf2 chore: update nix node_modules hashes 2026-06-24 02:44:09 +00:00
Kit Langton 516cfe4e09 refactor(schema): extract shared public schemas (#33571) 2026-06-23 22:31:06 -04:00
Brendan Allan 60aba622ab fix(app): use fixed titlebar tab widths (#33572) 2026-06-24 02:05:44 +00:00
Dax Raad e8610d821c refactor(core): drop filesystem entry mime 2026-06-23 21:49:01 -04:00
Dax Raad 53076999c5 fix(tui): normalize autocomplete attachments 2026-06-23 21:41:14 -04:00
Brendan Allan f8c9bfd454 fix(app): mount shortcuts per titlebar tab (#33567) 2026-06-24 01:39:30 +00:00
Brendan Allan fed4f4d86b feat(app): keep prompt state in tabs (#33566) 2026-06-24 01:32:59 +00:00
opencode-agent[bot] 1490752059 chore: generate 2026-06-24 00:54:55 +00:00
Dax 4898263dec feat(core): map providers to integrations (#33562) 2026-06-24 00:53:31 +00:00
Dax c556bddda3 feat(core): add opencode integration (#33560) 2026-06-24 00:26:54 +00:00
opencode-agent[bot] cfddb2407c chore: generate 2026-06-23 23:46:36 +00:00
Dax cf80b5c470 feat(core): add opencode integration (#33555) 2026-06-23 23:45:02 +00:00
Aiden Cline c17b9557f1 fix(core): preserve structured error messages (#33530) 2026-06-23 18:43:59 -05:00
opencode-agent[bot] 10f6bb7878 chore: generate 2026-06-23 23:24:49 +00:00
Aiden Cline b8cfd69acf feat(tui): redesign crash screen (#33549) 2026-06-23 18:23:15 -05:00
opencode-agent[bot] d2305d4a76 chore: generate 2026-06-23 20:31:13 +00:00
Shoubhit Dash c6cc13e183 feat(mcp): add resource template listing (#33546) 2026-06-24 01:59:12 +05:30
Kit Langton dcf7b4e792 fix(opencode): handle snapshot paths from subdirectories (#33506) 2026-06-23 20:51:56 +02:00
Aiden Cline 5152150bfe fix(opencode): make ACP resource text sourcing cross-platform (#33534) 2026-06-23 12:55:34 -05:00
Brendan Allan 2ba18b84a5 fix(app): use correct server sdk for titlebar session lookup (#33536) 2026-06-24 01:54:43 +08:00
Brendan Allan e04c5e72f7 fix(app): prompt persistence and draft sessions (#33528) 2026-06-24 01:50:19 +08:00
Aiden Cline a131811cdc feat(mcp): use mcp__server__tool naming convention (#33533) 2026-06-23 12:28:26 -05:00
opencode-agent[bot] 976e5d421f ci: route support questions (#33527)
Co-authored-by: Test <test@opencode.test>
2026-06-23 12:20:30 -05:00
Shoubhit Dash 8e2d422ffe fix(acp): preserve resource text source (#33524) 2026-06-23 22:15:00 +05:30
Aiden Cline a3825286cf test(core): avoid models cache recovery race (#33525) 2026-06-23 11:40:08 -05:00
ReStranger e0c0411003 fix: Skip bun version check for nix version (#33166)
Signed-off-by: ReStranger <restranger@disroot.org>
Co-authored-by: dbeley <6568955+dbeley@users.noreply.github.com>
2026-06-23 11:10:55 -05:00
opencode-agent[bot] e439456b13 chore: generate 2026-06-23 15:47:46 +00:00
Aiden Cline e3edbba3ca test(opencode): stabilize prompt concurrency tests (#33522) 2026-06-23 10:45:30 -05:00
Simon Klee 3cdd431794 fix(tui): make move session command bindable (#33508)
Add the session_move TUI keybind and map it to the existing session.move command. Keep it unbound by default and document the new configuration option.

Closes #33239
2026-06-23 15:55:14 +02:00
Adam ef4500d6af fix(data): ignore table changes 2026-06-23 07:28:11 -05:00
Adam f416f52ae3 Reapply "feat(stats): add user ids to inference metrics"
This reverts commit e0ff4fd1ac.
2026-06-23 07:27:01 -05:00
Adam e0ff4fd1ac Revert "feat(stats): add user ids to inference metrics"
This reverts commit 428ae6d21a.
2026-06-23 07:11:51 -05:00
Adam 428ae6d21a feat(stats): add user ids to inference metrics 2026-06-23 07:05:26 -05:00
Brendan Allan d568fed095 fix(app): tighten mobile utility UI (#32799) 2026-06-23 10:41:05 +00:00
opencode-agent[bot] 58a8d9fcb0 chore: generate 2026-06-23 10:28:43 +00:00
Aarav Sareen d24848359d feat(app): new session progress indicator (#32662)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-06-23 20:27:07 +10:00
Brendan Allan a21e74773f fix(app): improve iOS PWA shell (#32798) 2026-06-23 09:59:30 +00:00
opencode-agent[bot] fc95a84b42 chore: generate 2026-06-23 09:30:58 +00:00
Brendan Allan b0063709e6 feat(app): add mobile bottom navigation (#32797) 2026-06-23 09:29:22 +00:00
opencode-agent[bot] 77522697b0 chore: generate 2026-06-23 09:00:19 +00:00
Brendan Allan bbc88eb8e3 fix(app): refine mobile session layout (#32796) 2026-06-23 16:58:05 +08:00
opencode-agent[bot] a379c7956b chore: generate 2026-06-23 08:47:47 +00:00
Aarav Sareen 52eae83ae4 fix(app): add server button dropdown (#33358) 2026-06-23 18:46:00 +10:00
Aarav Sareen 3cc626b5da feat(app): collapsible servers (#33384) 2026-06-23 18:41:54 +10:00
Luke Parker f0849a697c fix(app): reject stale timeline range indexes (#33488) 2026-06-23 18:27:25 +10:00
Shoubhit Dash 3f3f120825 feat(mcp): add resource read tools (#33483) 2026-06-23 13:49:42 +05:30
Frank ea17d9bed8 zen: glm 5.2 2026-06-23 04:03:07 -04:00
Frank 726b2d7e6b zen: remove unused fields 2026-06-23 04:03:07 -04:00
opencode-agent[bot] bffc5938fa chore: generate 2026-06-23 08:02:37 +00:00
Luke Parker d111812389 fix(app): restore review line comments (#33481) 2026-06-23 08:01:03 +00:00
Brendan Allan 597f47b486 fix(app): improve mobile home layout (#32789) 2026-06-23 16:00:21 +08:00
opencode-agent[bot] 09757c605a chore: generate 2026-06-23 06:20:50 +00:00
Brendan Allan 0c4f508c50 feat(app): add server-keyed session routes (#32570) 2026-06-23 06:19:18 +00:00
opencode-agent[bot] 40db33c415 chore: generate 2026-06-23 04:57:55 +00:00
Dax Raad 9b01f15f1d test(opencode): retry Windows CLI cleanup 2026-06-23 00:56:14 -04:00
Dax Raad 81851ca6b9 test(opencode): stabilize Windows async readiness 2026-06-23 00:41:02 -04:00
Dax Raad 3c5632e110 test(opencode): stabilize Windows CLI subprocesses 2026-06-23 00:26:48 -04:00
Dax Raad 6ea3e6698a fix(opencode): scope reference readiness wait 2026-06-23 00:10:36 -04:00
Dax Raad ed75ce9ecc fix(core): prioritize reference plugin registration 2026-06-22 23:39:26 -04:00
Dax Raad af14fefc96 test(opencode): relax compaction readiness timeout 2026-06-22 23:01:05 -04:00
Dax Raad 4a710e4679 fix(core): await plugin readiness 2026-06-22 22:49:57 -04:00
Dax Raad d2c866bf70 test(core): speed up test setup 2026-06-22 22:49:57 -04:00
opencode-agent[bot] 237595e242 chore: generate 2026-06-23 02:19:31 +00:00
Aiden Cline d29f5eba92 refactor(core): remove shell description input (#32823) 2026-06-22 21:18:06 -05:00
Aiden Cline fbf889db83 fix(tui): preserve worker rejection handling (#33448)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-06-22 19:57:21 -05:00
Dax ef2357915e fix(tui): scope file autocomplete to session (#33458) 2026-06-22 20:31:36 -04:00
Dax Raad 975b1132f1 refactor(plugin): use direct runtime registry 2026-06-22 20:10:29 -04:00
Dax Raad cd97de7391 refactor(plugin): consolidate internal registration 2026-06-22 19:49:00 -04:00
Dax Raad 23fd5907be fix(opencode): normalize CLI test line endings 2026-06-22 19:15:50 -04:00
opencode-agent[bot] 49d3f86802 chore: generate 2026-06-22 23:08:26 +00:00
Dax 909a1a6d78 feat(plugin): add namespaced hook API (#33416) 2026-06-22 19:06:57 -04:00
Kit Langton dc468bdcfd fix(core): reset steps for promoted prompts (#33452) 2026-06-22 19:01:14 -04:00
Kit Langton f48f24ec4e refactor(core): simplify session input promotion (#33443) 2026-06-22 17:51:49 -04:00
Aiden Cline 34b3d59a23 ignore: update agents.md (#33446) 2026-06-22 15:52:43 -05:00
Frank a0a500316e zen: new inference 2026-06-22 15:40:14 -04:00
Kit Langton 1787fa4261 refactor(core): drop legacy compaction event (#33404) 2026-06-22 21:39:41 +02:00
Kit Langton 130957288e fix(llm): preserve structured tool errors (#33405) 2026-06-22 13:43:07 -04:00
Aiden Cline c5a4a8288c fix: dont show gpt-5.5-pro when using codex oauth (#33400)
Co-authored-by: Devin Oldenburg <devin@logicplanes.com>
2026-06-22 11:47:02 -05:00
Adam 39d7394ede fix(stats): hide unique users tooltip total 2026-06-22 11:40:00 -05:00
opencode-agent[bot] 494123a875 chore: generate 2026-06-22 16:18:36 +00:00
Kit Langton fe840d42b8 refactor(core): simplify session run coordination (#33388) 2026-06-22 12:16:30 -04:00
Kit Langton f50e4accf3 test(opencode): synchronize shell cancellation (#33386) 2026-06-22 11:55:04 -04:00
Kit Langton 9bceb8eb7d test(opencode): synchronize websocket retry failures (#33387) 2026-06-22 11:54:49 -04:00
Kit Langton adebb87191 test(opencode): use EventV2 location contract (#33383) 2026-06-22 11:37:43 -04:00
opencode-agent[bot] 4d5efba5c9 chore: generate 2026-06-22 15:36:00 +00:00
Kit Langton c6ee511485 refactor(core): simplify session context epochs (#33378) 2026-06-22 11:34:03 -04:00
Kit Langton f9f2280452 fix(core): defer session model validation (#33377) 2026-06-22 11:07:27 -04:00
Tommy D. Rossi d5980b47e9 feat(llm): add video and audio media support to Gemini protocol (#31889)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-06-22 10:03:19 -05:00
Kit Langton 41d1279b6f fix(opencode): preserve request logger context (#33381) 2026-06-22 10:58:22 -04:00
Simon Klee 0d32d1f293 cli: add --mini (#33353) 2026-06-22 16:27:56 +02:00
Dax Raad cf31029350 fix(core): batch plugin shutdown 2026-06-22 09:04:29 -04:00
Dax Raad 79b55d4db8 fix(tui): use bridged event stream for data 2026-06-22 08:50:46 -04:00
Adam 36264ccf90 fix(stats): format market share tokens 2026-06-22 07:46:11 -05:00
Dax Raad 7b750a8f20 fix(ci): preserve test log groups 2026-06-22 08:35:40 -04:00
Dax Raad e50261e524 fix(core): format generated migrations 2026-06-22 08:23:25 -04:00
opencode-agent[bot] 639c94a375 chore: generate 2026-06-22 12:22:43 +00:00
Dax Raad b13a2d712a fix(server): isolate event schema types 2026-06-22 08:20:39 -04:00
Dax Raad 1b91b5df34 fix(core): constrain event schema services 2026-06-22 08:14:49 -04:00
Dax Raad 595cc91ddc feat(server): stream events across locations 2026-06-22 08:12:43 -04:00
Dax 57ce1b9ca8 fix(cli): increment ports from default (#33282) 2026-06-22 08:05:49 -04:00
opencode-agent[bot] 4ecc3ac653 chore: generate 2026-06-22 11:22:22 +00:00
Simon Klee c7efbe6fc0 run: inline files for attached servers (#33317) 2026-06-22 13:20:43 +02:00
opencode-agent[bot] cd292a4ecb chore: generate 2026-06-22 04:17:10 +00:00
Dax 2bb4311042 refactor(core): simplify integration test fixtures (#33292) 2026-06-22 04:15:34 +00:00
opencode-agent[bot] cdc6d01c5a chore: generate 2026-06-22 04:00:59 +00:00
Dax Raad 35b3fc85d0 feat(core): expose session switching endpoints 2026-06-21 23:59:22 -04:00
Aiden Cline 9dadc2455f fix(tui): render skill load errors inline (#33298) 2026-06-21 22:40:12 -05:00
opencode-agent[bot] 06dae383f7 chore: generate 2026-06-21 23:42:07 +00:00
Adam 7d204b5b57 feat(stats): show model unique users 2026-06-21 18:40:22 -05:00
Kit Langton 49593c1ec4 fix(core): settle interrupted assistant steps (#33266) 2026-06-21 20:22:09 +00:00
Kit Langton ff837fe949 fix(core): handle read file failures (#33260) 2026-06-21 20:12:42 +00:00
Adam 4c6750d464 fix(stats): unblock stats sync 2026-06-21 14:53:46 -05:00
Kit Langton 69f1ec22e3 fix(core): bound web tool failures (#33259) 2026-06-21 15:37:25 -04:00
Kit Langton 823d327401 fix(core): handle missing read paths (#33255) 2026-06-21 13:50:14 -04:00
opencode-agent[bot] 82d9cab48d chore: generate 2026-06-21 14:36:19 +00:00
Dax fb43c15f88 refactor(core): simplify event model (#33238) 2026-06-21 10:34:57 -04:00
opencode-agent[bot] ca006a2d20 chore: update nix node_modules hashes 2026-06-21 12:21:25 +00:00
Dax Raad 8396395f17 fix(stats): update defect schemas 2026-06-21 08:08:31 -04:00
Dax Raad 1b8bab3e35 fix(ci): avoid Playwright Chromium install hang 2026-06-21 08:08:31 -04:00
opencode-agent[bot] 02687b6324 chore: generate 2026-06-21 12:07:16 +00:00
Dax c780d7cee7 feat(plugin): add v2 effect host (#33111) 2026-06-21 08:05:49 -04:00
opencode-agent[bot] 7a9337da8a chore: generate 2026-06-21 11:49:50 +00:00
Adam f96e6aa6ed fix(stats): bypass worker runtime crash 2026-06-21 06:48:25 -05:00
Adam c6f719e153 fix(stats): restore worker data exports 2026-06-21 06:39:49 -05:00
Adam 1a111be494 fix(stats): run worker effects directly 2026-06-21 06:30:15 -05:00
Adam a0aee82be9 fix(stats): inline worker runtime import 2026-06-21 06:22:26 -05:00
Adam c0dc6e50a7 fix(stats): restore worker runtime 2026-06-21 06:16:02 -05:00
Adam 418a9e4e66 fix(stats): restore model page rendering 2026-06-21 05:58:39 -05:00
Adam bd8ce5e6a9 chore(stats): remove deploy migrations 2026-06-21 05:41:10 -05:00
Adam a97c6de9db fix(stats): support planetscale cli variants 2026-06-21 05:17:22 -05:00
opencode-agent[bot] 24ea4dd008 chore: generate 2026-06-21 10:03:16 +00:00
Adam ffcb7542e1 fix(stats): run production migration safely 2026-06-21 05:01:43 -05:00
opencode-agent[bot] d4d841bafd chore: generate 2026-06-21 09:40:15 +00:00
Adam 6f0e934573 fix(stats): make unique users migration idempotent 2026-06-21 04:38:47 -05:00
Adam 233d065dd5 feat(stats): show model users metric 2026-06-21 04:30:25 -05:00
Dax f12ac6f234 fix(tui): reduce noisy MCP autocomplete matches (#33176) 2026-06-20 22:24:59 -04:00
James Long d3bbfff826 test(opencode): simplify message pagination layer wiring (#33157) 2026-06-20 21:44:37 -04:00
James Long 468f425e76 test(opencode): simplify session retry layer wiring (#33155) 2026-06-20 21:44:02 -04:00
James Long d59619fffd test(opencode): simplify git layer wiring (#33156) 2026-06-20 21:43:03 -04:00
opencode-agent[bot] 5606d2bab9 chore: update nix node_modules hashes 2026-06-21 00:19:31 +00:00
opencode e84d94d998 sync release versions for v1.17.9 2026-06-21 00:03:08 +00:00
Kit Langton 4f1ae1604e chore: upgrade Effect to beta 83 (#32340) 2026-06-20 19:57:29 -04:00
546 changed files with 25169 additions and 12053 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links:
- name: 💬 Discord Community
url: https://discord.gg/opencode
about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question.
about: For support, troubleshooting, how-to questions, and real-time discussion.
-10
View File
@@ -1,10 +0,0 @@
name: Question
description: Ask a question
body:
- type: textarea
id: question
attributes:
label: Question
description: What's your question?
validations:
required: true
+3 -2
View File
@@ -65,7 +65,7 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
run: GITHUB_ACTIONS=false bun turbo test
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@@ -99,7 +99,8 @@ jobs:
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
- name: Setup Bun
uses: ./.github/actions/setup-bun
+3 -3
View File
@@ -137,7 +137,7 @@ const table = sqliteTable("session", {
## Testing
- Avoid mocks as much as possible
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
@@ -152,7 +152,7 @@ const table = sqliteTable("session", {
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn 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 provider-turn allowance; a batch of steers resets it once.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
+23 -9
View File
@@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
_Avoid_: System update, system notification, raw text diff
**Context Epoch**:
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
**Baseline System Context**:
The full **System Context** rendered at the start of a **Context Epoch**.
@@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti
**Safe Provider-Turn Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
**Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**.
**Prompt Promotion**:
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Provider Turn**:
One request to a model provider and the response projected from that request.
**Session Drain**:
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
**Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
@@ -67,6 +79,11 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
@@ -75,31 +92,28 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent.
- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline.
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
- **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.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
+63 -38
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -86,7 +86,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.8",
"version": "1.17.9",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -111,7 +111,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -147,7 +147,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -174,7 +174,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -196,7 +196,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -220,7 +220,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -240,7 +240,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.8",
"version": "1.17.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -276,6 +276,8 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -331,7 +333,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -385,7 +387,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -399,7 +401,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"effect": "catalog:",
},
@@ -411,7 +413,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -442,7 +444,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -458,10 +460,10 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.74",
"@effect/platform-node-shared": "4.0.0-beta.74",
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
@@ -472,13 +474,14 @@
"typescript": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.74",
"effect": "4.0.0-beta.83",
},
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"aws4fetch": "1.0.20",
@@ -495,7 +498,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.8",
"version": "1.17.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -623,8 +626,9 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
@@ -649,6 +653,17 @@
"@opentui/solid",
],
},
"packages/schema": {
"name": "@opencode-ai/schema",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/script": {
"name": "@opencode-ai/script",
"dependencies": {
@@ -661,7 +676,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -676,7 +691,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
@@ -690,7 +705,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -703,7 +718,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -736,7 +751,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -755,7 +770,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -795,7 +810,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -822,7 +837,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -871,7 +886,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -935,9 +950,9 @@
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@effect/opentelemetry": "4.0.0-beta.74",
"@effect/platform-node": "4.0.0-beta.74",
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
"@effect/opentelemetry": "4.0.0-beta.83",
"@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
@@ -973,7 +988,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.74",
"effect": "4.0.0-beta.83",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -1328,13 +1343,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.74", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.74" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-flpyqLPyr+THSe6ZCGRZl6hi+FqxbIXNSkslKGiRJAjbPabam9mSp7R3aC8biIMt6xE4Fd0LNfo4p2GplUkm2Q=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74", "ioredis": "^5.7.0" } }, "sha512-/W16mKqxvhWINLjufzc0log1sl57exXQfwd+em398/zKCbmU3S7snXTDMN6w0ju2TtgK35qrsoGBXEochij6Sg=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.74", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-C6C2hXixNcZXLaFF2u7B/FtOsqpdY7luaPuiGFBJza0P7EnYDkwaT3kB6lv7l/qctmkADc24qOsSCWIKRbC4jg=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -1802,6 +1817,8 @@
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
@@ -3366,7 +3383,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
"effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -5898,6 +5915,10 @@
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -6700,6 +6721,10 @@
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
+2 -1
View File
@@ -56,6 +56,7 @@ const inferenceEventTable = new aws.s3tables.Table(
{ name: "error_cause2", type: "string", required: false },
{ name: "api_key", type: "string", required: false },
{ name: "workspace", type: "string", required: false },
{ name: "user_id", type: "string", required: false },
{ name: "is_subscription", type: "boolean", required: false },
{ name: "subscription", type: "string", required: false },
{ name: "response_length", type: "long", required: false },
@@ -84,7 +85,7 @@ const inferenceEventTable = new aws.s3tables.Table(
},
},
},
{ deleteBeforeReplace: $app.stage !== "production" },
{ deleteBeforeReplace: $app.stage !== "production", ignoreChanges: ["metadata"] },
)
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-LOxTad/iCquvJyonFOcz6/rDTPNDmwyBnykhWZJ5GC4=",
"aarch64-linux": "sha256-iO+0vYhp+2x6ACmh5lQJ/2Ac4uZTqRZE/KhG3u0o6D8=",
"aarch64-darwin": "sha256-tpBydRbrJ+4QxmkGUt/BhME8q6ysCW/CXrsNshYgqDU=",
"x86_64-darwin": "sha256-QQcI6SK7WJ7dSkX6xZuSQPoUdwfoCaimVgoHCnrO0wY="
"x86_64-linux": "sha256-/fq73Jr1mTysaKS0u7VUuwhFzz9tIlYw+9qyW5pA7es=",
"aarch64-linux": "sha256-iomnsCjOvab+v7+ebHoyg/Q7YtQegkln1+RTkb/tLn8=",
"aarch64-darwin": "sha256-7Uaxm1YlhblvtrVSFXPAx2zVjhYmhkjGkCA78bqs6Xg=",
"x86_64-darwin": "sha256-yJbe3TC8wlQJvQP7IwI+9USpsAjlwX7m5Jqa/7dW7bQ="
}
}
+7
View File
@@ -27,6 +27,13 @@ stdenvNoCC.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook
];
postPatch = ''
# NOTE: Relax Bun version check to be a warning instead of an error
substituteInPlace packages/script/src/index.ts \
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
'';
configurePhase = ''
runHook preConfigure
+4 -4
View File
@@ -30,9 +30,9 @@
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.74",
"@effect/platform-node": "4.0.0-beta.74",
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
"@effect/opentelemetry": "4.0.0-beta.83",
"@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -61,7 +61,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.74",
"effect": "4.0.0-beta.83",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -0,0 +1,157 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewLineCommentRegression"
const sessionID = "ses_review_line_comment_regression"
const title = "Review line comment regression"
test.beforeEach(async ({ page }) => {
await openReview(page)
})
test("opens the comment editor when code is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const value = 'after'", { exact: true })
await expectAppVisible(line)
await line.click()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("opens the comment editor when a line number is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("opens the comment editor for a line number range", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const start = review.locator('[data-column-number="1"]').last()
const end = review.locator('[data-column-number="3"]').last()
await expectAppVisible(start)
await expectAppVisible(end)
const from = await start.boundingBox()
const to = await end.boundingBox()
if (!from || !to) throw new Error("Missing line number bounds")
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
await page.mouse.down()
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
await page.mouse.up()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("shows a comment button when a line number is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => {
await page.mouse.move(0, 0)
await lineNumber.hover()
await expect(comment).toBeVisible({ timeout: 500 })
}).toPass()
await comment.click()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("stages a submitted line comment in the prompt context", async ({ page }) => {
const requests: string[] = []
page.on("request", (request) => {
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
})
const review = page.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
await review.getByRole("textbox").fill("Use the existing value instead")
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Session" }).click()
const context = page.getByText("Use the existing value instead", { exact: true }).last()
await expect(context).toBeVisible()
await expect(context.locator("..")).toContainText("review.ts:2")
expect(requests).toEqual([])
})
async function openReview(page: Page) {
await page.setViewportSize({ width: 700, height: 900 })
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_review_line_comment_regression",
worktree: directory,
vcs: "git",
name: "review-line-comment-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "review-line-comment-regression",
projectID: "proj_review_line_comment_regression",
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [
{
file: "src/review.ts",
additions: 1,
deletions: 1,
status: "modified",
patch:
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
},
],
pageMessages: () => ({
items: [
{
info: {
id: "msg_review_line_comment_regression",
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model: { providerID: "opencode", modelID: "test" },
},
parts: [
{
id: "prt_review_line_comment_regression",
sessionID,
messageID: "msg_review_line_comment_regression",
type: "text",
text: "Review this change.",
},
],
},
],
}),
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
await page.getByRole("tab", { name: "Changes" }).click()
expect(await (await diffResponse).json()).toHaveLength(1)
const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
await review
.getByRole("heading", { name: /review\.ts/ })
.getByRole("button")
.first()
.click()
}
@@ -21,6 +21,7 @@ const words = [
"vector",
]
const serverKey = "http://127.0.0.1:4096"
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
@@ -139,7 +140,7 @@ function toolPart(
status: "completed",
input,
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
@@ -200,9 +201,7 @@ function turn(index: number): Message[] {
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0
@@ -242,6 +241,7 @@ function orderedParts(message: Message) {
export const fixture = {
directory,
serverKey,
project: {
id: projectID,
worktree: directory,
@@ -295,6 +295,7 @@ export const fixture = {
.filter(renderable)
.map((part) => part.id),
),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
},
}
@@ -327,6 +327,18 @@ test.describe("smoke: session timeline", () => {
const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shellSubtitle).toHaveCount(0)
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
await expect(shellSubtitle).toHaveText("bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
await expect(shellSubtitle).toHaveCount(0)
})
})
@@ -706,7 +718,7 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
}
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}`
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
+2
View File
@@ -18,6 +18,7 @@ export interface MockServerConfig {
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
vcsDiff?: unknown[]
messageDelay?: number
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
events?: () => unknown[]
@@ -52,6 +53,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
+11 -5
View File
@@ -1,22 +1,28 @@
<!doctype html>
<html lang="en" style="background-color: var(--background-base)">
<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
/>
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
<link rel="shortcut icon" href="/favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#fafafa" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh p-px"></div>
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
<script src="/src/entry.tsx" type="module"></script>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.8",
"version": "1.17.9",
"description": "",
"type": "module",
"exports": {
+2 -1
View File
@@ -15,10 +15,11 @@
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
document.documentElement.style.backgroundColor = isDark ? "#080808" : "#fafafa"
// Update theme-color meta tag to match app color scheme
var metas = document.querySelectorAll("meta[name='theme-color']")
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#131010" : "#F8F7F7")
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#080808" : "#fafafa")
if (themeId === "oc-2") return
+218 -89
View File
@@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
@@ -30,7 +30,7 @@ import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
@@ -47,11 +47,14 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
const HomeRoute = lazy(() => import("@/pages/home"))
const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome })))
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -64,6 +67,10 @@ const SessionRoute = Object.assign(
const server = useServer()
const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
return <Navigate href={sessionHref(server.key, params.id)} />
}
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => {
@@ -82,42 +89,108 @@ const SessionRoute = Object.assign(
{ preload: Session.preload },
)
const TargetSessionRoute = Object.assign(
() => {
const params = useParams<{ serverKey: string }>()
const server = useServer()
const conn = createMemo(() => {
const key = requireServerKey(params.serverKey)
return server.list.find((item) => ServerConnection.key(item) === key)
})
return (
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ResolvedTargetSessionRoute />
</ServerSyncProvider>
</ServerSDKProvider>
)
},
{ preload: Session.preload },
)
function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings()
const tabs = useTabs()
const serverSDK = useServerSDK()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const resolved = useQuery(() => ({
queryKey: [serverSDK().scope, "session-route", params.id] as const,
queryFn: async () => {
const session = (await serverSDK().client.session.get({ sessionID: params.id })).data!
const root = await rootSession(session, (sessionID) =>
serverSDK()
.client.session.get({ sessionID })
.then((result) => result.data!),
)
return { session, rootID: root.id }
},
}))
const directory = createMemo<string | undefined>((prev) => prev ?? resolved.data?.session.directory)
const targetDirectory = () => directory()!
createEffect(() => {
const current = resolved.data
if (!current) return
tabs.addSessionTab({
server: serverKey(),
sessionId: current.rootID,
})
})
return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<Show when={resolved.data && !resolved.isPlaceholderData}>
<TargetSessionPage />
</Show>
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</Show>
</TargetServerScopedProviders>
)
}
function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionProviders>
<Session />
</SessionProviders>
</Show>
)
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
function SelectedServerLayout(props: ParentProps) {
function SelectedServerProviders(props: ParentProps) {
return (
<ServerKey>
<ServerSDKProvider>
<ServerSyncProvider>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
<ServerSyncProvider>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</ServerKey>
)
}
// Wraps /new-session. It resolves the draft's target server and provides the
// server-scoped shell for that server — without ServerKey, so the page never depends
// on the globally "selected" server.
function DraftServerLayout(props: ParentProps) {
const server = useServer()
const tabs = useTabs()
const [search] = useSearchParams<{ draftId?: string }>()
const conn = createMemo(() => {
const id = search.draftId
if (!id) return undefined
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
if (!draft) return undefined
return server.list.find((c) => ServerConnection.key(c) === draft.server)
})
function LegacyServerLayout(props: ParentProps) {
return (
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider>
<SelectedServerProviders>
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
</SelectedServerProviders>
)
}
@@ -126,37 +199,37 @@ function DraftRoute() {
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
<Show
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
keyed
fallback={<Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show>
</Show>
)
}
function ResolvedDraftRoute(props: { draftID: string }) {
const tabs = useTabs()
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
)
// Key on the directory so retargeting the draft's project re-instantiates the
// directory-scoped providers while keeping the same draft id. The draft's target
// server is provided by DraftServerLayout, so changing only the server updates the
// SDK/sync hooks without remounting the composer.
const directory = () => draft()?.directory
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const server = useServer()
const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server))
const directory = () => props.draft.directory
const serverKey = () => props.draft.server
return (
<Show when={directory()} keyed>
{(dir) => (
<SDKProvider directory={dir}>
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
)}
</Show>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<TargetServerScopedProviders directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</TargetServerScopedProviders>
</ServerSyncProvider>
</ServerSDKProvider>
)
}
@@ -210,32 +283,61 @@ function BodyDesignClass() {
// shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) {
return (
<SettingsProvider>
<>
<BodyDesignClass />
<CommandProvider>
<HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider>
</SettingsProvider>
</>
)
}
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
// each per-route server layout so they resolve to that route's server (selected vs
// draft). The Layout remounts when crossing between those groups.
function ServerScopedShell(props: ParentProps) {
// Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider>
<PermissionProvider directory={props.directory}>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<Layout>{props.children}</Layout>
</ModelsProvider>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)
}
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
function NewAppLayout(props: ParentProps) {
return (
<SelectedServerProviders>
<ServerScopedProviders>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
</SelectedServerProviders>
)
}
function TargetServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider directory={props.directory}>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider>
</NotificationProvider>
</PermissionProvider>
)
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
@@ -418,11 +520,9 @@ export function AppInterface(props: {
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
// Command/Highlights) and stays mounted across every route. The server-scoped
// providers and the visual Layout live in the per-route layouts below, so they
// resolve to that route's server (selected for most routes, the draft's server for
// /new-session). appChildren is server-agnostic, so it renders here once.
// The visual new layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const ServerShell = (shellProps: ParentProps) => (
<QueryProvider>
<SharedProviders>
@@ -439,28 +539,57 @@ export function AppInterface(props: {
servers={props.servers}
>
<GlobalProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<ServerShell>{routerProps.children}</ServerShell>
</TabsProvider>
)}
>
<Route component={SelectedServerLayout}>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Route component={DraftServerLayout}>
<Route path="/new-session" component={DraftRoute} />
</Route>
</Dynamic>
</ConnectionGate>
<SettingsProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</TabsProvider>
)}
>
<Routes />
</Dynamic>
</Show>
</ConnectionGate>
</SettingsProvider>
</GlobalProvider>
</ServerProvider>
)
}
function Routes() {
const settings = useSettings()
return (
<>
<Route component={LegacyServerLayout}>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} />
<Route
path="/:dir/session/:id"
component={() => {
const server = useServer()
const { id } = useParams()
return <Navigate href={`/server/${server.key}/session/${id}`} />
}}
/>
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</>
)
}
+1 -1
View File
@@ -363,7 +363,7 @@ export function DebugBar() {
return (
<aside
aria-label={language.t("debugBar.ariaLabel")}
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
class="pointer-events-auto fixed bottom-3 right-3 z-50 hidden w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] md:block sm:bottom-4 sm:right-4 sm:w-[324px]"
>
<div class="grid grid-cols-5 gap-px font-mono">
<Cell
@@ -19,6 +19,7 @@ import {
pickerMode,
preloadTreeDirectories,
cleanPickerInput,
createPriorityTaskQueue,
createDirectorySearch,
currentPickerSuggestions,
displayPickerPath,
@@ -55,6 +56,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [error, setError] = createSignal(false)
const [rootValid, setRootValid] = createSignal(false)
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
const loads = createPriorityTaskQueue<Array<{ name: string; type: "file" | "directory" }> | undefined>(3)
const advanced = new Set<string>()
let tree: FileTree | undefined
let container: HTMLDivElement | undefined
@@ -102,16 +104,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
})
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
async function load(path: string, generation: number, preload = true) {
async function load(path: string, generation: number, eager = false) {
const key = path.replace(/\/+$/, "")
setError(false)
const absolute = absoluteTreePath(root(), key)
const existing = listings.get(key)
if (existing && !eager) loads.promote(`${generation}:${key}`)
const request =
listings.get(key) ??
sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
.catch(() => undefined)
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 ?? [])
.catch(() => undefined)
})
listings.set(key, request)
const nodes = await request
if (!activeTreeNavigation(generation, navigation)) return false
@@ -121,8 +128,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
return false
}
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
if (preload && advanceTreePreload(advanced, key)) {
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
if (!eager && advanceTreePreload(advanced, key)) {
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
}
return true
}
@@ -15,6 +15,7 @@ import {
treePathWithin,
currentPickerSuggestions,
createDirectorySearch,
createPriorityTaskQueue,
displayPickerPath,
pickerParent,
pickerRoot,
@@ -168,6 +169,41 @@ test("advances preloading once for every expanded directory", () => {
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
})
test("limits background tasks and prioritizes newly requested work", async () => {
const queue = createPriorityTaskQueue<void>(2)
const first = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const started: string[] = []
let active = 0
let maximum = 0
const task = (name: string, blocker?: Promise<void>) => async () => {
started.push(name)
active++
maximum = Math.max(maximum, active)
await blocker
active--
}
const running = [
queue.schedule("first", "background", task("first", first.promise)),
queue.schedule("second", "background", task("second", second.promise)),
queue.schedule("preload", "background", task("preload")),
queue.schedule("opened", "user", task("opened")),
]
await Promise.resolve()
expect(started).toEqual(["first", "second"])
first.resolve()
await running[0]
await Promise.resolve()
expect(started).toEqual(["first", "second", "opened"])
second.resolve()
await Promise.all(running)
expect(started).toEqual(["first", "second", "opened", "preload"])
expect(maximum).toBe(2)
})
test("clamps bridged tree wheel scrolling", () => {
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
@@ -138,6 +138,79 @@ export function activeTreeNavigation(request: number, current: number) {
return request === current
}
export function createPriorityTaskQueue<T>(concurrency: number) {
type Job = {
key: string
priority: "user" | "background"
promise: Promise<T>
run: () => void
}
const jobs = new Map<string, Job>()
const user: Job[] = []
const background: Job[] = []
let active = 0
const drain = () => {
while (active < concurrency) {
const job = user.pop() ?? background.shift()
if (!job) return
active++
job.run()
}
}
const schedule = (key: string, priority: Job["priority"], task: () => Promise<T>) => {
const existing = jobs.get(key)
if (existing) {
if (priority === "user") promote(key)
return existing.promise
}
const deferred = Promise.withResolvers<T>()
const job: Job = {
key,
priority,
promise: deferred.promise,
run: () => {
const complete = () => {
active--
jobs.delete(key)
drain()
}
Promise.resolve()
.then(task)
.then(
(value) => {
complete()
deferred.resolve(value)
},
(error) => {
complete()
deferred.reject(error)
},
)
},
}
jobs.set(key, job)
;(priority === "user" ? user : background).push(job)
drain()
return job.promise
}
const promote = (key: string) => {
const job = jobs.get(key)
if (!job || job.priority === "user") return
const index = background.indexOf(job)
if (index === -1) return
background.splice(index, 1)
job.priority = "user"
user.push(job)
}
return { schedule, promote }
}
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
}
+1 -1
View File
@@ -11,7 +11,7 @@ export function HelpButton() {
return (
<Show when={!state.dismissed}>
<div class="fixed bottom-4 right-4 z-50">
<div class="fixed bottom-4 right-4 z-50 hidden md:block">
<Popover
open={shown()}
onOpenChange={setShown}
+1 -1
View File
@@ -1379,7 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource(
() => prompt.ready().promise,
() => prompt.ready.promise,
(p) => p,
)
@@ -27,7 +27,7 @@ let variant: string | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const prompt = {
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
@@ -388,12 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID)
tabs.promoteDraft(draftID, {
server: server.key,
dirBase64: base64Encode(sessionDirectory),
sessionId: session.id,
})
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
}
@@ -10,6 +10,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
@@ -158,6 +159,7 @@ export function SessionHeader() {
const isV2 = settings.general.newLayoutDesigns
const search = settings.visibility.search
const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -236,6 +238,7 @@ export function SessionHeader() {
statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: command.keybind("review.toggle"),
reviewVisible: isDesktop(),
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}))
@@ -518,6 +521,7 @@ type SessionHeaderV2ActionsState = {
statusLabel: string
reviewLabel: string
reviewKeybind: string
reviewVisible: boolean
reviewOpened: boolean
onReviewToggle: () => void
}
@@ -530,20 +534,22 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
<StatusPopoverV2 />
</Tooltip>
</Show>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />}
/>
</TooltipKeybind>
<Show when={props.state.reviewVisible}>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />}
/>
</TooltipKeybind>
</Show>
</div>
)
}
@@ -1,4 +1,5 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
@@ -89,6 +90,7 @@ export const SettingsGeneralV2: Component = () => {
const dialog = useDialog()
const params = useParams()
const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
@@ -345,6 +347,20 @@ export const SettingsGeneralV2: Component = () => {
/>
</div>
</SettingsRowV2>
<Show when={mobile()}>
<SettingsRowV2
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
description={language.t("settings.general.row.mobileTitlebarBottom.description")}
>
<div data-action="settings-mobile-titlebar-bottom">
<Switch
checked={settings.general.mobileTitlebarPosition() === "bottom"}
onChange={(checked) => settings.general.setMobileTitlebarPosition(checked ? "bottom" : "top")}
/>
</div>
</SettingsRowV2>
</Show>
</SettingsListV2>
</div>
)
@@ -1,4 +1,3 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
@@ -14,7 +13,7 @@ import { ServerConnection, serverName } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server"
import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list"
import { isWslServer, useFilteredWslServers, WslAddServerButton, WslServerSettings } from "@/wsl/settings"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
import "./settings-v2.css"
export const SettingsServersV2: Component = () => {
@@ -55,10 +54,7 @@ export const SettingsServersV2: Component = () => {
>
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
{language.t("dialog.server.add.button")}
</ButtonV2>
<WslAddServerButton />
<AddServerMenu onAddServer={openAdd} />
</div>
<Show when={showSearch()}>
<div class="settings-v2-tab-search">
@@ -167,6 +167,15 @@
background-color: var(--v2-background-bg-layer-01);
}
@media (max-width: 639px) {
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
[data-slot="tabs-v2-list"] {
width: 144px;
min-width: 144px;
padding-inline: 8px;
}
}
.settings-v2-nav-footer {
display: flex;
flex-direction: column;
+149 -110
View File
@@ -22,26 +22,26 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
import { LayoutRoute, useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global"
import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
import { tabHref, useTabs } from "@/context/tabs"
import "./titlebar.css"
import { Session } from "@opencode-ai/sdk/v2"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createTabPromptState } from "@/context/prompt"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -87,6 +87,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const location = useLocation()
const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mobile = createMediaQuery("(max-width: 767px)")
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@@ -98,6 +100,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
if (useV2Titlebar() && mobile()) {
const inset = bottom() ? "env(safe-area-inset-bottom, 0px)" : "env(safe-area-inset-top, 0px)"
return `calc(${height}px + ${inset})`
}
if (mac()) return `${height / zoom()}px`
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
return undefined
@@ -235,10 +241,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
"shrink-0 relative flex flex-row": true,
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
"h-10 bg-background-base overflow-hidden": !useV2Titlebar(),
"order-last": bottom(),
}}
style={{
"min-height": minHeight(),
"padding-left": mac() ? `${84 / zoom()}px` : 0,
"padding-top": useV2Titlebar() && mobile() && !bottom() ? "env(safe-area-inset-top, 0px)" : undefined,
"padding-bottom": bottom() ? "env(safe-area-inset-bottom, 0px)" : undefined,
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0,
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": electronWindows()
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
@@ -252,22 +261,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch>
<Match when={useV2Titlebar()}>
{(_) => {
const serverSync = useServerSync()
const navigate = useNavigate()
const layout = useLayout()
const newSessionHref = () => {
if (params.dir) return `/${params.dir}/session`
const project = layout.projects.list()[0]
if (!project) return "/"
return `/${base64Encode(project.worktree)}/session`
}
const global = useGlobal()
const tabs = useTabs()
const tabsStore = tabs.store
const tabsStoreActions = tabs
const [session] = createResource(
() => {
const route = layout.route()
if (route.type !== "session") return undefined
const conn = global.servers
.list()
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
return conn ? { route, sdk: global.createServerCtx(conn).sdk } : undefined
},
({ route, sdk }) =>
sdk.client.session
.get({ sessionID: route.sessionId })
.then((x) => x.data)
.catch(() => {}),
)
const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return
@@ -280,10 +295,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
)
if (main) return main
const sync = serverSync().createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (session?.parentID) {
const parentID = session.parentID
const s = session()
if (s?.parentID) {
const parentID = s.parentID
const parent = tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
)
@@ -304,15 +318,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}
if (route.type === "session") {
const sync = serverSync().createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (!session) return
const sessionId = session.parentID ?? session.id
const next = {
server: route.server ?? server.key,
dirBase64: route.dirBase64,
sessionId,
}
const s = session()
if (!s) return
const sessionId = s.parentID ?? s.id
const next = { server: route.server ?? server.key, sessionId }
tabsStoreActions.addSessionTab(next)
}
})
@@ -323,7 +332,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
tabsStoreActions.removeSessions(detail)
})
const openNewTab = () => navigate(newSessionHref())
const openNewTab = () => {
const route = layout.route()
const activeSession = session()
if (route.type === "session" && activeSession) {
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
return
}
const current = layout.projects.list()[0]
if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
return
}
const fallback = global.servers.list().flatMap((conn) => {
const project = global.createServerCtx(conn).projects.list()[0]
return project ? [{ server: ServerConnection.key(conn), project }] : []
})[0]
if (!fallback) return
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
}
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
command.register("titlebar-home", () => [
@@ -393,22 +423,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
if (next) tabs.select(next)
},
},
...Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
return {
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => {
const tab = tabsStore[index]
if (tab) tabs.select(tab)
},
}
}),
].filter((v) => v !== undefined)
})
@@ -421,10 +435,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return (
<div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 pr-3 pt-2"
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
classList={{
"pl-2": mac(),
"pl-4": !mac(),
"pt-2": !bottom(),
"pb-2": bottom(),
"md:pl-2": mac(),
"md:pl-4": !mac(),
}}
>
<ChannelIndicator />
@@ -470,6 +486,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<For each={tabsStore}>
{(tab, i) => {
let ref!: HTMLDivElement
useTabShortcut(i, () => tabs.select(tab))
const divider = () =>
i() !== 0 && (
@@ -495,25 +512,61 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
}
const sdk = createMemo(() => {
const conn = server.list.find((s) => ServerConnection.key(s) === tab.server)
if (!conn) return null
const { sdk } = global.createServerCtx(conn)
return sdk
})
const [session] = createResource(
() => {
const id = tab.sessionId
const _sdk = sdk()
if (!_sdk) return null
return { id, sdk: _sdk }
},
({ id, sdk }) =>
sdk.client.session
.get({ sessionID: id })
.then((x) => x.data)
.catch(() => undefined),
)
createEffect(() => {
if (tab.type !== "session") return
const _sdk = sdk()
if (!_sdk) return
const sess = session()
if (!sess) return
createTabPromptState(tabs, tab, _sdk.scope, {
dir: base64Encode(sess.directory),
id: sess.id,
})
})
return (
<>
{divider()}
<TabNavItem
ref={ref}
href={tabHref(tab)}
server={tab.server}
directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId}
onNavigate={() => {
tabs.select(tab)
<Show when={session()}>
{(session) => (
<TabNavItem
ref={ref}
href={tabHref(tab)}
server={tab.server}
sessionId={tab.sessionId}
session={session()}
onNavigate={() => {
tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
/>
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
/>
)}
</Show>
</>
)
}}
@@ -563,8 +616,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
as="a"
href={newSessionHref()}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</Show>
@@ -789,11 +841,30 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
)
}
function useTabShortcut(index: () => number, onSelect: () => void) {
const command = useCommand()
command.register(() => {
const number = index() + 1
if (number > 9) return []
return [
{
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
hidden: true,
onSelect,
},
]
})
}
function TabNavItem(props: {
ref?: HTMLDivElement
href: string
server: ServerConnection.Key
directory: string
sessionId?: string
hideClose?: boolean
onClose: () => void
@@ -801,43 +872,31 @@ function TabNavItem(props: {
active?: boolean
activeServer: boolean
forceTruncate?: boolean
session: Session
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
const global = useGlobal()
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.createServerCtx(conn)
})
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
const [session] = createResource(
() => {
const ctx = dirSyncCtx()
if (!ctx || !props.sessionId) return
return [props.sessionId, ctx] as const
},
async ([sessionId, dirSyncCtx]) => {
await dirSyncCtx.session.sync(sessionId).catch(() => {})
return dirSyncCtx.session.get(sessionId)
},
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
)
return (
<div
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={session.latest}>
<Show when={props.session}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
@@ -851,9 +910,9 @@ function TabNavItem(props: {
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
>
<span data-slot="project-avatar-slot">
<ProjectTabAvatar
<SessionTabAvatar
project={project()}
directory={props.directory}
directory={session().directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
@@ -887,26 +946,6 @@ function TabNavItem(props: {
)
}
function ProjectTabAvatar(props: {
project?: LocalProject
directory: string
sessionId: string
activeServer: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
loading={state.loading()}
/>
)
}
function DraftTabItem(props: {
ref?: HTMLDivElement
href: string
@@ -924,7 +963,7 @@ function DraftTabItem(props: {
<div
ref={props.ref}
data-active={props.active}
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
@@ -969,7 +1008,7 @@ function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: s
return (
<div
ref={props.ref}
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
+4 -1
View File
@@ -2,12 +2,14 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file"
import { useSDK } from "./sdk"
export type LineComment = {
id: string
@@ -202,6 +204,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false,
init: () => {
const params = useParams()
const sdk = useSDK()
const serverSDK = useServerSDK()
const cache = createScopedCache(
(key) => {
@@ -228,7 +231,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
return cache.get(key).value
}
const session = createMemo(() => load(params.dir!, params.id))
const session = createMemo(() => load(base64Encode(sdk().directory), params.id))
return {
ready: () => session().ready(),
+2 -1
View File
@@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
@@ -65,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const scope = createMemo(() => sdk().directory)
const path = createPathHelpers(scope)
const tabs = layout.tabs(() =>
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)),
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
)
const inflight = new Map<string, Promise<void>>()
+13 -3
View File
@@ -16,6 +16,7 @@ import { createPathHelpers } from "./file/path"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -79,7 +80,7 @@ export type LayoutRoute =
| { type: "home" }
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? []
@@ -131,6 +132,14 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
return { type: "draft", draftID }
}
if (parts[0] === "server" && parts[2] === "session" && parts[3]) {
return {
type: "session",
sessionId: parts[3],
server: requireServerKey(parts[1]),
}
}
const dirBase64 = parts[0]
const dir = decode64(dirBase64)
if (!dir) return { type: "home" }
@@ -138,7 +147,7 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
if (parts[1] !== "session") return { type: "home" }
const id = parts[2]
if (id) return { type: "session", dir, dirBase64, sessionId: id }
if (id) return { type: "session", sessionId: id }
return { type: "dir-new-sesssion", dir, dirBase64 }
}
@@ -154,6 +163,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const route = createMemo(() => {
const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value
if (value.server) return value
return { ...value, server: server.key }
})
@@ -572,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
handoff: {
tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() })
setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
},
clearTabs() {
if (!store.handoff?.tabs) return
+4 -4
View File
@@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk"
@@ -108,7 +108,7 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
gate: false,
init: () => {
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
@@ -119,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const empty: Notification[] = []
const currentDirectory = createMemo(() => {
return decode64(params.dir)
return props.directory?.() ?? decode64(params.dir)
})
const currentSession = createMemo(() => params.id)
const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
+4 -4
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
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"
@@ -47,13 +47,13 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
gate: false,
init: () => {
init: (props: { directory?: Accessor<string | undefined> }) => {
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const permissionsEnabled = createMemo(() => {
const directory = decode64(params.dir)
const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return false
const [store] = serverSync().child(directory)
return hasPermissionPromptRules(store.config.permission)
@@ -85,7 +85,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
// When config has permission: "allow", auto-enable directory-level auto-accept
createEffect(() => {
if (!ready()) return
const directory = decode64(params.dir)
const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return
const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission
+47 -7
View File
@@ -1,12 +1,17 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/core/util/encode"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
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 { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import { useServer } from "./server"
import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings"
interface PartBase {
content: string
@@ -180,7 +185,7 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
function createPromptSession(serverScope: ServerScope, scope: Scope) {
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
promptTarget(serverScope, scope),
createStore<PromptStore>(promptStore()),
@@ -189,6 +194,12 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) {
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),
@@ -246,18 +257,28 @@ export function createPromptState() {
const [store, setStore] = createStore<PromptStore>(promptStore())
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready: () => ready,
ready,
...createPromptStateValue(store, setStore),
}
}
export const createTabPromptState = (
tabs: ReturnType<typeof useTabs>,
tab: Tab,
...args: Parameters<typeof createPromptSession>
) => tabs.state(tab, "prompt", () => createPromptSession(...args))
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
name: "Prompt",
gate: false,
init: () => {
const params = useParams()
const params = useParams<{ serverKey?: string; id?: string }>()
const sdk = useSDK()
const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK()
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => {
@@ -280,7 +301,25 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const owner = getOwner()
const tab = createMemo<Tab | undefined>(() => {
if (!settings.general.newLayoutDesigns()) return
if (search.draftId) {
return tabs.store.find((item) => item.type === "draft" && item.draftID === search.draftId)
}
if (!params.id) return
const serverKey = params.serverKey ? requireServerKey(params.serverKey) : server.key
return (
tabs.store.find(
(item) => item.type === "session" && item.server === serverKey && item.sessionId === params.id,
) ?? { type: "session", server: serverKey, sessionId: params.id }
)
})
const load = (scope: Scope) => {
const current = tab()
if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}
const key = scopeKey(scope)
const existing = cache.get(key)
if (existing) {
@@ -303,12 +342,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }),
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
)
const pick = (scope?: Scope) => (scope ? load(scope) : session())
const ready = createPromptReady(session)
return {
ready: () => session().ready,
ready,
current: () => session().current(),
cursor: () => session().cursor(),
dirty: () => session().dirty(),
+9
View File
@@ -33,6 +33,7 @@ export interface Settings {
editToolPartsExpanded: boolean
showSessionProgressBar: boolean
showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean
}
appearance: {
@@ -118,6 +119,7 @@ const defaultSettings: Settings = {
editToolPartsExpanded: false,
showSessionProgressBar: true,
showCustomAgents: false,
mobileTitlebarPosition: "top",
},
appearance: {
fontSize: 14,
@@ -248,6 +250,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
mobileTitlebarPosition: withFallback(
() => store.general?.mobileTitlebarPosition,
defaultSettings.general.mobileTitlebarPosition,
),
setMobileTitlebarPosition(value: "top" | "bottom") {
setStore("general", "mobileTitlebarPosition", value)
},
newLayoutDesigns,
setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value)
+33
View File
@@ -0,0 +1,33 @@
import { createRoot, type Owner } from "solid-js"
type Entry = {
value: unknown
dispose: VoidFunction
}
export function createTabMemory(owner: Owner | null) {
const entries = new Map<string, Map<string, Entry>>()
const remove = (key: string) => {
const state = entries.get(key)
if (!state) return
for (const entry of state.values()) entry.dispose()
entries.delete(key)
}
return {
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)
const existing = state.get(name)
if (existing) return existing.value as T
const entry = createRoot((dispose) => ({ value: init(), dispose }), owner)
state.set(name, entry)
return entry.value
},
remove,
dispose() {
for (const key of entries.keys()) remove(key)
},
}
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./tab-memory"
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
createRoot((dispose) => {
const memory = createTabMemory(getOwner())
let disposed = 0
const first = memory.ensure("tab", "prompt", () => {
onCleanup(() => disposed++)
return { value: "prompt" }
})
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
memory.remove("tab")
expect(disposed).toBe(1)
expect(memory.ensure("tab", "prompt", () => ({ value: "new" }))).not.toBe(first)
dispose()
})
})
})
+26 -27
View File
@@ -1,19 +1,19 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, startTransition } from "solid-js"
import { createEffect, getOwner, onCleanup, startTransition } from "solid-js"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
export type SessionTab = {
type: "session"
server: ServerConnection.Key
dirBase64: string
sessionId: string
}
@@ -34,16 +34,12 @@ type RecentTab = {
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) =>
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}`
tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId)
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) {
const dirBase64 = base64Encode(session.directory)
return tabs.some(
(tab) =>
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
)
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
@@ -71,6 +67,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const params = useParams()
const navigate = useNavigate()
const location = useLocation()
const memory = createTabMemory(getOwner())
const closing = new Set<string>()
let recentWrite = 0
@@ -94,37 +91,39 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
onCleanup(memory.dispose)
createEffect(() => {
if (!ready() || !recentReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
const next = store.filter((tab) => servers.has(tab.server))
if (next.length !== store.length) setStore(() => next)
if (next.length !== store.length) {
for (const tab of store) {
if (!servers.has(tab.server)) memory.remove(tabKey(tab))
}
setStore(() => next)
}
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
})
const navigateTab = (tab: Tab) => {
const href = tabHref(tab)
setRecentKey(tabKey(tab))
if (tab.server === server.key) {
navigate(href)
return
}
void startTransition(() => {
server.setActive(tab.server)
navigate(href)
})
navigate(href)
}
const actions = {
addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab }
if (closing.has(tabKey(next))) return
const existing = store.find((item) => tabKey(item) === tabKey(next))
if (existing) return existing
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
tabs.push(next)
}),
)
return next
},
draft(draftID: string) {
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
@@ -163,6 +162,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
if (active) navigateTab(next)
})
memory.remove(`draft:${draftID}`)
removeDraftPersisted(draftID)
},
removeTab: (index: number) => {
@@ -182,12 +182,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
memory.remove(key)
if (draftID) removeDraftPersisted(draftID)
},
removeServer(key: ServerConnection.Key) {
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
const removed = store.filter((tab) => tab.server === key).map(tabKey)
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
for (const key of removed) memory.remove(key)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
@@ -195,11 +197,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
removeSessions: (input: SessionTabsRemovedDetail) => {
const removed = store
.filter(
(tab) =>
tab.type === "session" &&
tab.server === server.key &&
atob(tab.dirBase64) === input.directory &&
input.sessionIDs.includes(tab.sessionId),
(tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
)
.map(tabKey)
void startTransition(() => {
@@ -211,7 +209,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
? tabHref({
type: "session",
server: server.key,
dirBase64: params.dir,
sessionId: params.id,
})
: undefined
@@ -224,14 +221,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const removedCurrent =
currentTab?.type === "session" &&
currentTab.server === server.key &&
atob(currentTab.dirBase64) === input.directory &&
sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab || tab.type !== "session") continue
if (tab.server !== server.key) continue
if (atob(tab.dirBase64) !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
}
@@ -246,6 +241,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
})
for (const key of removed) memory.remove(key)
},
select: navigateTab,
remember(tab: Tab) {
@@ -265,6 +261,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}
navigate("/")
},
state<T>(tab: Tab, name: string, init: () => T) {
return memory.ensure(tabKey(tab), name, init)
},
}
return { ...actions, store, ready, recentReady }
+7 -5
View File
@@ -4,7 +4,8 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router"
import { useSDK, type DirectorySDK } from "./sdk"
import type { Platform } from "./platform"
import { useServer } from "./server"
import { useServerSDK } from "./server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
@@ -374,10 +375,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false,
init: () => {
const sdk = useSDK()
const server = useServer()
const serverSDK = useServerSDK()
const params = useParams()
const cache = new Map<string, TerminalCacheEntry>()
const scope = server.scope()
const scope = () => serverSDK().scope
const directory = createMemo(() => base64Encode(sdk().directory))
caches.add(cache)
onCleanup(() => caches.delete(cache))
@@ -421,11 +423,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope))
const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope()))
createEffect(
on(
() => ({ dir: params.dir, id: params.id, scope }),
() => ({ dir: directory(), id: params.id, scope: scope() }),
(next, prev) => {
if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
+5
View File
@@ -589,6 +589,8 @@ export const dict = {
"home.title": "Home",
"home.projects": "Projects",
"home.project.add": "Add project",
"home.server.collapse": "Collapse server projects",
"home.server.expand": "Expand server projects",
"home.sessions.search.placeholder": "Search sessions",
"home.sessions.search.sessions": "Sessions",
"home.sessions.search.noResults": "No sessions found for {{query}}",
@@ -839,6 +841,9 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description":
"Place the title bar and session tabs at the bottom of the screen on mobile",
"settings.general.row.showCustomAgents.title": "Custom agents",
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
+22 -8
View File
@@ -2,26 +2,40 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
import { type Accessor, createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
import type { ServerConnection } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) {
export function DirectoryDataProvider(
props: ParentProps<{
directory: string | Accessor<string>
draftID?: string
server?: Accessor<ServerConnection.Key | undefined>
}>,
) {
const location = useLocation()
const navigate = useNavigate()
const params = useParams()
const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory))
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => {
const server = props.server?.()
if (server) return sessionHref(server, sessionID)
return `/${slug()}/session/${sessionID}`
}
createEffect(() => {
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
if (props.draftID) return
if (props.draftID || props.server?.()) return
const next = sync().data.path.directory
if (!next || next === props.directory) return
if (!next || next === directory()) return
const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
@@ -37,9 +51,9 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
return (
<DataProvider
data={sync().data}
directory={props.directory}
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
directory={directory()}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onSessionHref={href}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
+112 -71
View File
@@ -1,5 +1,17 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { batch, createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js"
import {
batch,
createEffect,
createMemo,
For,
Match,
on,
onCleanup,
onMount,
Show,
startTransition,
Switch,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createStore } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
@@ -32,21 +44,20 @@ import {
displayName,
getProjectAvatarSource,
homeProjectDirectories,
homeProjectNavigation,
type HomeProjectSelection,
projectForSession,
sortedRootSessions,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global"
import { useCommand } from "@/context/command"
import { useSettings } from "@/context/settings"
import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health"
import { Persist, persisted } from "@/utils/persist"
const HOME_SESSION_LIMIT = 64
const HOME_ROW_LAYOUT =
@@ -113,16 +124,7 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}`
}
export default function Home() {
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
<HomeDesign />
</Show>
)
}
function HomeDesign() {
export function NewHome() {
const sync = useServerSync()
const layout = useLayout()
const platform = usePlatform()
@@ -134,6 +136,7 @@ function HomeDesign() {
const global = useGlobal()
const command = useCommand()
const notification = useNotification()
const tabs = useTabs()
let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({
search: "",
@@ -270,21 +273,11 @@ function HomeDesign() {
openProjectNewSession(conn, project.worktree)
}
function navigateOnServer(conn: ServerConnection.Any, href: string) {
const next = homeProjectNavigation(server.key, ServerConnection.key(conn), href)
if (!next.server) {
navigate(next.href)
return
}
pendingHomeNavigation = next
server.setActive(next.server)
}
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
const ctx = global.createServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
navigateOnServer(conn, `/${base64Encode(directory)}/session`)
tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
function editProject(conn: ServerConnection.Any, project: LocalProject) {
@@ -313,7 +306,10 @@ function HomeDesign() {
const ctx = global.createServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
})
}
function chooseProject(conn: ServerConnection.Any) {
@@ -321,8 +317,6 @@ function HomeDesign() {
addProjects(conn, homeProjectDirectories(result))
}
const server = global.createServerCtx(conn)
pickDirectory({
server: conn,
title: language.t("command.project.open"),
@@ -339,7 +333,7 @@ function HomeDesign() {
return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
<HomeProjectColumn
projects={projects()}
selected={state.selection}
@@ -365,7 +359,7 @@ function HomeDesign() {
/>
<section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-12"
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12"
aria-label={language.t("sidebar.project.recentSessions")}
>
<HomeSessionSearch
@@ -416,7 +410,7 @@ function HomeDesign() {
record={record}
server={state.selection.server}
activeServer={state.selection.server === server.key}
openSession={openSession}
onClick={() => openSession(record.session)}
/>
)}
</For>
@@ -429,6 +423,12 @@ function HomeDesign() {
</div>
</ScrollView>
</section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div>
</div>
)
@@ -452,8 +452,15 @@ function HomeProjectColumn(props: {
const global = useGlobal()
const dialog = useDialog()
const controller = useServerManagementController({ navigateOnAdd: false })
const [state, setState] = persisted(
Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }),
)
return (
<aside class="flex min-w-0 flex-col lg:pt-[52px] mt-14 gap-4" aria-label={props.language.t("home.projects")}>
<aside
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
aria-label={props.language.t("home.projects")}
>
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show when={global.servers.list().length === 1}>
@@ -477,20 +484,23 @@ function HomeProjectColumn(props: {
const key = ServerConnection.key(item)
const healthy = () => !!global.servers.health[key]?.healthy
const serverCtx = global.createServerCtx(item)
const collapsed = () => !!state.collapsed[key]
return (
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<HomeServerRow
server={item}
selected={props.selected.server === key && !props.selected.directory}
healthy={healthy()}
collapsed={collapsed()}
health={global.servers.health[key]}
controller={controller}
focusServer={props.focusServer}
chooseProject={props.chooseProject}
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
toggleCollapsed={() => setState("collapsed", key, !state.collapsed[key])}
language={props.language}
/>
<Show when={healthy()}>
<Show when={healthy() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
</Show>
@@ -499,37 +509,55 @@ function HomeProjectColumn(props: {
}}
</For>
</Show>
<div class="mt-4 flex min-w-0 flex-col gap-1">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openSettings}
>
<IconV2 name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
</button>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openHelp}
>
<IconV2 name="help" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</button>
</div>
<HomeUtilityNav
class="mt-4 hidden lg:flex"
openSettings={props.openSettings}
openHelp={props.openHelp}
language={props.language}
/>
</aside>
)
}
function HomeUtilityNav(props: {
class?: string
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1`}>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openSettings}
>
<IconV2 name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
</button>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openHelp}
>
<IconV2 name="help" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</button>
</div>
)
}
function HomeServerRow(props: {
server: ServerConnection.Any
selected: boolean
healthy: boolean
collapsed: boolean
health: ServerHealth | undefined
controller: ReturnType<typeof useServerManagementController>
focusServer: (server: ServerConnection.Any) => void
chooseProject: (server: ServerConnection.Any) => void
openEdit: (server: ServerConnection.Http) => void
toggleCollapsed: () => void
language: ReturnType<typeof useLanguage>
}) {
const [state, setState] = createStore({ menuOpen: false })
@@ -542,7 +570,30 @@ function HomeServerRow(props: {
disabled={!props.healthy}
onClick={() => props.focusServer(props.server)}
>
<div class="flex size-4 shrink-0 items-center justify-center">
<Show when={props.healthy}>
<span
data-action="home-server-collapse"
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
aria-label={
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}
aria-expanded={!props.collapsed}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.toggleCollapsed()
}}
onPointerDown={(event) => event.preventDefault()}
>
<IconV2
name="chevron-down"
size="small"
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
/>
</span>
</Show>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} />
</div>
<span class="flex min-w-0 items-center gap-1">
@@ -707,21 +758,6 @@ function HomeProjectAvatar(props: { project: LocalProject }) {
)
}
function HomeSessionAvatar(props: { project: LocalProject; session: Session; activeServer: boolean }) {
const directory = () => props.session.directory
const sessionId = () => props.session.id
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
unread={state.unread()}
loading={state.loading()}
/>
)
}
function HomeSessionLeading(props: {
project: LocalProject
session: Session
@@ -739,7 +775,12 @@ function HomeSessionLeading(props: {
style={{ right: "calc(100% + 12px)" }}
/>
</Show>
<HomeSessionAvatar project={props.project} session={props.session} activeServer={props.activeServer} />
<SessionTabAvatar
project={props.project}
directory={props.session.directory}
sessionId={props.session.id}
activeServer={props.activeServer}
/>
</div>
)
}
@@ -1024,7 +1065,7 @@ function HomeSessionRow(props: {
record: HomeSessionRecord
server: ServerConnection.Key
activeServer: boolean
openSession: (session: Session) => void
onClick: () => void
}) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
@@ -1033,7 +1074,7 @@ function HomeSessionRow(props: {
type="button"
data-component="home-session-row"
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
onClick={() => props.openSession(props.record.session)}
onClick={props.onClick}
>
<HomeSessionLeading
project={props.record.project}
@@ -1093,7 +1134,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
].filter((group) => group.sessions.length > 0)
}
function LegacyHome() {
export function LegacyHome() {
const sync = useServerSync()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
+45
View File
@@ -0,0 +1,45 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useNotification } from "@/context/notification"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const notification = useNotification()
const navigate = useNavigate()
const params = useParams<{ id?: string }>()
setNavigate(navigate)
createEffect(() => setV2Toast(true))
createEffect(() => {
if (!notification.ready() || !params.id) return
notification.session.markViewed(params.id)
})
const update: TitlebarUpdate = {
version: () => {
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
},
installing: () => platform.updater?.state().status === "installing",
install: () => void platform.updater?.install(),
}
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar update={update} />
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
</main>
{import.meta.env.DEV && <DebugBar />}
<HelpButton />
<ToastRegion v2 />
</div>
)
}
+149 -170
View File
@@ -13,7 +13,7 @@ import {
type Accessor,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useServerSync } from "@/context/server-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -92,7 +92,7 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) {
export default function LegacyLayout(props: ParentProps) {
const serverSDK = useServerSDK()
const [store, setStore, , ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
@@ -131,10 +131,8 @@ export default function Layout(props: ParentProps) {
const command = useCommand()
const theme = useTheme()
const language = useLanguage()
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => setV2Toast(newDesign()))
createEffect(() => setV2Toast(false))
const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
@@ -158,7 +156,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({
autoselect: !initialDirectory && !newDesign(),
autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
@@ -996,7 +994,7 @@ export default function Layout(props: ParentProps) {
id: "sidebar.toggle",
title: language.t("command.sidebar.toggle"),
category: language.t("command.category.view"),
keybind: newDesign() ? undefined : "mod+b",
keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(),
},
{
@@ -1134,20 +1132,19 @@ export default function Layout(props: ParentProps) {
},
]
if (!newDesign())
Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
commands.push({
id: `project.${number}`,
category: language.t("command.category.project"),
title: `Open Project {number}`,
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => navigateToProjectIndex(index),
})
Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
commands.push({
id: `project.${number}`,
category: language.t("command.category.project"),
title: `Open Project {number}`,
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => navigateToProjectIndex(index),
})
})
for (const [id] of availableThemeEntries()) {
commands.push({
@@ -1812,7 +1809,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
document.documentElement.style.setProperty(
"--dialog-left-margin",
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
`${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
)
})
@@ -2355,176 +2352,158 @@ export default function Layout(props: ParentProps) {
)
return (
<Show
when={!newDesign()}
fallback={
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
<HelpButton />
<ToastRegion v2={newDesign()} />
</div>
}
>
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
arm()
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div>
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</div>
</main>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
</div>
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div>
<HelpButton />
<ToastRegion v2={newDesign()} />
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div>
</Show>
<HelpButton />
<ToastRegion v2={false} />
</div>
)
}
@@ -0,0 +1,33 @@
import type { LocalProject } from "@/context/layout"
import { getProjectAvatarVariant } from "@/context/layout"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { SessionProgressIndicatorV2 } from "@opencode-ai/ui/v2/session-progress-indicator-v2"
import { Show } from "solid-js"
export function SessionTabAvatar(props: {
project?: LocalProject
directory: string
sessionId: string
activeServer: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<Show
when={state.loading()}
fallback={
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
/>
}
>
<SessionProgressIndicatorV2 class="shrink-0" />
</Show>
)
}
+53 -35
View File
@@ -28,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { checksum } from "@opencode-ai/core/util/encode"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
@@ -56,7 +56,6 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
@@ -92,7 +91,6 @@ export default function Page() {
const prompt = usePrompt()
const comments = useComments()
const terminal = useTerminal()
const server = useServer()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
@@ -137,11 +135,11 @@ export default function Page() {
layout.handoff.clearTabs()
return
}
if (pending.scope !== server.scope()) return
if (pending.scope !== serverSDK().scope) return
if (pending.id !== id) return
layout.handoff.clearTabs()
if (pending.dir !== (params.dir ?? "")) return
if (pending.dir !== base64Encode(sdk().directory)) return
const from = workspaceTabs().tabs()
if (from.all.length === 0 && !from.active) return
@@ -247,7 +245,7 @@ export default function Page() {
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
() => ({ dir: sdk().directory, id: params.id }),
(next, prev) => {
if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return
@@ -575,7 +573,7 @@ export default function Page() {
createEffect(
on(
() => params.dir,
() => sdk().directory,
(dir) => {
if (!dir) return
setStore("newSessionWorktree", "main")
@@ -1570,40 +1568,56 @@ export default function Page() {
/>
)
const mobileTabs = (compact = false, bottom = false) => (
<Tabs value={store.mobileTab} class="h-auto">
<Tabs.List
classList={{
"!h-9": compact,
"[&::after]:!border-b-0 [&::after]:!border-t [&::after]:!border-border-weak-base": bottom,
}}
>
<Tabs.Trigger
value="session"
classList={{
"!w-1/2 !max-w-none": true,
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom,
}}
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
classList={{
"!w-1/2 !max-w-none !border-r-0": true,
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom,
}}
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
onClick={() => setStore("mobileTab", "changes")}
>
{hasReview()
? language.t("session.review.filesChanged", { count: reviewCount() })
: language.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
)
const mobileTabsBottom = createMemo(
() => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom",
)
return (
<div class="relative size-full overflow-hidden flex flex-col">
{sessionSync() ?? ""}
<SessionHeader />
<div
class="flex-1 min-h-0 flex flex-col md:flex-row "
class="flex-1 min-h-0 flex flex-col md:flex-row"
classList={{
"gap-2 p-2": settings.general.newLayoutDesigns(),
}}
>
<Show when={!isDesktop() && !!params.id}>
<Tabs value={store.mobileTab} class="h-auto">
<Tabs.List>
<Tabs.Trigger
value="session"
class="!w-1/2 !max-w-none"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
class="!w-1/2 !max-w-none !border-r-0"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "changes")}
>
{hasReview()
? language.t("session.review.filesChanged", { count: reviewCount() })
: language.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</Show>
<Show when={!isDesktop() && !!params.id && !settings.general.newLayoutDesigns()}>{mobileTabs()}</Show>
<div
classList={{
@@ -1622,6 +1636,9 @@ export default function Page() {
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
>
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
{mobileTabs(true)}
</Show>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
@@ -1629,8 +1646,8 @@ export default function Page() {
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
header: "px-4 !h-16 !pb-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
@@ -1686,7 +1703,8 @@ export default function Page() {
</Switch>
</div>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
</div>
<Show when={desktopReviewOpen()}>
@@ -1,4 +1,4 @@
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
@@ -25,10 +25,12 @@ import { pathKey } from "@/utils/path-key"
import { useLocal } from "@/context/local"
import { useProviders } from "@/hooks/use-providers"
import { useSettings } from "@/context/settings"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { ServerConnection, useServer } from "@/context/server"
import { type DraftTab, useTabs } from "@/context/tabs"
import { useDirectoryPicker } from "@/components/directory-picker"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useGlobal } from "@/context/global"
export function SessionComposerRegion(props: {
state: SessionComposerState
@@ -72,26 +74,52 @@ export function SessionComposerRegion(props: {
const settings = useSettings()
const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const [search] = useSearchParams<{ draftId?: string }>()
const view = layout.view(route.sessionKey)
const draft = createMemo(() => {
if (!search.draftId) return
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
})
const projectServer = createMemo(() => {
if (!search.draftId) return server.current
const target = draft()?.server
if (!target) return
return server.list.find((conn) => ServerConnection.key(conn) === target)
})
const projectServerCtx = createMemo(() => {
const conn = projectServer()
if (conn) return global.createServerCtx(conn)
})
const projects = createMemo(() =>
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
)
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
const selectProject = (worktree: string) => {
layout.projects.open(worktree)
server.projects.touch(worktree)
const conn = projectServer()
const target = projectServerCtx()
if (search.draftId) {
tabs.updateDraft(search.draftId, { server: server.key, directory: worktree })
if (!conn || !target) return
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
return
}
layout.projects.open(worktree)
server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = (title: string) => {
if (!server.current) return
const conn = projectServer()
if (!conn) return
pickDirectory({
server: server.current,
server: conn,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
@@ -114,7 +142,7 @@ export function SessionComposerRegion(props: {
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
projects: {
available: layout.projects.list(),
available: projects(),
directory: sdk().directory,
select: selectProject,
add: addProject,
@@ -200,7 +228,11 @@ export function SessionComposerRegion(props: {
const openParent = () => {
const id = parentID()
if (!id) return
navigate(`/${route.params.dir}/session/${id}`)
navigate(
route.params.serverKey
? sessionHref(requireServerKey(route.params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
}
createEffect(() => {
@@ -211,6 +243,12 @@ export function SessionComposerRegion(props: {
update()
})
const ready = Promise.resolve()
const [promptReadyResource] = createResource(
() => prompt.ready.promise ?? ready,
(promise) => promise.then(() => true),
)
return (
<div
ref={props.setPromptDockRef}
@@ -253,7 +291,7 @@ export function SessionComposerRegion(props: {
<Show when={showComposer()}>
<Show
when={prompt.ready()}
when={promptReadyResource()}
fallback={
<>
<Show when={rolled()} keyed>
+2 -3
View File
@@ -405,7 +405,7 @@ export function FileTabContent(props: { tab: string }) {
cacheKey: cacheKey(),
}}
enableLineSelection
enableHoverUtility
enableGutterUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
@@ -413,11 +413,10 @@ export function FileTabContent(props: { tab: string }) {
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderHoverUtility={commentsUi.renderHoverUtility}
renderGutterUtility={commentsUi.renderGutterUtility}
onLineSelected={(range: SelectedLineRange | null) => {
commentsUi.onLineSelected(range)
}}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
commentsUi.onLineSelectionEnd(range)
}}
@@ -1,15 +1,19 @@
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout"
import { useServer } from "@/context/server"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
export const useSessionKey = () => {
const params = useParams()
const server = useServer()
const scope = createMemo(() => server.scope())
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir)))
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id)))
const sdk = useSDK()
const serverSDK = useServerSDK()
const scope = createMemo(() => serverSDK().scope)
const directory = createMemo(() => base64Encode(sdk().directory))
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory())))
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory(), params.id)))
return { params, sessionKey, workspaceKey }
}
@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSettings } from "@/context/settings"
import { useTerminal } from "@/context/terminal"
import { useSDK } from "@/context/sdk"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
@@ -25,10 +26,11 @@ export function TerminalPanel() {
const delays = [120, 240]
const layout = useLayout()
const terminal = useTerminal()
const sdk = useSDK()
const language = useLanguage()
const command = useCommand()
const settings = useSettings()
const { params, workspaceKey, view } = useSessionLayout()
const { workspaceKey, view } = useSessionLayout()
const opened = createMemo(() => view().terminal.opened())
const size = createSizing()
@@ -122,7 +124,7 @@ export function TerminalPanel() {
})
createEffect(() => {
const dir = params.dir
const dir = sdk().directory
if (!dir) return
if (!terminal.ready()) return
language.locale()
@@ -140,7 +142,7 @@ export function TerminalPanel() {
})
const handoff = createMemo(() => {
const dir = params.dir
const dir = sdk().directory
if (!dir) return []
return getTerminalHandoff(workspaceKey()) ?? []
})
@@ -62,6 +62,8 @@ import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
@@ -71,6 +73,7 @@ import { makeTimer } from "@solid-primitives/timer"
import { scheduleConnectedMeasure } from "./measure"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
@@ -260,6 +263,7 @@ export function MessageTimeline(props: {
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const { params, sessionKey } = useSessionKey()
@@ -449,7 +453,10 @@ export function MessageTimeline(props: {
const id = activeMessageID()
const active = id ? (messageLastRowIndex().get(id) ?? -1) : -1
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
return [...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b)
return filterVirtualIndexes(
[...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
},
})
const resizeItem = virtualizer.resizeItem
@@ -757,12 +764,18 @@ export function MessageTimeline(props: {
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
const href = (id: string) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
navigate(href(parentID))
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
navigate(href(nextSessionID))
return
}
if (params.serverKey) {
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
@@ -864,7 +877,9 @@ export function MessageTimeline(props: {
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
navigate(
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
)
}
function DialogDeleteSession(props: { sessionID: string }) {
@@ -1285,7 +1300,9 @@ export function MessageTimeline(props: {
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true,
"pr-3": true,
"pl-4": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
@@ -0,0 +1,3 @@
export function filterVirtualIndexes(indexes: number[], count: number) {
return indexes.filter((index) => index >= 0 && index < count)
}
@@ -18,6 +18,8 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useTabs } from "@/context/tabs"
import { requireServerKey } from "@/utils/session-route"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -45,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const sessionTabs = useTabs()
const layout = useLayout()
const navigate = useNavigate()
const { params, tabs, view } = useSessionLayout()
@@ -381,7 +384,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
onSelect: () => {
if (params.serverKey) {
sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
},
}),
sessionCommand({
id: "session.undo",
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
describe("session routes", () => {
test("builds and decodes a server-keyed session route", () => {
const server = ServerConnection.Key.make("https://example.com:4096")
const href = sessionHref(server, "session-1")
expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1")
expect(requireServerKey(href.split("/")[2])).toBe(server)
})
test("rejects malformed server keys", () => {
expect(() => requireServerKey("not-base64")).toThrow("Invalid server route")
})
test("builds the legacy directory-keyed route", () => {
expect(legacySessionHref("/Users/example/project", "session-1")).toBe(
"/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1",
)
})
test("resolves the root session", async () => {
const sessions: Record<string, { id: string; parentID?: string }> = {
child: { id: "child", parentID: "parent" },
parent: { id: "parent", parentID: "root" },
root: { id: "root" },
}
expect(
await rootSession(sessions.child, async (id) => {
const session = sessions[id]
if (!session) throw new Error(`Missing session: ${id}`)
return session
}),
).toBe(sessions.root)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
export function sessionHref(server: ServerConnection.Key, sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
export function legacySessionHref(directory: string, sessionID: string) {
return `/${base64Encode(directory)}/session/${sessionID}`
}
export function requireServerKey(segment: string | undefined) {
const key = decode64(segment)
if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route")
return ServerConnection.Key.make(key)
}
type SessionParent = { id: string; parentID?: string }
export async function rootSession(session: SessionParent, get: (sessionID: string) => Promise<SessionParent>) {
let current = session
while (current.parentID) current = await get(current.parentID)
return current
}
+21 -6
View File
@@ -24,11 +24,11 @@ export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl"
}
export function WslAddServerButton() {
export function AddServerMenu(props: { onAddServer: () => void }) {
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const openAdd = () => {
const openAddWsl = () => {
dialog.push(() => (
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
<DialogAddWslServer />
@@ -36,10 +36,25 @@ export function WslAddServerButton() {
))
}
return (
<Show when={platform.wslServers}>
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
{language.t("wsl.server.addShort")}
</ButtonV2>
<Show
when={platform.wslServers}
fallback={
<ButtonV2 variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
</ButtonV2>
}
>
<MenuV2 gutter={4} modal={false} placement="bottom-end">
<MenuV2.Trigger as={ButtonV2} variant="ghost-muted" icon="plus">
{language.t("dialog.server.add.button")}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</MenuV2.Item>
<MenuV2.Item onSelect={openAddWsl}>{language.t("wsl.server.add")}</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
)
}
@@ -0,0 +1,71 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createEffect, createRoot } from "solid-js"
import { ServerScope } from "@/utils/server-scope"
let Prompt: typeof import("@/context/prompt")
let read: ((value: string | null) => void) | undefined
const storage: AsyncStorage = {
getItem: () => new Promise((resolve) => (read = resolve)),
setItem: async () => undefined,
removeItem: async () => undefined,
clear: async () => undefined,
key: async () => null,
getLength: async () => 0,
length: Promise.resolve(0),
}
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useParams: () => ({}),
useSearchParams: () => [{}],
useLocation: () => ({ pathname: "", query: {} }),
useNavigate: () => () => undefined,
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
mock.module("@/context/platform", () => ({
usePlatform: () => ({ platform: "desktop", storage: () => storage }),
}))
Prompt = await import("@/context/prompt")
})
describe("prompt persistence", () => {
test("waits for an async draft to hydrate before reporting ready", async () => {
await new Promise<void>((resolve, reject) => {
createRoot((dispose) => {
const session = Prompt.createPromptSession(ServerScope.local, { draftID: "draft-async" })
const ready = Prompt.createPromptReady(() => session)
expect(ready()).toBe(false)
expect(session.current()[0]).toMatchObject({ type: "text", content: "" })
read?.(
JSON.stringify({
prompt: [{ type: "text", content: "persisted draft", start: 0, end: 15 }],
cursor: 15,
context: { items: [] },
}),
)
createEffect(() => {
if (!ready()) return
try {
expect(session.current()[0]).toMatchObject({ type: "text", content: "persisted draft" })
dispose()
resolve()
} catch (error) {
dispose()
reject(error)
}
})
})
})
})
})
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { createVirtualizer } from "@tanstack/solid-virtual"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { createRoot, createSignal } from "solid-js"
import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items"
test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => {
@@ -44,3 +45,26 @@ test("logical scroll offset includes pending measurement adjustments", () => {
dispose()
})
})
test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)
const pinned = [1]
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return count()
},
getScrollElement: () => null,
estimateSize: () => 60,
initialRect: { width: 800, height: 600 },
rangeExtractor: (range) =>
filterVirtualIndexes([...new Set([...defaultRangeExtractor(range), ...pinned])], range.count),
})
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([0, 1])
setCount(1)
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([0])
expect(() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item]))).not.toThrow()
dispose()
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.17.8",
"version": "1.17.9",
"type": "module",
"license": "MIT",
"bin": {
+16
View File
@@ -6,6 +6,22 @@ declare const OPENCODE_CLI_NAME: string | undefined
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
commands: [
Spec.make("api", {
description: "Make a request to the running server",
params: {
request: Argument.string("operation | method path").pipe(
Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"),
Argument.variadic({ min: 1, max: 2 }),
),
data: Flag.string("data").pipe(Flag.withAlias("d"), Flag.withDescription("Request body"), Flag.optional),
header: Flag.string("header").pipe(
Flag.withAlias("H"),
Flag.withDescription("Request header in name:value form"),
Flag.atMost(100),
),
param: Flag.keyValuePair("param").pipe(Flag.withDescription("OpenAPI path or query parameter"), Flag.optional),
},
}),
Spec.make("debug", {
description: "Debugging and troubleshooting tools",
commands: [Spec.make("agents", { description: "List all agents" })],
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test"
import { rawRequest, resolveOperation } from "./api"
describe("api request resolution", () => {
test("resolves an operation ID with path and query parameters", () => {
expect(
resolveOperation(
{
paths: {
"/api/session/{sessionID}": {
get: { operationId: "v2.session.get" },
},
},
},
"v2.session.get",
{ sessionID: "ses/a", workspace: "work" },
),
).toEqual({ method: "GET", path: "/api/session/ses%2Fa?workspace=work" })
})
test("rejects a missing path parameter", () => {
expect(() =>
resolveOperation(
{ paths: { "/api/session/{sessionID}": { get: { operationId: "v2.session.get" } } } },
"v2.session.get",
{},
),
).toThrow("Missing path parameter: sessionID")
})
test("resolves curl-like method and path input", () => {
expect(rawRequest(["post", "/api/foo"])).toEqual({ method: "POST", path: "/api/foo" })
expect(rawRequest(["v2.session.list"])).toBeUndefined()
})
})
+85
View File
@@ -0,0 +1,85 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon"
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
type Operation = {
operationId?: string
}
type OpenApi = {
paths?: Record<string, Record<string, Operation>>
}
export default Runtime.handler(
Commands.commands.api,
Effect.fn("cli.api")(function* (input) {
const daemon = yield* Daemon.Service
const transport = yield* daemon.transport()
const params = Option.getOrElse(input.param, () => ({}))
const request = yield* resolveRequest(transport, input.request, params)
const headers = new Headers(transport.headers)
for (const header of input.header) {
const index = header.indexOf(":")
if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`))
headers.set(header.slice(0, index).trim(), header.slice(index + 1).trim())
}
const body = Option.getOrUndefined(input.data)
if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
const response = yield* Effect.tryPromise(() =>
fetch(new URL(request.path, transport.url), {
method: request.method,
headers,
body,
}),
)
const output = yield* Effect.promise(() => response.text())
if (output) process.stdout.write(output + (output.endsWith(EOL) ? "" : EOL))
}),
)
export function resolveOperation(spec: OpenApi, operationID: string, params: Record<string, string>) {
for (const [path, operations] of Object.entries(spec.paths ?? {})) {
for (const [method, operation] of Object.entries(operations)) {
if (!methods.has(method) || operation.operationId !== operationID) continue
return { method: method.toUpperCase(), path: interpolate(path, params) }
}
}
throw new Error(`Operation not found: ${operationID}`)
}
export function rawRequest(input: readonly string[]) {
if (input.length !== 2 || !methods.has(input[0].toLowerCase()) || !input[1].startsWith("/")) return
return { method: input[0].toUpperCase(), path: input[1] }
}
function resolveRequest(
transport: { url: string; headers: RequestInit["headers"] },
input: readonly string[],
params: Record<string, string>,
) {
const raw = rawRequest(input)
if (raw) return Effect.succeed(raw)
if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
return Effect.tryPromise(async () => {
const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers })
if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`)
return resolveOperation((await response.json()) as OpenApi, input[0], params)
})
}
function interpolate(path: string, params: Record<string, string>) {
const used = new Set<string>()
const pathname = path.replaceAll(/\{([^}]+)\}/g, (_, name: string) => {
const value = params[name]
if (value === undefined) throw new Error(`Missing path parameter: ${name}`)
used.add(name)
return encodeURIComponent(value)
})
const query = new URLSearchParams(Object.entries(params).filter(([name]) => !used.has(name))).toString()
return query ? `${pathname}?${query}` : pathname
}
+5 -3
View File
@@ -27,9 +27,11 @@ export default Runtime.handler(
function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password)
// Preserve the familiar default when available, but let the OS choose a free
// port when another local server already owns 4096.
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
const next = (port: number): ReturnType<typeof bind> =>
bind(hostname, port, password).pipe(
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
)
return next(4096)
}
function bind(hostname: string, port: number, password: string) {
+1
View File
@@ -9,6 +9,7 @@ import { Daemon } from "./services/daemon"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
api: () => import("./commands/handlers/api"),
debug: {
agents: () => import("./commands/handlers/debug/agents"),
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.17.8",
"version": "1.17.9",
"type": "module",
"license": "MIT",
"scripts": {
@@ -105,6 +105,7 @@ export async function handler(
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
const requestId = input.request.headers.get("x-opencode-request") ?? ""
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
const projectId = input.request.headers.get("x-opencode-project") ?? ""
const userAgent = input.request.headers.get("user-agent") ?? ""
logger.metric({
is_stream: isStream,
@@ -193,8 +194,13 @@ export async function handler(
headers: (() => {
const headers = new Headers(input.request.headers)
providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId)
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
if (v === "$ip") return headers.set(k, ip)
if (v === "$session") return headers.set(k, sessionId)
if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId)
if (v === "$project") return headers.set(k, projectId)
headers.set(k, v)
})
headers.delete("host")
headers.delete("content-length")
@@ -691,6 +697,7 @@ export async function handler(
logger.metric({
api_key: data.apiKey,
workspace: data.workspaceID,
user_id: data.user.id,
...(() => {
if (data.billing.subscription)
return {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.17.8",
"version": "1.17.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -2
View File
@@ -52,9 +52,8 @@ export namespace ZenData {
api: z.string(),
apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
headerModifier: z.record(z.string(), z.any()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
payloadMappings: z.record(z.string(), z.string()).optional(),
adjustCacheUsage: z.boolean().optional(),
budget: z.number().optional(),
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.17.8",
"version": "1.17.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
@@ -132,6 +132,7 @@ function toLakeEvent(time: string, data: Record<string, unknown>) {
error_cause2: string(data, "error.cause2"),
api_key: string(data, "api_key"),
workspace: string(data, "workspace"),
user_id: string(data, "user_id"),
is_subscription: boolean(data, "isSubscription"), // removed
subscription: string(data, "subscription"),
response_length: integer(data, "response_length"),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.17.8",
"version": "1.17.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.17.8",
"version": "1.17.9",
"type": "module",
"license": "MIT",
"scripts": {
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./test/preload.ts"]
+3 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.8",
"version": "1.17.9",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -91,6 +91,8 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
+2 -32
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
"ddl": [
{
"name": "workspace",
@@ -900,16 +900,6 @@
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'build'",
"generated": null,
"name": "agent",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": true,
@@ -930,26 +920,6 @@
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "replacement_seq",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": false,
+19 -5
View File
@@ -45,15 +45,17 @@ async function generate() {
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
await Bun.write(
target,
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
await formatTypescript(
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
),
)
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
}
await fs.mkdir(full)
await drizzle(temporary, full, "schema")
await Bun.write(schema, renderSchema(await generatedSql(full)))
await Bun.write(registry, renderRegistry(await typescriptMigrations()))
await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full))))
await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations())))
} finally {
await fs.rm(temporary, { recursive: true, force: true })
}
@@ -76,12 +78,12 @@ async function check() {
await fs.mkdir(full)
await drizzle(temporary, full, "schema")
if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) {
if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) {
throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
}
const migrations = await typescriptMigrations()
if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) {
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
}
} finally {
@@ -170,6 +172,18 @@ function escapeTemplate(line: string) {
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
}
async function formatTypescript(input: string) {
const prettier = await import("prettier")
const typescript = await import("prettier/plugins/typescript")
const estree = await import("prettier/plugins/estree")
return prettier.format(input, {
parser: "typescript",
plugins: [typescript.default, estree.default],
semi: false,
printWidth: 120,
})
}
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
+4 -4
View File
@@ -35,19 +35,19 @@ export class Org extends Schema.Class<Org>("Org")({
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
cause: Schema.optional(Schema.Defect()),
}) {}
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
cause: Schema.optional(Schema.Defect()),
}) {}
export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("AccountTransportError", {
method: Schema.String,
url: Schema.String,
description: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect),
cause: Schema.optional(Schema.Defect()),
}) {
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
return new AccountTransportError({
@@ -94,7 +94,7 @@ export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired"
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
cause: Schema.Defect,
cause: Schema.Defect(),
}) {}
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
+14 -48
View File
@@ -1,47 +1,17 @@
export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider"
import { PositiveInt } from "./schema"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { State } from "./state"
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export const ID = Agent.ID
export type ID = typeof ID.Type
export const defaultID = ID.make("build")
export const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
export const Color = Agent.Color
export class Info extends Schema.Class<Info>("AgentV2.Info")({
id: ID,
model: ModelV2.Ref.pipe(Schema.optional),
request: ProviderV2.Request,
system: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]),
hidden: Schema.Boolean,
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
permissions: PermissionSchema.Ruleset,
}) {
static empty(id: ID) {
return new Info({
id,
request: {
headers: {},
body: {},
},
mode: "all",
hidden: false,
permissions: [],
})
}
}
export const Info = Agent.Info
export type Info = Agent.Info
export interface Selection {
readonly id: ID
@@ -49,21 +19,19 @@ export interface Selection {
}
type Data = {
agents: Map<ID, Info>
agents: Map<ID, Types.DeepMutable<Info>>
default?: ID
}
export type Editor = {
export type Draft = {
list: () => readonly Info[]
get: (id: ID) => Info | undefined
default: (id: ID | undefined) => void
update: (id: ID, fn: (agent: Draft<Info>) => void) => void
update: (id: ID, fn: (agent: Types.DeepMutable<Info>) => void) => void
remove: (id: ID) => void
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
export interface Interface extends State.Transformable<Draft> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
@@ -73,21 +41,19 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
initial: () => ({ agents: new Map() }),
editor: (draft) => ({
draft: (draft) => ({
list: () => Array.fromIterable(draft.agents.values()) as Info[],
get: (id) => draft.agents.get(id),
default: (id) => {
draft.default = id
},
update: (id, fn) => {
const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable<Info>)
if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current)
current.id = id
@@ -113,7 +79,7 @@ export const layer = Layer.effect(
return Service.of({
transform: state.transform,
update: state.update,
reload: state.reload,
get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id)
}),
+75 -22
View File
@@ -1,14 +1,27 @@
export * as AISDK from "./aisdk"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Cause, Context, Effect, Layer, Schema } from "effect"
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
import { ModelV2 } from "./model"
import { EventV2 } from "./event"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { State } from "./state"
type SDK = any
export interface SDKEvent {
readonly model: ModelV2.Info
readonly package: string
readonly options: Record<string, any>
sdk?: SDK
}
export interface LanguageEvent {
readonly model: ModelV2.Info
readonly sdk: SDK
readonly options: Record<string, any>
language?: LanguageModelV3
}
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
if (typeof ms !== "number" || ms <= 0) return res
if (!res.body) return res
@@ -109,7 +122,7 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
export class InitError extends Schema.TaggedErrorClass<InitError>()("AISDK.InitError", {
providerID: ProviderV2.ID,
cause: Schema.Defect,
cause: Schema.Defect(),
}) {}
function initError(providerID: ProviderV2.ID) {
@@ -117,19 +130,70 @@ function initError(providerID: ProviderV2.ID) {
}
export interface Interface {
readonly hook: {
readonly sdk: (
callback: (event: SDKEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly language: (
callback: (event: LanguageEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
}
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
export const layer = Layer.effect(
export const locationLayer = Layer.effect(
Service,
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
let sdkHooks: ((event: SDKEvent) => Effect.Effect<void> | void)[] = []
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
const languages = new Map<string, LanguageModelV3>()
const sdks = new Map<string, SDK>()
return Service.of({
const register = <Event>(
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
) =>
Effect.fn("AISDK.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
const scope = yield* Scope.Scope
let active = true
update([...hooks(), callback])
const dispose = Effect.sync(() => {
if (!active) return
active = false
update(hooks().filter((item) => item !== callback))
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
})
const run = Effect.fnUntraced(function* <Event>(
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
event: Event,
) {
for (const hook of hooks) {
const result = hook(event)
if (Effect.isEffect(result)) yield* result
}
return event
})
const service = Service.of({
hook: {
sdk: register(
() => sdkHooks,
(next) => (sdkHooks = next),
),
language: register(
() => languageHooks,
(next) => (languageHooks = next),
),
},
runSDK: (event) => run(sdkHooks, event),
runLanguage: (event) => run(languageHooks, event),
language: Effect.fn("AISDK.language")(function* (model) {
const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}`
const existing = languages.get(key)
@@ -148,26 +212,14 @@ export const layer = Layer.effect(
})
const sdk =
sdks.get(sdkKey) ??
(yield* plugin
.trigger("aisdk.sdk", { model, package: model.api.package, options }, {})
.pipe(initError(model.providerID))).sdk
(yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk
if (!sdk)
return yield* new InitError({
providerID: model.providerID,
cause: new Error("No AISDK provider plugin returned an SDK"),
})
sdks.set(sdkKey, sdk)
const result = yield* plugin
.trigger(
"aisdk.language",
{
model,
sdk,
options,
},
{},
)
.pipe(initError(model.providerID))
const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID))
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe(
initError(model.providerID),
)
@@ -175,7 +227,8 @@ export const layer = Layer.effect(
return language
}),
})
return service
}),
)
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))))
export const defaultLayer = locationLayer
+56 -103
View File
@@ -1,36 +1,21 @@
export * as Catalog from "./catalog"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { ModelV2 } from "./model"
import { ModelRequest } from "./model-request"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Location } from "./location"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Integration } from "./integration"
export type ProviderRecord = {
provider: ProviderV2.Info
models: Map<ModelV2.ID, ModelV2.Info>
provider: ProviderV2.MutableInfo
models: Map<ModelV2.ID, ModelV2.MutableInfo>
}
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"CatalogV2.ProviderNotFound",
{
providerID: ProviderV2.ID,
},
) {}
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("CatalogV2.ModelNotFound", {
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
}) {}
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = {
@@ -42,16 +27,16 @@ type Data = {
defaultModel?: DefaultModel
}
export type Editor = {
export type Draft = {
provider: {
list: () => readonly ProviderRecord[]
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID) => void
}
model: {
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
default: {
get: () => DefaultModel | undefined
@@ -60,44 +45,35 @@ export type Editor = {
}
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
export interface Interface extends State.Transformable<Draft> {
readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]>
}
readonly model: {
readonly get: (
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const integrations = yield* Integration.Service
const scope = yield* Scope.Scope
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true
if (connected) return true
return !integration
if (integration?.connections.length) return true
return provider.integrationID === undefined && !integration
}
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
@@ -113,39 +89,33 @@ export const layer = Layer.effect(
...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request),
variant: model.request.variant,
}
return new ModelV2.Info({
return ModelV2.Info.make({
...model,
api,
request,
})
}
function* getRecord(providerID: ProviderV2.ID) {
const match = state.get().providers.get(providerID)
if (!match) return yield* new ProviderNotFoundError({ providerID })
return match
}
const normalizeApi = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => {
if (typeof item.request.body.baseURL !== "string") return
item.api.url = item.request.body.baseURL
delete item.request.body.baseURL
}
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }),
editor: (draft) => {
const result: Editor = {
draft: (draft) => {
const result: Draft = {
provider: {
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
get: (providerID) => draft.providers.get(providerID),
update: (providerID, fn) => {
let current = draft.providers.get(providerID)
if (!current) {
current = castDraft({
provider: ProviderV2.Info.empty(providerID),
models: new Map<ModelV2.ID, ModelV2.Info>(),
})
current = {
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}
draft.providers.set(providerID, current)
}
fn(current.provider)
@@ -160,13 +130,14 @@ export const layer = Layer.effect(
update: (providerID, modelID, fn) => {
let record = draft.providers.get(providerID)
if (!record) {
record = castDraft({
provider: ProviderV2.Info.empty(providerID),
models: new Map<ModelV2.ID, ModelV2.Info>(),
})
record = {
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}
draft.providers.set(providerID, record)
}
const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID))
const model =
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model)
model.id = modelID
@@ -186,8 +157,7 @@ export const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
@@ -198,25 +168,13 @@ export const layer = Layer.effect(
yield* events.publish(Event.Updated, {})
}),
})
yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped.
Stream.filter(
(event) =>
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
),
Stream.runForEach((event) =>
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
),
Effect.forkIn(scope, { startImmediately: true }),
)
const result: Interface = {
transform: state.transform,
reload: state.reload,
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID)
return record.provider
return state.get().providers.get(providerID)?.provider
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
@@ -225,23 +183,18 @@ export const layer = Layer.effect(
available: Effect.fn("CatalogV2.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
const connections = yield* integrations.connection.list()
return (yield* result.provider.all()).filter((provider) =>
available(
provider,
active.get(Integration.ID.make(provider.id)),
connections.has(Integration.ID.make(provider.id)),
),
available(provider, active.get(provider.integrationID ?? Integration.ID.make(provider.id))),
)
}),
},
model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
const record = yield* getRecord(providerID)
const record = state.get().providers.get(providerID)
if (!record) return
const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return projectModel(model, record.provider)
return model && projectModel(model, record.provider)
}),
all: Effect.fn("CatalogV2.model.all")(function* () {
@@ -250,7 +203,7 @@ export const layer = Layer.effect(
Array.flatMap((record) => {
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
}),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
)
}),
@@ -262,31 +215,30 @@ export const layer = Layer.effect(
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
if (
Option.isSome(provider) &&
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && model.value.enabled) return model
const provider = yield* result.provider.get(defaultModel.providerID)
if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID)
if (model?.enabled) return model
}
}
return pipe(
yield* result.model.available(),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
Array.head,
return Option.getOrUndefined(
pipe(
yield* result.model.available(),
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
Array.head,
),
)
}),
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return Option.none<ModelV2.Info>()
if (!record) return
const provider = record.provider
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
}
const candidates = pipe(
@@ -302,7 +254,7 @@ export const layer = Layer.effect(
Array.map((model) => ({
model,
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30),
age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
})),
Array.filter((item) => item.cost > 0 && item.age <= 18),
@@ -319,10 +271,12 @@ export const layer = Layer.effect(
)
}
return pipe(
candidates,
Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
return Option.getOrUndefined(
pipe(
candidates,
Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
),
)
}),
},
@@ -336,6 +290,5 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Policy.locationLayer),
)
+12 -21
View File
@@ -1,33 +1,24 @@
export * as CommandV2 from "./command"
import { Context, Effect, Layer, Schema } from "effect"
import { castDraft, type Draft } from "immer"
import { ModelV2 } from "./model"
import { Context, Effect, Layer, Types } from "effect"
import { Command } from "@opencode-ai/schema/command"
import { State } from "./state"
export class Info extends Schema.Class<Info>("CommandV2.Info")({
name: Schema.String,
template: Schema.String,
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Info = Command.Info
export type Info = Command.Info
export type Data = {
commands: Map<string, Info>
commands: Map<string, Types.DeepMutable<Info>>
}
export type Editor = {
export type Draft = {
list: () => readonly Info[]
get: (name: string) => Info | undefined
update: (name: string, update: (command: Draft<Info>) => void) => void
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
remove: (name: string) => void
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
export interface Interface extends State.Transformable<Draft> {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
@@ -37,13 +28,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.sync(() => {
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
initial: () => ({ commands: new Map() }),
editor: (draft) => ({
draft: (draft) => ({
list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name),
update: (name, update) => {
const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" }))
const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable<Info>)
if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current)
current.name = name
@@ -55,7 +46,7 @@ export const layer = Layer.effect(
})
return Service.of({
update: state.update,
reload: state.reload,
transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name)
+2 -2
View File
@@ -3,10 +3,10 @@ export * as Config from "./config"
import path from "path"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { PermissionSchema } from "./permission/schema"
import { Policy } from "./policy"
import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
@@ -56,7 +56,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
username: Schema.String.pipe(Schema.optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: PermissionSchema.Ruleset.pipe(Schema.optional).annotate({
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
+2 -2
View File
@@ -1,7 +1,7 @@
export * as ConfigAgent from "./agent"
import { Schema } from "effect"
import { PermissionSchema } from "../permission/schema"
import { Permission } from "@opencode-ai/schema/permission"
import { ConfigProvider } from "./provider"
import { PositiveInt } from "../schema"
@@ -21,5 +21,5 @@ export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
permissions: PermissionSchema.Ruleset.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}) {}
+61 -61
View File
@@ -1,5 +1,6 @@
export * as ConfigAgentPlugin from "./agent"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { AgentV2 } from "../../agent"
@@ -8,7 +9,6 @@ import { ConfigAgent } from "../agent"
import { ConfigMarkdown } from "../markdown"
import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
@@ -33,70 +33,70 @@ const agentKeys = new Set([
"permissions",
])
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-agent"),
effect: Effect.gen(function* () {
const agent = yield* AgentV2.Service
export const Plugin = define({
id: "config-agent",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => content && decode(file, content)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) =>
documents.filter((document): document is Config.Document => document !== undefined),
),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
yield* agent.update((editor) => {
const global = documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(documents, "default_agent")
if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault))
for (const current of editor.list()) {
editor.update(current.id, (agent) => agent.permissions.push(...global))
}
for (const document of documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
editor.remove(agentID)
continue
}
const exists = editor.get(agentID) !== undefined
editor.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.request !== undefined) {
Object.assign(agent.request.headers, item.request.headers ?? {})
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
yield* ctx.agent.transform(
Effect.fn(function* (draft) {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => content && decode(file, content)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) =>
documents.filter((document): document is Config.Document => document !== undefined),
),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
const global = documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(documents, "default_agent")
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...global))
}
}
})
for (const document of documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
draft.remove(agentID)
continue
}
const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.request !== undefined) {
Object.assign(agent.request.headers, item.request.headers ?? {})
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
})
}
}
}),
)
}),
})
+32 -33
View File
@@ -1,52 +1,51 @@
export * as ConfigCommandPlugin from "./command"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { CommandV2 } from "../../command"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ConfigCommand } from "../command"
import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-command"),
effect: Effect.gen(function* () {
const command = yield* CommandV2.Service
export const Plugin = define({
id: "config-command",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const transform = yield* command.transform()
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
]),
)
}).pipe(Effect.map((documents) => documents.flat()))
yield* transform((editor) => {
for (const document of documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
editor.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
yield* ctx.command.transform(
Effect.fn(function* (draft) {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
]),
)
}).pipe(Effect.map((documents) => documents.flat()))
for (const document of documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
draft.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
}
}
})
}),
)
}),
})
@@ -0,0 +1,91 @@
export * as ConfigExternalPlugin from "./external"
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
import { Effect, Schema } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { Location } from "../../location"
import { Npm } from "../../npm"
import { define } from "../../plugin/internal"
import { PluginPromise } from "../../plugin/promise"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
effect: Schema.declare<EffectPlugin["effect"]>(
(input): input is EffectPlugin["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
setup: Schema.declare<PromisePlugin["setup"]>(
(input): input is PromisePlugin["setup"] => typeof input === "function",
),
}),
]),
})
export const Plugin = define({
id: "config-plugin",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
yield* Effect.gen(function* () {
const configured: { package: string; options?: Record<string, any> }[] = []
for (const entry of yield* config.entries()) {
if (entry.type === "document") {
const directory = entry.path ? path.dirname(entry.path) : location.directory
for (const item of entry.info.plugins ?? []) {
const ref = typeof item === "string" ? { package: item } : item
const packageName = (() => {
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
if (ref.package.startsWith("./") || ref.package.startsWith("../")) {
return path.resolve(directory, ref.package)
}
return ref.package
})()
configured.push({ package: packageName, options: ref.options })
}
}
if (entry.type === "directory") {
const files = yield* fs
.glob("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
files.sort()
for (const file of files) configured.push({ package: file })
}
}
for (const ref of configured) {
yield* Effect.gen(function* () {
const entrypoint = path.isAbsolute(ref.package)
? pathToFileURL(ref.package).href
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
const mod = yield* Effect.promise(() => import(entrypoint))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
yield* ctx.plugin.add({
id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
})
}).pipe(Effect.ignoreCause)
}
}).pipe(Effect.forkScoped({ startImmediately: true }))
}),
})
+109 -108
View File
@@ -1,123 +1,124 @@
export * as ConfigProviderPlugin from "./provider"
import { define } from "../../plugin/internal"
import { Effect } from "effect"
import { Catalog } from "../../catalog"
import { Config } from "../../config"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-provider"),
effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service
export const Plugin = define({
id: "config-provider",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const integrations = yield* Integration.Service
const transform = yield* catalog.transform()
const integrationTransform = yield* integrations.transform()
const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
),
)
yield* integrationTransform((integrations) => {
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = Integration.ID.make(id)
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
yield* ctx.integration.transform(
Effect.fn(function* (integrations) {
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
provider.env === undefined ? [] : [id],
),
),
)
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = id
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
}
}
}
})
yield* transform((catalog) => {
const configuredDefault = Config.latest(entries, "model")
if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID)
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = ProviderV2.ID.make(id)
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
})
const providerApi = catalog.provider.get(providerID)?.provider.api
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.request !== undefined) {
ModelRequest.assign(model.request, {
headers: config.request.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
})
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
headers: {},
body: {},
generation: {},
options: {},
}
model.variants.push(existing)
}
ModelRequest.assign(existing, {
headers: variant.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
})
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
}
})
}),
)
yield* ctx.catalog.transform(
Effect.fn(function* (catalog) {
const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredDefault = Config.latest(entries, "model")
if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID)
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = id
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
}
})
const providerApi = catalog.provider.get(providerID)?.provider.api
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.request !== undefined) {
ModelRequest.assign(model.request, {
headers: config.request.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
})
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
headers: {},
body: {},
generation: {},
options: {},
}
model.variants.push(existing)
}
ModelRequest.assign(existing, {
headers: variant.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
})
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
}
}),
)
}),
})
+41 -42
View File
@@ -1,57 +1,56 @@
export * as ConfigReferencePlugin from "./reference"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
import { ConfigReference } from "../reference"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = {
id: PluginV2.ID.make("core/config-reference"),
effect: Effect.gen(function* () {
export const Plugin = define({
id: "core/config-reference",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service
const references = yield* Reference.Service
const update = yield* references.transform()
const entries = new Map<string, Reference.Source>()
for (const doc of (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document",
)) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
entries.set(
name,
local(entry)
? new Reference.LocalSource({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
})
: new Reference.GitSource({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
branch: typeof entry === "string" ? undefined : entry.branch,
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
}),
)
}
}
yield* update((editor) => {
for (const [name, source] of entries) editor.add(name, source)
})
const global = yield* Global.Service
yield* ctx.reference.transform(
Effect.fn(function* (draft) {
const entries = new Map<string, Reference.Source>()
for (const doc of (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document",
)) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
})
: Reference.GitSource.make({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
branch: typeof entry === "string" ? undefined : entry.branch,
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
}),
)
}
}
for (const [name, source] of entries) draft.add(name, source)
}),
)
}),
}
})
function validAlias(name: string) {
return name.length > 0 && !/[\/\s`,]/.test(name)
+36 -34
View File
@@ -1,48 +1,50 @@
export * as ConfigSkillPlugin from "./skill"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"
import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-skill"),
effect: Effect.gen(function* () {
export const Plugin = define({
id: "config-skill",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service
const skill = yield* SkillV2.Service
const transform = yield* skill.transform()
const entries = yield* config.entries()
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
yield* transform((editor) => {
for (const directory of directories) {
editor.source(
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
editor.source(
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
)
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
editor.source(new SkillV2.UrlSource({ type: "url", url: item }))
continue
yield* ctx.skill.transform(
Effect.fn(function* (draft) {
const entries = yield* config.entries()
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of directories) {
draft.source(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
editor.source(
new SkillV2.DirectorySource({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
})
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
}),
)
}),
})
@@ -48,7 +48,7 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSource
{
directory: AbsolutePath,
message: Schema.String,
cause: Schema.optional(Schema.Defect),
cause: Schema.optional(Schema.Defect()),
},
) {}
+23 -38
View File
@@ -2,60 +2,45 @@ export * as Credential from "./credential"
import { asc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database"
import { IntegrationSchema } from "./integration/schema"
import { NonNegativeInt, withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { CredentialTable } from "./credential/sql"
export const ID = Schema.String.pipe(
Schema.brand("Credential.ID"),
withStatics((schema) => ({ create: () => schema.make("cred_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const ID = Credential.ID
export type ID = Credential.ID
export class OAuth extends Schema.Class<OAuth>("Credential.OAuth")({
type: Schema.Literal("oauth"),
methodID: IntegrationSchema.MethodID,
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const OAuth = Credential.OAuth
export type OAuth = Credential.OAuth
export class Key extends Schema.Class<Key>("Credential.Key")({
type: Schema.Literal("key"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Key = Credential.Key
export type Key = Credential.Key
export const Info = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Info" })
export type Info = Schema.Schema.Type<typeof Info>
export const Value = Credential.Value
export type Value = Credential.Value
export class Stored extends Schema.Class<Stored>("Credential.Stored")({
export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID,
integrationID: IntegrationSchema.ID,
integrationID: Integration.ID,
label: Schema.String,
value: Info,
value: Value,
}) {}
export interface Interface {
/** Returns every stored credential. */
readonly all: () => Effect.Effect<Stored[]>
readonly all: () => Effect.Effect<Info[]>
/** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Replaces any credential for an integration and returns the new record. */
readonly create: (input: {
readonly integrationID: IntegrationSchema.ID
readonly value: Info
readonly integrationID: Integration.ID
readonly value: Value
readonly label?: string
}) => Effect.Effect<Stored>
}) => Effect.Effect<Info>
/** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void>
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
/** Removes a stored credential. */
readonly remove: (id: ID) => Effect.Effect<void>
}
@@ -66,10 +51,10 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const decode = Schema.decodeUnknownSync(Info)
const decode = Schema.decodeUnknownSync(Value)
const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return
return new Stored({
return new Info({
id: row.id,
integrationID: row.integration_id,
label: row.label,
@@ -106,7 +91,7 @@ export const layer = Layer.effect(
return row ? stored(row) : undefined
}),
create: Effect.fn("Credential.create")(function* (input) {
const credential = new Stored({
const credential = new Info({
id: ID.create(),
integrationID: input.integrationID,
label: input.label ?? "default",
+2 -3
View File
@@ -1,13 +1,12 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import type { IntegrationSchema } from "../integration/schema"
import type { Credential } from "../credential"
export const CredentialTable = sqliteTable("credential", {
id: text().$type<Credential.ID>().primaryKey(),
integration_id: text().$type<IntegrationSchema.ID>(),
integration_id: text().$type<Credential.Info["integrationID"]>(),
label: text().notNull(),
value: text({ mode: "json" }).$type<Credential.Info>().notNull(),
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
connector_id: text(),
method_id: text(),
active: integer({ mode: "boolean" }),
+3
View File
@@ -37,5 +37,8 @@ export const migrations = (
import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622142730_simplify_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`agent\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`replacement_seq\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622170816_reset_v2_session_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622202450_simplify_session_input",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration
-3
View File
@@ -149,11 +149,8 @@ export default {
CREATE TABLE \`session_context_epoch\` (
\`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL,
\`agent\` text DEFAULT 'build' NOT NULL,
\`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL,
\`replacement_seq\` integer,
\`revision\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)

Some files were not shown because too many files have changed in this diff Show More