Compare commits

..

36 Commits

Author SHA1 Message Date
Kit Langton f8e5b12f41 fix(core): preserve provider session failures 2026-06-24 22:43:18 -04:00
opencode-agent[bot] c45d1db9a0 chore: update nix node_modules hashes 2026-06-25 02:28:42 +00:00
Brendan Allan ba2ba770d5 fix(app): clear late session notifications (#33753)
Co-authored-by: Test <test@opencode.test>
2026-06-25 02:21:42 +00:00
opencode-agent[bot] 5ca19947d9 chore: generate 2026-06-25 02:18:06 +00:00
Brendan Allan 3b4aaafd41 refactor(app): centralize session state (#33641) 2026-06-25 02:16:39 +00:00
Kit Langton 56a37c3640 refactor(protocol): extract server contracts (#33708) 2026-06-24 22:15:31 -04:00
opencode-agent[bot] f9dac262ff fix(app): remove session loading stripe (#33649)
Co-authored-by: Test <test@opencode.test>
2026-06-25 10:09:06 +08:00
opencode-agent[bot] 3730125f8f chore: generate 2026-06-24 23:43:02 +00:00
Dax 9bb5370205 feat(core): add session snapshot and revert system (#33226) 2026-06-24 23:41:16 +00:00
Dax Raad 25c6abc31a fix(core): lower OpenAI text verbosity 2026-06-24 19:32:59 -04:00
Dax Raad f4afb2c0a5 fix(core): preserve unconfigured console models 2026-06-24 18:58:17 -04:00
Dax Raad bba74985ab fix(core): handle unavailable fff index 2026-06-24 18:49:22 -04:00
Dax Raad e9ee2129ea fix(core): disable fff broad scanning 2026-06-24 18:42:15 -04:00
Dax Raad 68260ea6c9 fix(core): scope fff broad scanning 2026-06-24 18:42:15 -04:00
Aiden Cline 49ea8a9455 fix(opencode): always print MCP OAuth URL (#33716) 2026-06-24 17:24:16 -05:00
Isaac Huang 142c5c11d9 docs: add GMI Cloud provider entry to providers directory (#32914) 2026-06-24 17:15:36 -05:00
opencode-agent[bot] 4b83f5d8f0 chore: update nix node_modules hashes 2026-06-24 20:56:38 +00:00
opencode-agent[bot] 121decfd15 chore: generate 2026-06-24 20:45:23 +00:00
Kit Langton 24b0132bc5 refactor(schema): extract public event definitions (#33579) 2026-06-24 16:43:17 -04:00
opencode 858f35f1b3 sync release versions for v1.17.10 2026-06-24 20:37:40 +00:00
Simon Klee 20750a3295 tui: remove logo animation (#33633)
Render branding as a static logo to avoid continuous redraws and mouse-driven visual effects.
2026-06-24 19:28:46 +00:00
Aiden Cline 1ddb75dd3e fix: restore dev CI (#33701) 2026-06-24 14:04:50 -05:00
Frank ea5dd9d940 zen: track model tier in stats 2026-06-24 14:34:58 -04:00
opencode-agent[bot] 31b58b4704 chore: generate 2026-06-24 17:21:26 +00:00
Dax Raad 42bb793574 feat(core): generate model variants 2026-06-24 13:17:14 -04:00
Dax Raad 17f312d537 refactor(core): simplify model requests 2026-06-24 13:17:14 -04:00
Aiden Cline 116ac93ddb fix(mcp): hide denied resource template tool (#33686) 2026-06-24 12:01:44 -05:00
opencode-agent[bot] 2684293291 chore: generate 2026-06-24 16:56:33 +00:00
4rcadia e8e83afbce feat(mcp): append server instructions to context (#32490)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-24 11:54:19 -05:00
Adam 2e909334c1 refactor(stats): simplify locale prefix stripping 2026-06-24 10:33:59 -05:00
Adam 69f3b633b8 fix(stats): strip locale prefixes from data routes 2026-06-24 10:33:58 -05:00
Frank db7c28b3ee zen: migrate to new inference 2026-06-24 11:15:42 -04:00
opencode-agent[bot] e45f55d922 chore: update nix node_modules hashes 2026-06-24 15:09:00 +00:00
Victor Navarro 04c1730c90 refactor(ui): isolate session components (#33670) 2026-06-24 14:56:09 +00:00
opencode-agent[bot] 1ef0fd5d01 chore: generate 2026-06-24 13:59:28 +00:00
Shoubhit Dash 36501a81c3 docs(llm): propose AI library design (#33666) 2026-06-24 19:27:21 +05:30
437 changed files with 10733 additions and 6760 deletions
+1
View File
@@ -325,6 +325,7 @@ jobs:
run: bun run build run: bun run build
working-directory: packages/desktop working-directory: packages/desktop
env: env:
NODE_OPTIONS: --max-old-space-size=4096
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_ORG: ${{ vars.SENTRY_ORG }}
+2
View File
@@ -9,6 +9,7 @@ on:
- "bun.lock" - "bun.lock"
- "packages/storybook/**" - "packages/storybook/**"
- "packages/ui/**" - "packages/ui/**"
- "packages/session-ui/**"
pull_request: pull_request:
branches: [dev] branches: [dev]
paths: paths:
@@ -17,6 +18,7 @@ on:
- "bun.lock" - "bun.lock"
- "packages/storybook/**" - "packages/storybook/**"
- "packages/ui/**" - "packages/ui/**"
- "packages/session-ui/**"
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
+1
View File
@@ -28,6 +28,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream - Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module. - In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`.
Reduce total variable count by inlining when a value is only used once. Reduce total variable count by inlining when a value is only used once.
-9
View File
@@ -57,13 +57,6 @@ The bounded projection of a Core-executed tool result persisted in Session histo
**Managed Tool Output File**: **Managed Tool Output File**:
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history. A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
**Model Request Options**:
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
_Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**PTY Environment**: **PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
@@ -114,8 +107,6 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- 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. - 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. - 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`. - 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`.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter. - A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
+94 -28
View File
@@ -29,11 +29,12 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4", "@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
@@ -86,7 +87,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.17.9", "version": "1.17.10",
"bin": { "bin": {
"lildax": "./bin/lildax.cjs", "lildax": "./bin/lildax.cjs",
}, },
@@ -111,7 +112,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -147,7 +148,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -174,7 +175,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.82", "@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -196,7 +197,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -220,7 +221,7 @@
}, },
"packages/console/support": { "packages/console/support": {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
@@ -240,7 +241,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.17.9", "version": "1.17.10",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -333,7 +334,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"effect": "catalog:", "effect": "catalog:",
@@ -387,7 +388,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -401,7 +402,7 @@
}, },
"packages/effect-sqlite-node": { "packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node", "name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -413,10 +414,11 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
@@ -444,7 +446,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -460,7 +462,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83",
@@ -479,7 +481,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
@@ -498,7 +500,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.17.9", "version": "1.17.10",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -537,6 +539,8 @@
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
@@ -626,7 +630,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -653,6 +657,18 @@
"@opentui/solid", "@opentui/solid",
], ],
}, },
"packages/protocol": {
"name": "@opencode-ai/protocol",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/schema": { "packages/schema": {
"name": "@opencode-ai/schema", "name": "@opencode-ai/schema",
"dependencies": { "dependencies": {
@@ -676,7 +692,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -691,9 +707,10 @@
}, },
"packages/server": { "packages/server": {
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -703,9 +720,53 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
}, },
}, },
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
"remend": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
},
},
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -718,7 +779,7 @@
}, },
"packages/stats/app": { "packages/stats/app": {
"name": "@opencode-ai/stats-app", "name": "@opencode-ai/stats-app",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*", "@opencode-ai/stats-core": "workspace:*",
@@ -751,7 +812,7 @@
}, },
"packages/stats/core": { "packages/stats/core": {
"name": "@opencode-ai/stats-core", "name": "@opencode-ai/stats-core",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@aws-sdk/client-athena": "3.933.0", "@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0", "@planetscale/database": "1.19.0",
@@ -770,7 +831,7 @@
}, },
"packages/stats/server": { "packages/stats/server": {
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@aws-sdk/client-firehose": "3.933.0", "@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -789,6 +850,7 @@
"packages/storybook": { "packages/storybook": {
"name": "@opencode-ai/storybook", "name": "@opencode-ai/storybook",
"devDependencies": { "devDependencies": {
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "^10.2.13", "@storybook/addon-a11y": "^10.2.13",
@@ -810,7 +872,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@@ -837,11 +899,9 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:", "@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
@@ -886,7 +946,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -1817,6 +1877,8 @@
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"], "@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
@@ -1825,6 +1887,8 @@
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"], "@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
@@ -5843,6 +5907,8 @@
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], "@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
+2 -1
View File
@@ -42,11 +42,12 @@ const inferenceEventTable = new aws.s3tables.Table(
{ name: "request", type: "string", required: false }, { name: "request", type: "string", required: false },
{ name: "client", type: "string", required: false }, { name: "client", type: "string", required: false },
{ name: "user_agent", type: "string", required: false }, { name: "user_agent", type: "string", required: false },
{ name: "model", type: "string", required: false },
{ name: "model_tier", type: "string", required: false },
{ name: "model_variant", type: "string", required: false }, { name: "model_variant", type: "string", required: false },
{ name: "source", type: "string", required: false }, { name: "source", type: "string", required: false },
{ name: "provider", type: "string", required: false }, { name: "provider", type: "string", required: false },
{ name: "provider_model", type: "string", required: false }, { name: "provider_model", type: "string", required: false },
{ name: "model", type: "string", required: false },
{ name: "llm_error_code", type: "int", required: false }, { name: "llm_error_code", type: "int", required: false },
{ name: "llm_error_message", type: "string", required: false }, { name: "llm_error_message", type: "string", required: false },
{ name: "error_response", type: "string", required: false }, { name: "error_response", type: "string", required: false },
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-JJnJVqP2+NiiRVoTKXjGD09lPRyfz8YMfx0m3jBaUzU=", "x86_64-linux": "sha256-Q0CW2wMhVtRM3uILV1kvKAzpQJqBxTp4MLsGlsSoT1s=",
"aarch64-linux": "sha256-tY3/IA+iZbOQQGxrcHMvQ1xqByskZyVI+4LZkU0wVjs=", "aarch64-linux": "sha256-UHpoDqqV+umG47LENB7DLxbCqvwjTCDjHWIFiv6Vmk8=",
"aarch64-darwin": "sha256-9qMRrQKKOgQuOcAgAq8oZacVFD7G4nbAfZawmCRuJdY=", "aarch64-darwin": "sha256-rqlFositEiB1wzx6Jcp5yVQlp8LLYBW+79KXmRpXh2Q=",
"x86_64-darwin": "sha256-dR2VGxDLoAPEmk8SsEF4dhlPBcb9ubztAuVfwxuAD4M=" "x86_64-darwin": "sha256-BKr4YQio3eP0VLZ0z+oyaM93H8dAbSwmlzxT8PH6W8M="
} }
} }
@@ -119,7 +119,6 @@ export async function setupTimelineBenchmark(page: Page, options: { historyTurns
editToolPartsExpanded: true, editToolPartsExpanded: true,
shellToolPartsExpanded: true, shellToolPartsExpanded: true,
showReasoningSummaries: true, showReasoningSummaries: true,
showSessionProgressBar: true,
}, },
}), }),
) )
@@ -13,7 +13,6 @@ export async function installTimelineSettings(page: Page) {
editToolPartsExpanded: true, editToolPartsExpanded: true,
shellToolPartsExpanded: true, shellToolPartsExpanded: true,
showReasoningSummaries: true, showReasoningSummaries: true,
showSessionProgressBar: true,
}, },
}), }),
) )
@@ -261,7 +261,6 @@ async function configurePage(page: Page) {
editToolPartsExpanded: true, editToolPartsExpanded: true,
shellToolPartsExpanded: true, shellToolPartsExpanded: true,
showReasoningSummaries: true, showReasoningSummaries: true,
showSessionProgressBar: true,
}, },
}), }),
) )
@@ -47,7 +47,6 @@ async function configurePage(page: Page) {
editToolPartsExpanded: true, editToolPartsExpanded: true,
shellToolPartsExpanded: true, shellToolPartsExpanded: true,
showReasoningSummaries: true, showReasoningSummaries: true,
showSessionProgressBar: true,
}, },
}), }),
) )
@@ -362,7 +362,6 @@ async function configureSmokePage(page: Page, directory: string) {
editToolPartsExpanded: true, editToolPartsExpanded: true,
shellToolPartsExpanded: true, shellToolPartsExpanded: true,
showReasoningSummaries: true, showReasoningSummaries: true,
showSessionProgressBar: true,
}, },
}), }),
) )
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.9", "version": "1.17.10",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -47,6 +47,7 @@
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4", "@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
+14 -25
View File
@@ -4,7 +4,7 @@ import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file" import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
@@ -31,8 +31,8 @@ import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync" import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global" import { GlobalProvider } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
@@ -51,7 +51,7 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new" import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route" import { legacySessionHref, requireServerKey, sessionHref } from "./utils/session-route"
import Session from "@/pages/session" import Session from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home" import { NewHome, LegacyHome } from "@/pages/home"
@@ -109,37 +109,26 @@ function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>() const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings() const settings = useSettings()
const tabs = useTabs() const tabs = useTabs()
const global = useGlobal() const sync = useServerSync()
const serverSDK = useServerSDK()
const serverKey = createMemo(() => requireServerKey(params.serverKey)) const serverKey = createMemo(() => requireServerKey(params.serverKey))
const placement = createMemo(() => global.sessionPlacement.get(serverKey(), params.id)) const cached = createMemo(() => sync().session.lineage.peek(params.id))
const [resolved] = createResource( const [resolved] = createResource(
() => { () => {
if (placement()) return if (cached()) return
return { id: params.id, sdk: serverSDK() } return { id: params.id, sync: sync() }
},
async ({ id, sdk }) => {
const session = (await sdk.client.session.get({ sessionID: id })).data!
const root = await rootSession(session, (sessionID) =>
sdk.client.session.get({ sessionID }).then((result) => result.data!),
)
return global.sessionPlacement.set({
server: serverKey(),
leafID: session.id,
rootID: root.id,
directory: session.directory,
})
}, },
({ id, sync }) => sync.session.lineage.resolve(id),
) )
const directory = createMemo(() => placement()?.directory ?? resolved()?.directory) const current = createMemo(() => cached() ?? resolved())
const directory = createMemo(() => current()?.session.directory)
const targetDirectory = () => directory()! const targetDirectory = () => directory()!
createEffect(() => { createEffect(() => {
const current = placement() ?? resolved() const session = current()
if (!current) return if (!session) return
tabs.addSessionTab({ tabs.addSessionTab({
server: serverKey(), server: serverKey(),
sessionId: current.rootID, sessionId: session.root.id,
}) })
}) })
@@ -185,6 +185,10 @@ beforeAll(async () => {
mock.module("@/context/server-sync", () => ({ mock.module("@/context/server-sync", () => ({
useServerSync: () => () => ({ useServerSync: () => () => ({
session: {
remember: () => undefined,
set: () => undefined,
},
child: (directory: string) => { child: (directory: string) => {
syncedDirectories.push(directory) syncedDirectories.push(directory)
storedSessions[directory] ??= [] storedSessions[directory] ??= []
@@ -56,16 +56,14 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
export async function sendFollowupDraft(input: FollowupSendInput) { export async function sendFollowupDraft(input: FollowupSendInput) {
const text = draftText(input.draft.prompt) const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt) const images = draftImages(input.draft.prompt)
const [, setStore] = input.serverSync.child(input.draft.sessionDirectory)
const setBusy = () => { const setBusy = () => {
if (!input.optimisticBusy) return if (!input.optimisticBusy) return
setStore("session_status", input.draft.sessionID, { type: "busy" }) input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
} }
const setIdle = () => { const setIdle = () => {
if (!input.optimisticBusy) return if (!input.optimisticBusy) return
setStore("session_status", input.draft.sessionID, { type: "idle" }) input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
} }
const wait = async () => { const wait = async () => {
@@ -234,9 +232,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return Promise.resolve() if (!sessionID) return Promise.resolve()
serverSync().todo.set(sessionID, []) serverSync().session.set("todo", sessionID, [])
const [, setStore] = serverSync().child(sdk().directory)
setStore("todo", sessionID, [])
input.onAbort?.() input.onAbort?.()
@@ -282,6 +278,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
const seed = (dir: string, info: Session) => { const seed = (dir: string, info: Session) => {
serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir) const [, setStore] = serverSync().child(dir)
setStore("session", (list: Session[]) => { setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id) const result = Binary.search(list, info.id, (item) => item.id)
@@ -7,8 +7,8 @@ import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion" import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/ui/file" import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/ui/markdown" import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -335,18 +335,6 @@ export const SettingsGeneral: Component = () => {
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showSessionProgressBar.title")}
description={language.t("settings.general.row.showSessionProgressBar.description")}
>
<div data-action="settings-show-session-progress-bar">
<Switch
checked={settings.general.showSessionProgressBar()}
onChange={(checked) => settings.general.setShowSessionProgressBar(checked)}
/>
</div>
</SettingsRow>
<SettingsRow <SettingsRow
title={language.t("settings.general.row.newLayoutDesigns.title")} title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")} description={language.t("settings.general.row.newLayoutDesigns.description")}
@@ -318,18 +318,6 @@ export const SettingsGeneralV2: Component = () => {
</div> </div>
</SettingsRowV2> </SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showSessionProgressBar.title")}
description={language.t("settings.general.row.showSessionProgressBar.description")}
>
<div data-action="settings-show-session-progress-bar">
<Switch
checked={settings.general.showSessionProgressBar()}
onChange={(checked) => settings.general.setShowSessionProgressBar(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.newLayoutDesigns.title")} title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")} description={language.t("settings.general.row.newLayoutDesigns.description")}
+2 -25
View File
@@ -512,38 +512,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return conn ? global.ensureServerCtx(conn) : undefined return conn ? global.ensureServerCtx(conn) : undefined
}) })
const sdk = createMemo(() => serverCtx()?.sdk ?? null) const sdk = createMemo(() => serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => { const cachedSession = createMemo(() => serverCtx()?.sync.session.peek(tab.sessionId))
const placement = global.sessionPlacement.get(tab.server, tab.sessionId)
const ctx = serverCtx()
if (!placement || !ctx) return
return ctx.sync
.child(placement.directory, { bootstrap: false })[0]
.session.find((session) => session.id === tab.sessionId)
})
const [loadedSession] = createResource( const [loadedSession] = createResource(
() => { () => {
if (cachedSession()) return null
const id = tab.sessionId const id = tab.sessionId
const ctx = serverCtx() const ctx = serverCtx()
return ctx ? { id, ctx } : null return ctx ? { id, ctx } : null
}, },
({ id, ctx }) => ({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
ctx.sdk.client.session
.get({ sessionID: id })
.then((x) => {
const session = x.data
if (!session) return
if (!session.parentID)
global.sessionPlacement.set({
server: tab.server,
leafID: session.id,
rootID: session.id,
directory: session.directory,
})
return session
})
.catch(() => undefined),
) )
const session = createMemo(() => cachedSession() ?? loadedSession()) const session = createMemo(() => cachedSession() ?? loadedSession())
let prefetched = false let prefetched = false
+79 -549
View File
@@ -1,175 +1,23 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" import { createMemo } from "solid-js"
import { diffs as list, message as clean } from "@/utils/diffs" import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import { type createServerSdkContext } from "./server-sdk" import type { createServerSdkContext } from "./server-sdk"
import { type createServerSyncContextInner } from "./server-sync" import type { createServerSyncContextInner } from "./server-sync"
import type { State } from "./global-sync/types"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const sessionFields = new Set([
const isNotFound = (error: unknown) => "session_status",
error instanceof Error && "session_working",
typeof error.cause === "object" && "session_diff",
error.cause !== null && "todo",
(error.cause as { status?: unknown }).status === 404 "permission",
"question",
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) { "message",
const map = new Map(a.map((item) => [item.id, item] as const)) "part",
for (const item of b) map.set(item.id, item) "part_text_accum_delta",
return [...map.values()].sort((x, y) => cmp(x.id, y.id)) ])
}
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[input.messageID]
}
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return [input.message]
const result = Binary.search(messages, input.message.id, (m) => m.id)
const next = [...messages]
next.splice(result.index, 0, input.message)
return next
})
setStore("part", input.message.id, sortParts(input.parts))
}
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return messages
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (!result.found) return messages
const next = [...messages]
next.splice(result.index, 1)
return next
})
setStore("part", (part: Record<string, Part[] | undefined>) => {
if (!(input.messageID in part)) return part
const next = { ...part }
delete next[input.messageID]
return next
})
}
export const createDirSyncContext = ( export const createDirSyncContext = (
directory: string, directory: string,
@@ -177,210 +25,42 @@ export const createDirSyncContext = (
serverSDK: ReturnType<typeof createServerSdkContext>, serverSDK: ReturnType<typeof createServerSdkContext>,
) => { ) => {
const client = serverSDK.createClient({ directory, throwOnError: true }) const client = serverSDK.createClient({ directory, throwOnError: true })
type Child = ReturnType<(typeof serverSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => serverSync.child(directory, { mcp: true })) const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const target = (directory?: string) => {
if (!directory || directory === directory) return current()
return serverSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 2 const data = new Proxy({} as State, {
const historyMessagePageSize = 200 get(_, property: keyof State) {
const inflight = new Map<string, Promise<void>>() if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data)
const inflightDiff = new Map<string, Promise<void>>() if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data]
const inflightTodo = new Map<string, Promise<void>>() return current()[0][property]
const optimistic = new Map<string, Map<string, OptimisticItem>>() },
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
}) })
const set = ((...input: unknown[]) => {
if (typeof input[0] === "string" && sessionFields.has(input[0])) {
return (serverSync.session.set as (...args: unknown[]) => unknown)(...input)
}
const result = (current()[1] as (...args: unknown[]) => unknown)(...input)
if (input[0] === "session") current()[0].session.forEach(serverSync.session.remember)
return result
}) as SetStoreFunction<State>
const getSession = (sessionID: string) => { const index = (sessionID: string) => {
const store = current()[0] const session = serverSync.session.get(sessionID)
const match = Binary.search(store.session, sessionID, (s) => s.id) if (!session || session.directory !== directory) return
if (match.found) return store.session[match.index] const [store, setStore] = current()
return undefined const result = Binary.search(store.session, session.id, (item) => item.id)
} if (result.found) {
setStore("session", result.index, reconcile(session))
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return return
} }
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = serverSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(serverSDK.scope, directory, sessionIDs)
for (const sessionID of sessionIDs) {
serverSync.todo.set(sessionID, undefined)
}
setStore( setStore(
produce((draft) => { "session",
dropSessionCaches(draft, sessionIDs) produce((draft) => void draft.splice(result.index, 0, session)),
}),
) )
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: { client: typeof client; sessionID: string; limit: number; before?: string }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = serverSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
scope: serverSDK.scope,
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.catch((error) => {
if (isNotFound(error) && !tracked(input.directory, input.sessionID)) return
throw error
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
} }
return { return {
get data() { data,
return current()[0] set,
},
get set(): Setter {
return current()[1]
},
get status() { get status() {
return current()[0].status return current()[0].status
}, },
@@ -389,24 +69,20 @@ export const createDirSyncContext = (
}, },
get project() { get project() {
const store = current()[0] const store = current()[0]
const match = Binary.search(serverSync.data.project, store.project, (p) => p.id) const match = Binary.search(serverSync.data.project, store.project, (project) => project.id)
if (match.found) return serverSync.data.project[match.index] if (match.found) return serverSync.data.project[match.index]
return undefined
}, },
session: { session: {
get: getSession, get(sessionID: string) {
const session = serverSync.session.get(sessionID)
if (session?.directory === directory) return session
},
optimistic: { optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const _directory = input.directory ?? directory serverSync.session.optimistic.add(input)
const [, setStore] = target(input.directory)
setOptimistic(_directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
}, },
remove(input: { directory?: string; sessionID: string; messageID: string }) { remove(input: { directory?: string; sessionID: string; messageID: string }) {
const _directory = input.directory ?? directory serverSync.session.optimistic.remove(input)
const [, setStore] = target(input.directory)
clearOptimistic(_directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
}, },
}, },
addOptimisticMessage(input: { addOptimisticMessage(input: {
@@ -417,194 +93,48 @@ export const createDirSyncContext = (
model: { providerID: string; modelID: string } model: { providerID: string; modelID: string }
variant?: string variant?: string
}) { }) {
const message: Message = { serverSync.session.optimistic.add({
id: input.messageID,
sessionID: input.sessionID, sessionID: input.sessionID,
role: "user", message: {
time: { created: Date.now() }, id: input.messageID,
agent: input.agent, sessionID: input.sessionID,
model: { ...input.model, variant: input.variant }, role: "user",
} time: { created: Date.now() },
const [, setStore] = target() agent: input.agent,
setOptimistic(directory, input.sessionID, { message, parts: input.parts }) model: { ...input.model, variant: input.variant },
setOptimisticAdd(setStore as (...args: unknown[]) => void, { },
sessionID: input.sessionID,
message,
parts: input.parts, parts: input.parts,
}) })
}, },
async sync(sessionID: string, opts?: { force?: boolean }) { async sync(sessionID: string, options?: { force?: boolean }) {
const [store, setStore] = serverSync.child(directory) await serverSync.session.sync(sessionID, options)
const key = keyFor(directory, sessionID) index(sessionID)
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(serverSDK.scope, directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID }))
.then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
.catch((error) => {
if (isNotFound(error) && !tracked(directory, sessionID)) return
throw error
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
}, },
async diff(sessionID: string, opts?: { force?: boolean }) { diff: serverSync.session.diff,
const [store, setStore] = serverSync.child(directory) todo: serverSync.session.todo,
touch(directory, setStore, sessionID) history: serverSync.session.history,
if (store.session_diff[sessionID] !== undefined && !opts?.force) return evict(sessionID: string) {
serverSync.session.evict(sessionID)
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = serverSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = serverSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
serverSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
serverSync.todo.set(sessionID, list)
}),
)
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const [, setStore] = serverSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, _directory = directory) {
const [, setStore] = serverSync.child(_directory)
seenFor(_directory).delete(sessionID)
evict(_directory, setStore, [sessionID])
}, },
fetch: async (count = 10) => { fetch: async (count = 10) => {
const [store, setStore] = serverSync.child(directory) const [store, setStore] = current()
setStore("limit", (x) => x + count) setStore("limit", (value) => value + count)
await client.session.list().then((x) => { const response = await client.session.list()
const sessions = (x.data ?? []) const sessions = (response.data ?? [])
.filter((s) => !!s?.id) .filter((session) => !!session?.id)
.sort((a, b) => cmp(a.id, b.id)) .sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit) .slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" })) sessions.forEach(serverSync.session.remember)
}) setStore("session", reconcile(sessions, { key: "id" }))
}, },
more: createMemo(() => current()[0].session.length >= current()[0].limit), more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => { archive: async (sessionID: string) => {
const [, setStore] = serverSync.child(directory) await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
await client.session.update({ sessionID, time: { archived: Date.now() } }) current()[1](
setStore( "session",
produce((draft) => { produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id) const match = Binary.search(draft, sessionID, (session) => session.id)
if (match.found) draft.session.splice(match.index, 1) if (match.found) draft.splice(match.index, 1)
}), }),
) )
}, },
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap" import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
@@ -7,28 +7,25 @@ import type {
ProviderAuthResponse, ProviderAuthResponse,
QuestionRequest, QuestionRequest,
Session, Session,
Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js" import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import type { ServerSession } from "../server-session"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../server-sync" import { loadMcpQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { ScopedKey, type ServerScope } from "@/utils/server-scope"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
path: Path path: Path
project: Project[] project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: NormalizedProviderListResponse provider: NormalizedProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
@@ -215,6 +212,7 @@ export async function bootstrapDirectory(input: {
provider: NormalizedProviderListResponse provider: NormalizedProviderListResponse
} }
queryClient: QueryClient queryClient: QueryClient
session?: ServerSession
}) { }) {
const loading = input.store.status !== "complete" const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project) const seededProject = projectID(input.directory, input.global.project)
@@ -238,7 +236,30 @@ export async function bootstrapDirectory(input: {
.then((data) => input.setStore("agent", data)), .then((data) => input.setStore("agent", data)),
() => () =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), () =>
retry(() =>
input.sdk.session.status().then(async (x) => {
if (input.session) {
const statuses = x.data ?? {}
await Promise.all(
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
)
input.session.set(
"session_status",
produce((draft) => {
for (const sessionID of Object.keys(draft)) {
if (statuses[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
}
}),
)
for (const [sessionID, status] of Object.entries(statuses)) {
input.session.set("session_status", sessionID, reconcile(status))
}
}
if (!input.session) input.setStore("session_status", x.data!)
}),
),
!seededProject && !seededProject &&
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
!seededPath && !seededPath &&
@@ -263,21 +284,25 @@ export async function bootstrapDirectory(input: {
const grouped = groupBySession( const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
) )
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() => const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
return warm.then(() =>
batch(() => { batch(() => {
for (const sessionID of Object.keys(input.store.permission)) { const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
input.setStore("permission", sessionID, []) if (input.session?.get(sessionID)?.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, [])
} }
for (const [sessionID, permissions] of Object.entries(grouped)) { for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore( const value = reconcile(
"permission", permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
sessionID, { key: "id" },
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
) )
if (input.session) input.session.set("permission", sessionID, value)
if (!input.session) input.setStore("permission", sessionID, value)
} }
}), }),
) )
@@ -288,21 +313,25 @@ export async function bootstrapDirectory(input: {
input.sdk.question.list().then((x) => { input.sdk.question.list().then((x) => {
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() => const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
return warm.then(() =>
batch(() => { batch(() => {
for (const sessionID of Object.keys(input.store.question)) { const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
input.setStore("question", sessionID, []) if (input.session?.get(sessionID)?.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, [])
} }
for (const [sessionID, questions] of Object.entries(grouped)) { for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore( const value = reconcile(
"question", questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
sessionID, { key: "id" },
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
) )
if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value)
} }
}), }),
) )
@@ -1,7 +1,7 @@
import { beforeAll, describe, expect, mock, test } from "bun:test" import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot, getOwner, type Owner } from "solid-js" import { createRoot, getOwner, type Owner } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { State } from "./types" import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync" import type { QueryOptionsApi } from "../server-sync"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
@@ -17,7 +17,7 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQuery } from "@tanstack/solid-query" import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync" import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils" import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
@@ -17,6 +17,21 @@ import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs" import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const SESSION_CONTENT_EVENTS = new Set([
"session.diff",
"todo.updated",
"session.status",
"message.updated",
"message.removed",
"message.part.updated",
"message.part.removed",
"message.part.delta",
"permission.asked",
"permission.replied",
"question.asked",
"question.replied",
"question.rejected",
])
export function applyGlobalEvent(input: { export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown } event: { type: string; properties?: unknown }
@@ -100,8 +115,11 @@ export function applyDirectoryEvent(input: {
vcsCache?: VcsCache vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number retainedLimit?: number
sessionContent?: boolean
permission?: State["permission"]
}) { }) {
const event = input.event const event = input.event
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0) const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
switch (event.type) { switch (event.type) {
case "server.instance.disposed": { case "server.instance.disposed": {
@@ -117,7 +135,7 @@ export function applyDirectoryEvent(input: {
} }
const next = input.store.session.slice() const next = input.store.session.slice()
next.splice(result.index, 0, info) next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.store.permission }) const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" })) input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
@@ -147,7 +165,7 @@ export function applyDirectoryEvent(input: {
} }
const next = input.store.session.slice() const next = input.store.session.slice()
next.splice(result.index, 0, info) next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.store.permission }) const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" })) input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
break break
@@ -1,139 +0,0 @@
import { describe, expect, test } from "bun:test"
import {
clearSessionPrefetch,
clearSessionPrefetchDirectory,
getSessionPrefetch,
runSessionPrefetch,
setSessionPrefetch,
shouldSkipSessionPrefetch,
} from "./session-prefetch"
import { ServerScope } from "@/utils/server-scope"
const scope = ServerScope.local
describe("session prefetch", () => {
test("stores and clears message metadata by directory", () => {
clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
clearSessionPrefetch(scope, "/tmp/b", ["ses_1"])
setSessionPrefetch({
directory: "/tmp/a",
scope,
sessionID: "ses_1",
limit: 200,
cursor: "abc",
complete: false,
at: 123,
})
expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toEqual({
limit: 200,
cursor: "abc",
complete: false,
at: 123,
})
expect(getSessionPrefetch(scope, "/tmp/b", "ses_1")).toBeUndefined()
clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toBeUndefined()
})
test("dedupes inflight work", async () => {
clearSessionPrefetch(scope, "/tmp/c", ["ses_2"])
let calls = 0
const run = () =>
runSessionPrefetch({
directory: "/tmp/c",
scope,
sessionID: "ses_2",
task: async () => {
calls += 1
return { limit: 100, cursor: "next", complete: true, at: 456 }
},
})
const [a, b] = await Promise.all([run(), run()])
expect(calls).toBe(1)
expect(a).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
expect(b).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
})
test("clears a whole directory", () => {
setSessionPrefetch({
scope,
directory: "/tmp/d",
sessionID: "ses_1",
limit: 10,
cursor: "a",
complete: true,
at: 1,
})
setSessionPrefetch({
scope,
directory: "/tmp/d",
sessionID: "ses_2",
limit: 20,
cursor: "b",
complete: false,
at: 2,
})
setSessionPrefetch({
scope,
directory: "/tmp/e",
sessionID: "ses_1",
limit: 30,
cursor: "c",
complete: true,
at: 3,
})
clearSessionPrefetchDirectory(scope, "/tmp/d")
expect(getSessionPrefetch(scope, "/tmp/d", "ses_1")).toBeUndefined()
expect(getSessionPrefetch(scope, "/tmp/d", "ses_2")).toBeUndefined()
expect(getSessionPrefetch(scope, "/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
})
test("isolates identical directories and sessions by server scope", () => {
const remote = "https://debian.example" as ServerScope
setSessionPrefetch({ scope, directory: "/repo", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
setSessionPrefetch({ scope: remote, directory: "/repo", sessionID: "ses_1", limit: 20, complete: true, at: 2 })
expect(getSessionPrefetch(scope, "/repo", "ses_1")?.limit).toBe(10)
expect(getSessionPrefetch(remote, "/repo", "ses_1")?.limit).toBe(20)
})
test("refreshes stale first-page prefetched history", () => {
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 200, cursor: "x", complete: false, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(false)
})
test("keeps deeper or complete history cached", () => {
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 400, cursor: "x", complete: false, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(true)
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 120, complete: true, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(true)
})
})
@@ -1,107 +0,0 @@
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
const key = (scope: ServerScope, directory: string, sessionID: string) => ScopedKey.from(scope, directory, sessionID)
export const SESSION_PREFETCH_TTL = 15_000
type Meta = {
limit: number
cursor?: string
complete: boolean
at: number
}
export function shouldSkipSessionPrefetch(input: { message: boolean; info?: Meta; chunk: number; now?: number }) {
if (input.message) {
if (!input.info) return true
if (input.info.complete) return true
if (input.info.limit > input.chunk) return true
} else {
if (!input.info) return false
}
return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL
}
const cache = new Map<string, Meta>()
const inflight = new Map<string, Promise<Meta | undefined>>()
const rev = new Map<string, number>()
const version = (id: string) => rev.get(id) ?? 0
export function getSessionPrefetch(scope: ServerScope, directory: string, sessionID: string) {
return cache.get(key(scope, directory, sessionID))
}
export function getSessionPrefetchPromise(scope: ServerScope, directory: string, sessionID: string) {
return inflight.get(key(scope, directory, sessionID))
}
export function clearSessionPrefetchInflight(scope: ServerScope) {
const prefix = ScopedKey.prefix(scope)
for (const id of inflight.keys()) {
if (id.startsWith(prefix)) inflight.delete(id)
}
}
export function isSessionPrefetchCurrent(scope: ServerScope, directory: string, sessionID: string, value: number) {
return version(key(scope, directory, sessionID)) === value
}
export function runSessionPrefetch(input: {
directory: string
scope: ServerScope
sessionID: string
task: (value: number) => Promise<Meta | undefined>
}) {
const id = key(input.scope, input.directory, input.sessionID)
const pending = inflight.get(id)
if (pending) return pending
const value = version(id)
const promise = input.task(value).finally(() => {
if (inflight.get(id) === promise) inflight.delete(id)
})
inflight.set(id, promise)
return promise
}
export function setSessionPrefetch(input: {
directory: string
scope: ServerScope
sessionID: string
limit: number
cursor?: string
complete: boolean
at?: number
}) {
cache.set(key(input.scope, input.directory, input.sessionID), {
limit: input.limit,
cursor: input.cursor,
complete: input.complete,
at: input.at ?? Date.now(),
})
}
export function clearSessionPrefetch(scope: ServerScope, directory: string, sessionIDs: Iterable<string>) {
for (const sessionID of sessionIDs) {
if (!sessionID) continue
const id = key(scope, directory, sessionID)
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}
export function clearSessionPrefetchDirectory(scope: ServerScope, directory: string) {
const prefix = ScopedKey.prefix(scope, directory)
const keys = new Set([...cache.keys(), ...inflight.keys()])
for (const id of keys) {
if (!id.startsWith(prefix)) continue
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}
@@ -15,7 +15,7 @@ import type {
Todo, Todo,
VcsInfo, VcsInfo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store" import type { SetStoreFunction, Store } from "solid-js/store"
@@ -1,5 +1,5 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
-3
View File
@@ -8,13 +8,11 @@ import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web" import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { createSessionPlacementStore } from "@/utils/session-placement"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global", name: "Global",
init: () => { init: () => {
const server = useServer() const server = useServer()
const sessionPlacement = createSessionPlacementStore()
const serverHealth = useServerHealth( const serverHealth = useServerHealth(
() => server.list, () => server.list,
() => true, () => true,
@@ -87,7 +85,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
}, },
}, },
}, },
sessionPlacement,
ensureServerCtx(conn: ServerConnection.Any) { ensureServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn) return ensureServerCtx(conn)
}, },
@@ -0,0 +1,98 @@
import { describe, expect, test } from "bun:test"
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
import { createServerSession } from "./server-session"
const session = (id: string, parentID?: string): Session => ({
id,
slug: id,
projectID: "project",
directory: "/repo",
title: id,
version: "1",
parentID,
time: { created: 1, updated: 1 },
})
function setup(sessions: Record<string, Session>) {
const get: unknown[] = []
const messages: unknown[] = []
const client = {
session: {
get: async (input: unknown) => {
get.push(input)
const id = (input as { sessionID: string }).sessionID
return { data: sessions[id] }
},
messages: async (input: unknown) => {
messages.push(input)
return { data: [], response: { headers: new Headers() } }
},
diff: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
},
} as unknown as OpencodeClient
return { get, messages, store: createServerSession(client) }
}
describe("server session", () => {
test("resolves lineage by session ID without directory", async () => {
const ctx = setup({ child: session("child", "root"), root: session("root") })
const result = await ctx.store.lineage.resolve("child")
expect(result.root.id).toBe("root")
expect(ctx.get).toEqual([{ sessionID: "child" }, { sessionID: "root" }])
expect(ctx.store.lineage.peek("child")).toEqual(result)
})
test("loads session content through the server client", async () => {
const ctx = setup({ root: session("root") })
await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }])
expect(ctx.store.data.message.root).toEqual([])
})
test("applies events without a directory store", () => {
const ctx = setup({})
ctx.store.apply({ type: "session.created", properties: { info: session("root") } })
ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } })
expect(ctx.store.get("root")?.directory).toBe("/repo")
expect(ctx.store.data.session_working("root")).toBe(true)
})
test("preserves pinned session content under server-wide cache pressure", () => {
const ctx = setup({})
ctx.store.pin("active")
ctx.store.optimistic.add({
sessionID: "active",
message: {
id: "message",
sessionID: "active",
role: "assistant",
time: { created: 1 },
parentID: "parent",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "agent",
path: { cwd: "/repo", root: "/repo" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [],
})
for (let index = 0; index < 50; index++) {
ctx.store.apply({
type: "session.status",
properties: { sessionID: `session-${index}`, status: { type: "busy" } },
})
}
expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"])
})
})
+626
View File
@@ -0,0 +1,626 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import type {
Message,
OpencodeClient,
Part,
PermissionRequest,
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
import { rootSession } from "@/utils/session-route"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 2
const historyMessagePageSize = 200
const sessionInfoLimit = 2_048
type OptimisticItem = {
message: Message
parts: Part[]
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
function mergeOptimisticPage(
page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean },
items: OptimisticItem[],
) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, item.part]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
if (!result.found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (result.found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, merge(current ?? [], item.parts))
}
return {
...page,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
confirmed,
}
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
if (map.get(key) === promise) map.delete(key)
})
map.set(key, promise)
return promise
}
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const items = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) items.set(item.id, item)
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
}
export function createServerSession(client: OpencodeClient) {
const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, SnapshotFileDiff[]>,
todo: {} as Record<string, Todo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
message: {} as Record<string, Message[]>,
part: {} as Record<string, Part[]>,
part_text_accum_delta: {} as Record<string, string>,
session_working(id: string) {
return (this.session_status[id]?.type ?? "idle") !== "idle"
},
})
const requests = new Map<string, Promise<Session>>()
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const seen = new Set<string>()
const infoSeen = new Set<string>()
const pinned = new Map<string, number>()
const generations = new Map<string, number>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number | undefined>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean | undefined>,
loading: {} as Record<string, boolean | undefined>,
at: {} as Record<string, number | undefined>,
})
const remember = (session: Session) => {
setData("info", session.id, reconcile(session))
infoSeen.delete(session.id)
infoSeen.add(session.id)
if (infoSeen.size > sessionInfoLimit) {
const preserve = new Set([
...pinned.keys(),
...requests.keys(),
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.question)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.session_status)
.filter(([, status]) => status.type !== "idle")
.map(([sessionID]) => sessionID),
])
for (const sessionID of preserve) {
let current = data.info[sessionID]
while (current) {
preserve.add(current.id)
current = current.parentID ? data.info[current.parentID] : undefined
}
}
const stale: string[] = []
for (const sessionID of infoSeen) {
if (infoSeen.size - stale.length <= sessionInfoLimit) break
if (!preserve.has(sessionID)) stale.push(sessionID)
}
stale.forEach((sessionID) => infoSeen.delete(sessionID))
setData(
"info",
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
)
}
return session
}
const resolve = (sessionID: string, options?: { force?: boolean }) => {
const cached = data.info[sessionID]
if (cached && !options?.force) return Promise.resolve(cached)
const pending = requests.get(sessionID)
if (pending) return pending
const generation = generations.get(sessionID) ?? 0
const request = client.session.get({ sessionID }).then((result) => {
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
if ((generations.get(sessionID) ?? 0) !== generation) return result.data
return remember(result.data)
})
requests.set(sessionID, request)
void request.then(
() => {
if (requests.get(sessionID) === request) requests.delete(sessionID)
},
() => {
if (requests.get(sessionID) === request) requests.delete(sessionID)
},
)
return request
}
const peekLineage = (sessionID: string) => {
const session = data.info[sessionID]
if (!session) return
const seen = new Set([session.id])
let root = session
while (root.parentID) {
if (seen.has(root.parentID)) throw new Error(`Session parent cycle: ${root.parentID}`)
seen.add(root.parentID)
const parent = data.info[root.parentID]
if (!parent) return
root = parent
}
return { session, root }
}
const clearOptimistic = (sessionID: string, messageID?: string) => {
if (!messageID) {
optimistic.delete(sessionID)
return
}
const items = optimistic.get(sessionID)
if (!items) return
items.delete(messageID)
if (items.size === 0) optimistic.delete(sessionID)
}
const evict = (sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
sessionIDs.forEach((sessionID) => {
generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1)
clearOptimistic(sessionID)
requests.delete(sessionID)
inflight.delete(sessionID)
inflightDiff.delete(sessionID)
inflightTodo.delete(sessionID)
})
setData(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
delete draft.limit[sessionID]
delete draft.cursor[sessionID]
delete draft.complete[sessionID]
delete draft.loading[sessionID]
delete draft.at[sessionID]
}
}),
)
}
const protectedSessions = () =>
new Set([
...pinned.keys(),
...requests.keys(),
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...optimistic.keys(),
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.question)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.session_status)
.filter(([, status]) => status.type !== "idle")
.map(([sessionID]) => sessionID),
])
const touch = (sessionID: string) =>
evict(
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
)
const fetchMessages = async (sessionID: string, limit: number, before?: string) => {
const response = await retry(() => client.session.messages({ sessionID, limit, before }))
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
part: items.map((item) => ({
id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
})),
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
complete: !response.response.headers.get("x-next-cursor"),
}
}
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
if (meta.loading[sessionID]) return
const generation = generations.get(sessionID) ?? 0
setMeta("loading", sessionID, true)
await fetchMessages(sessionID, limit, before)
.then((page) => {
if ((generations.get(sessionID) ?? 0) !== generation) return
const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID))
const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session
batch(() => {
setData("message", sessionID, reconcile(messages, { key: "id" }))
for (const item of next.part) {
const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type))
if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" }))
}
setMeta("limit", sessionID, messages.length)
setMeta("cursor", sessionID, next.cursor)
setMeta("complete", sessionID, next.complete)
setMeta("at", sessionID, Date.now())
})
})
.finally(() => {
if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false)
})
}
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
touch(sessionID)
return runInflight(inflight, sessionID, async () => {
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
if (cached && data.info[sessionID] && !options?.force) return
await Promise.all([
resolve(sessionID, options),
cached && !options?.force
? Promise.resolve()
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
])
})
}
const prefetch = async (sessionID: string, limit: number) => {
touch(sessionID)
await inflight.get(sessionID)
if (
Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 &&
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit)
)
return
await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit))
}
const eventSessionID = (event: { type: string; properties?: unknown }) => {
const properties = event.properties
if (!properties || typeof properties !== "object") return
if ("sessionID" in properties && typeof properties.sessionID === "string") return properties.sessionID
if (
"info" in properties &&
properties.info &&
typeof properties.info === "object" &&
"sessionID" in properties.info &&
typeof properties.info.sessionID === "string"
)
return properties.info.sessionID
if (
"part" in properties &&
properties.part &&
typeof properties.part === "object" &&
"sessionID" in properties.part &&
typeof properties.part.sessionID === "string"
)
return properties.part.sessionID
}
const apply = (event: { type: string; properties?: unknown }) => {
const eventID = eventSessionID(event)
if (eventID) {
touch(eventID)
if (!data.info[eventID]) void resolve(eventID).catch(() => {})
}
switch (event.type) {
case "session.created":
remember((event.properties as { info: Session }).info)
return
case "session.updated": {
const info = (event.properties as { info: Session }).info
remember(info)
if (info.time.archived) evict([info.id])
return
}
case "session.deleted": {
const sessionID = (event.properties as { info: Session }).info.id
infoSeen.delete(sessionID)
setData(
"info",
produce((draft) => void delete draft[sessionID]),
)
evict([sessionID])
return
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
return
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
return
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus }
setData("session_status", props.sessionID, reconcile(props.status))
return
}
case "message.updated": {
const info = cleanMessage((event.properties as { info: Message }).info)
const messages = data.message[info.sessionID]
if (!messages) {
setData("message", info.sessionID, [info])
return
}
const result = Binary.search(messages, info.id, (message) => message.id)
if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
if (!result.found)
setData("message", info.sessionID, (value = []) => {
const next = value.slice()
next.splice(result.index, 0, info)
return next
})
return
}
case "message.removed": {
const props = event.properties as { sessionID: string; messageID: string }
setData(
produce((draft) => {
const messages = draft.message[props.sessionID]
if (messages) {
const result = Binary.search(messages, props.messageID, (message) => message.id)
if (result.found) messages.splice(result.index, 1)
}
for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id]
delete draft.part[props.messageID]
}),
)
return
}
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) return
setData(
"part_text_accum_delta",
produce((draft) => void delete draft[part.id]),
)
const parts = data.part[part.messageID]
if (!parts) {
setData("part", part.messageID, [part])
return
}
const result = Binary.search(parts, part.id, (item) => item.id)
if (result.found) setData("part", part.messageID, result.index, reconcile(part))
if (!result.found)
setData("part", part.messageID, (value = []) => {
const next = value.slice()
next.splice(result.index, 0, part)
return next
})
return
}
case "message.part.removed": {
const props = event.properties as { messageID: string; partID: string }
setData(
produce((draft) => {
delete draft.part_text_accum_delta[props.partID]
const parts = draft.part[props.messageID]
if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id)
if (result.found) parts.splice(result.index, 1)
if (parts.length === 0) delete draft.part[props.messageID]
}),
)
return
}
case "message.part.delta": {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = data.part[props.messageID]
if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) return
const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field]
setData(
"part_text_accum_delta",
props.partID,
(value) => (value ?? (typeof current === "string" ? current : "")) + props.delta,
)
setData(
"part",
props.messageID,
produce((draft) => {
if (!draft) return
const part = draft[result.index]
const field = props.field as keyof typeof part
;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta
}),
)
return
}
case "permission.asked": {
const permission = event.properties as PermissionRequest
const permissions = data.permission[permission.sessionID] ?? []
const result = Binary.search(permissions, permission.id, (item) => item.id)
if (result.found) setData("permission", permission.sessionID, result.index, reconcile(permission))
if (!result.found)
setData(
"permission",
permission.sessionID,
produce((draft = []) => void draft.splice(result.index, 0, permission)),
)
return
}
case "permission.replied": {
const props = event.properties as { sessionID: string; requestID: string }
setData(
"permission",
props.sessionID,
produce((draft) => {
if (!draft) return
const result = Binary.search(draft, props.requestID, (item) => item.id)
if (result.found) draft.splice(result.index, 1)
}),
)
return
}
case "question.asked": {
const question = event.properties as QuestionRequest
const questions = data.question[question.sessionID] ?? []
const result = Binary.search(questions, question.id, (item) => item.id)
if (result.found) setData("question", question.sessionID, result.index, reconcile(question))
if (!result.found)
setData(
"question",
question.sessionID,
produce((draft = []) => void draft.splice(result.index, 0, question)),
)
return
}
case "question.replied":
case "question.rejected": {
const props = event.properties as { sessionID: string; requestID: string }
setData(
"question",
props.sessionID,
produce((draft) => {
if (!draft) return
const result = Binary.search(draft, props.requestID, (item) => item.id)
if (result.found) draft.splice(result.index, 1)
}),
)
}
}
}
return {
data,
set: setData,
get: (sessionID: string) => data.info[sessionID],
peek: (sessionID: string) => data.info[sessionID],
remember,
resolve,
lineage: {
peek: peekLineage,
async resolve(sessionID: string) {
const session = await resolve(sessionID)
return { session, root: await rootSession(session, resolve) }
},
},
sync,
prefetch,
shouldPrefetch(sessionID: string, limit: number) {
if (data.message[sessionID] === undefined) return true
if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true
if (meta.complete[sessionID]) return false
return (meta.limit[sessionID] ?? 0) <= limit
},
fresh(sessionID: string, ttl: number) {
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
},
optimistic: {
add(input: { sessionID: string; message: Message; parts: Part[] }) {
const items = optimistic.get(input.sessionID)
if (items) items.set(input.message.id, input)
if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]]))
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
setData(
"part",
input.message.id,
input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
)
},
remove(input: { sessionID: string; messageID: string }) {
clearOptimistic(input.sessionID, input.messageID)
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData(
"part",
produce((draft) => void delete draft[input.messageID]),
)
},
},
diff(sessionID: string, options?: { force?: boolean }) {
touch(sessionID)
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightDiff, sessionID, () => {
const generation = generations.get(sessionID) ?? 0
return retry(() => client.session.diff({ sessionID })).then((result) => {
if ((generations.get(sessionID) ?? 0) !== generation) return
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
})
})
},
todo(sessionID: string, options?: { force?: boolean }) {
touch(sessionID)
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightTodo, sessionID, () => {
const generation = generations.get(sessionID) ?? 0
return retry(() => client.session.todo({ sessionID })).then((result) => {
if ((generations.get(sessionID) ?? 0) !== generation) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
})
})
},
history: {
more: (sessionID: string) =>
data.message[sessionID] !== undefined &&
meta.limit[sessionID] !== undefined &&
!meta.complete[sessionID] &&
!!meta.cursor[sessionID],
loading: (sessionID: string) => meta.loading[sessionID] ?? false,
async loadMore(sessionID: string, count = historyMessagePageSize) {
touch(sessionID)
if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return
await loadMessages(sessionID, count, meta.cursor[sessionID], "prepend")
},
},
evict(sessionID: string) {
if (protectedSessions().has(sessionID)) return
seen.delete(sessionID)
evict([sessionID])
},
pin(sessionID: string) {
pinned.set(sessionID, (pinned.get(sessionID) ?? 0) + 1)
touch(sessionID)
},
unpin(sessionID: string) {
const count = pinned.get(sessionID)
if (!count || count === 1) pinned.delete(sessionID)
if (count && count > 1) pinned.set(sessionID, count - 1)
},
apply,
}
}
export type ServerSession = ReturnType<typeof createServerSession>
+18 -33
View File
@@ -1,4 +1,4 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client" import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -17,8 +17,7 @@ import {
loadProvidersQuery, loadProvidersQuery,
} from "./global-sync/bootstrap" } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store" import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim" import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
@@ -29,7 +28,8 @@ import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils" import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key" import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync" import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { createRefCountMap } from "@/utils/refcount" import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global" import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
@@ -37,15 +37,13 @@ import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { toggleMcp } from "./global-sync/mcp" import { toggleMcp } from "./global-sync/mcp"
import { createServerSession } from "./server-session"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
error?: InitError error?: InitError
path: Path path: Path
project: Project[] project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: NormalizedProviderListResponse provider: NormalizedProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
@@ -117,7 +115,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return !bootstrap.isPending return !bootstrap.isPending
}, },
project: [], project: [],
session_todo: {},
provider_auth: {}, provider_auth: {},
get path() { get path() {
const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" } const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" }
@@ -187,20 +184,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return (setGlobalStore as (...args: unknown[]) => unknown)(...input) return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore }) as typeof setGlobalStore
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return
if (!todos) {
setGlobalStore(
"session_todo",
produce((draft) => {
delete draft[sessionID]
}),
)
return
}
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
}
const paused = () => untrack(() => globalStore.reload) !== undefined const paused = () => untrack(() => globalStore.reload) !== undefined
const queue = createRefreshQueue({ const queue = createRefreshQueue({
@@ -210,6 +193,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
bootstrapInstance, bootstrapInstance,
}) })
const session = createServerSession(serverSDK.client)
const children = createChildStoreManager({ const children = createChildStoreManager({
owner, owner,
scope: serverSDK.scope, scope: serverSDK.scope,
@@ -238,7 +223,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
sessionMeta.delete(key) sessionMeta.delete(key)
sdkCache.delete(key) sdkCache.delete(key)
clearProviderRev(serverSDK.scope, key) clearProviderRev(serverSDK.scope, key)
clearSessionPrefetchDirectory(serverSDK.scope, key)
}, },
translate: language.t, translate: language.t,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
@@ -262,11 +246,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (meta && meta.limit >= retainedLimit) { if (meta && meta.limit >= retainedLimit) {
const next = trimSessions(store.session, { const next = trimSessions(store.session, {
limit: retainedLimit, limit: retainedLimit,
permission: store.permission, permission: session.data.permission,
}) })
if (next.length !== store.session.length) { if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" })) setStore("session", reconcile(next, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
} }
children.unpin(key) children.unpin(key)
return return
@@ -289,11 +272,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0) const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
const childSessions = store.session.filter((s) => !!s.parentID) const childSessions = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...childSessions], { const next = trimSessions([...nonArchived, ...childSessions], {
limit, limit,
permission: store.permission, permission: session.data.permission,
}) })
batch(() => { batch(() => {
next.forEach(session.remember)
setStore( setStore(
"sessionTotal", "sessionTotal",
estimateRootSessionTotal({ estimateRootSessionTotal({
@@ -302,8 +286,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
limited: x.limited, limited: x.limited,
}), }),
) )
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(next, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
}) })
sessionMeta.set(key, { limit }) sessionMeta.set(key, { limit })
}) })
@@ -357,6 +340,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
loadSessions, loadSessions,
translate: language.t, translate: language.t,
queryClient, queryClient,
session,
}) })
}) })
@@ -374,6 +358,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const event = e.details const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500 const recent = bootingRoot || Date.now() - bootedAt < 1500
session.apply(event)
if (directory === "global") { if (directory === "global") {
applyGlobalEvent({ applyGlobalEvent({
event, event,
@@ -403,8 +389,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
store, store,
setStore, setStore,
push: queue.push, push: queue.push,
setSessionTodo,
retainedLimit: sessionMeta.get(key)?.limit, retainedLimit: sessionMeta.get(key)?.limit,
sessionContent: false,
permission: session.data.permission,
vcsCache: children.vcsCache.get(key), vcsCache: children.vcsCache.get(key),
loadLsp: () => { loadLsp: () => {
void queryClient.fetchQuery(queryOptionsApi.lsp(key)) void queryClient.fetchQuery(queryOptionsApi.lsp(key))
@@ -478,9 +465,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
// bootstrap, // bootstrap,
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateConfigMutation.mutateAsync,
project: projectApi, project: projectApi,
todo: { session,
set: setSessionTodo,
},
mcp: { mcp: {
toggle: async (directory: string, name: string) => { toggle: async (directory: string, name: string) => {
const key = directoryKey(directory) const key = directoryKey(directory)
-9
View File
@@ -31,7 +31,6 @@ export interface Settings {
showReasoningSummaries: boolean showReasoningSummaries: boolean
shellToolPartsExpanded: boolean shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean editToolPartsExpanded: boolean
showSessionProgressBar: boolean
showCustomAgents: boolean showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom" mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean newLayoutDesigns?: boolean
@@ -117,7 +116,6 @@ const defaultSettings: Settings = {
showReasoningSummaries: false, showReasoningSummaries: false,
shellToolPartsExpanded: false, shellToolPartsExpanded: false,
editToolPartsExpanded: false, editToolPartsExpanded: false,
showSessionProgressBar: true,
showCustomAgents: false, showCustomAgents: false,
mobileTitlebarPosition: "top", mobileTitlebarPosition: "top",
}, },
@@ -239,13 +237,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setEditToolPartsExpanded(value: boolean) { setEditToolPartsExpanded(value: boolean) {
setStore("general", "editToolPartsExpanded", value) setStore("general", "editToolPartsExpanded", value)
}, },
showSessionProgressBar: withFallback(
() => store.general?.showSessionProgressBar,
defaultSettings.general.showSessionProgressBar,
),
setShowSessionProgressBar(value: boolean) {
setStore("general", "showSessionProgressBar", value)
},
showCustomAgents, showCustomAgents,
setShowCustomAgents(value: boolean) { setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value) setStore("general", "showCustomAgents", value)
-2
View File
@@ -590,8 +590,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit", "settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني", "إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
"settings.general.row.showSessionProgressBar.title": "إظهار شريط تقدم الجلسة",
"settings.general.row.showSessionProgressBar.description": "عرض شريط التقدم المتحرك أعلى الجلسة أثناء عمل الوكيل",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي", "settings.general.row.wayland.title": "استخدام Wayland الأصلي",
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.", "settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
-3
View File
@@ -598,9 +598,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição", "settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo", "Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
"settings.general.row.showSessionProgressBar.title": "Mostrar barra de progresso da sessão",
"settings.general.row.showSessionProgressBar.description":
"Exibir a barra de progresso animada no topo da sessão quando o agente estiver trabalhando",
"settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.", "settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
-3
View File
@@ -663,9 +663,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje", "settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci", "Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
"settings.general.row.showSessionProgressBar.title": "Prikaži traku napretka sesije",
"settings.general.row.showSessionProgressBar.description":
"Prikaži animiranu traku napretka na vrhu sesije kada agent radi",
"settings.general.row.wayland.title": "Koristi nativni Wayland", "settings.general.row.wayland.title": "Koristi nativni Wayland",
"settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.", "settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
-3
View File
@@ -657,9 +657,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele", "settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen", "Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
"settings.general.row.showSessionProgressBar.title": "Vis sessionens fremdriftslinje",
"settings.general.row.showSessionProgressBar.description":
"Vis den animerede fremdriftslinje øverst i sessionen, når agenten arbejder",
"settings.general.row.wayland.title": "Brug native Wayland", "settings.general.row.wayland.title": "Brug native Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
-3
View File
@@ -609,9 +609,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen", "settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen", "Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
"settings.general.row.showSessionProgressBar.title": "Sitzungsfortschrittsleiste anzeigen",
"settings.general.row.showSessionProgressBar.description":
"Die animierte Fortschrittsleiste oben in der Sitzung anzeigen, wenn der Agent arbeitet",
"settings.general.row.wayland.title": "Natives Wayland verwenden", "settings.general.row.wayland.title": "Natives Wayland verwenden",
"settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.", "settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
-3
View File
@@ -854,9 +854,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts", "settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Show edit, write, and patch tool parts expanded by default in the timeline", "Show edit, write, and patch tool parts expanded by default in the timeline",
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
"settings.general.row.showSessionProgressBar.description":
"Display the animated progress bar at the top of the session when the agent is working",
"settings.general.row.newLayoutDesigns.title": "New layout and designs", "settings.general.row.newLayoutDesigns.title": "New layout and designs",
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI", "settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
"settings.general.row.pinchZoom.title": "Pinch to zoom", "settings.general.row.pinchZoom.title": "Pinch to zoom",
-3
View File
@@ -667,9 +667,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición", "settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo", "Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
"settings.general.row.showSessionProgressBar.title": "Mostrar barra de progreso de la sesión",
"settings.general.row.showSessionProgressBar.description":
"Mostrar la barra de progreso animada en la parte superior de la sesión cuando el agente esté trabajando",
"settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.", "settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
-3
View File
@@ -606,9 +606,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit", "settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie", "Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
"settings.general.row.showSessionProgressBar.title": "Afficher la barre de progression de la session",
"settings.general.row.showSessionProgressBar.description":
"Afficher la barre de progression animée en haut de la session lorsque l'agent travaille",
"settings.general.row.wayland.title": "Utiliser Wayland natif", "settings.general.row.wayland.title": "Utiliser Wayland natif",
"settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.", "settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
-3
View File
@@ -595,9 +595,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開", "settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します", "タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
"settings.general.row.showSessionProgressBar.title": "セッション進行状況バーを表示",
"settings.general.row.showSessionProgressBar.description":
"エージェントの作業中に、セッション上部にアニメーション付きの進行状況バーを表示します",
"settings.general.row.wayland.title": "ネイティブWaylandを使用", "settings.general.row.wayland.title": "ネイティブWaylandを使用",
"settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。", "settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
-3
View File
@@ -591,9 +591,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기", "settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다", "타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다",
"settings.general.row.showSessionProgressBar.title": "세션 진행 표시줄 표시",
"settings.general.row.showSessionProgressBar.description":
"에이전트가 작업 중일 때 세션 상단에 애니메이션 진행 표시줄을 표시합니다",
"settings.general.row.wayland.title": "네이티브 Wayland 사용", "settings.general.row.wayland.title": "네이티브 Wayland 사용",
"settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.", "settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
-3
View File
@@ -664,9 +664,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler", "settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen", "Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen",
"settings.general.row.showSessionProgressBar.title": "Vis fremdriftslinje for sesjonen",
"settings.general.row.showSessionProgressBar.description":
"Vis den animerte fremdriftslinjen øverst i sesjonen når agenten jobber",
"settings.general.row.wayland.title": "Bruk innebygd Wayland", "settings.general.row.wayland.title": "Bruk innebygd Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
-3
View File
@@ -596,9 +596,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit", "settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu", "Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
"settings.general.row.showSessionProgressBar.title": "Pokazuj pasek postępu sesji",
"settings.general.row.showSessionProgressBar.description":
"Wyświetlaj animowany pasek postępu u góry sesji, gdy agent pracuje",
"settings.general.row.wayland.title": "Użyj natywnego Wayland", "settings.general.row.wayland.title": "Użyj natywnego Wayland",
"settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.", "settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
-3
View File
@@ -664,9 +664,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit", "settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию", "Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
"settings.general.row.showSessionProgressBar.title": "Показывать индикатор прогресса сессии",
"settings.general.row.showSessionProgressBar.description":
"Показывать анимированный индикатор прогресса вверху сессии, когда агент работает",
"settings.general.row.wayland.title": "Использовать нативный Wayland", "settings.general.row.wayland.title": "Использовать нативный Wayland",
"settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.", "settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
-3
View File
@@ -655,9 +655,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit", "settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์", "แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
"settings.general.row.showSessionProgressBar.title": "แสดงแถบความคืบหน้าของเซสชัน",
"settings.general.row.showSessionProgressBar.description":
"แสดงแถบความคืบหน้าแบบเคลื่อนไหวที่ด้านบนของเซสชันเมื่อเอเจนต์กำลังทำงาน",
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ", "settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
"settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท", "settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท",
"settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า", "settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า",
-4
View File
@@ -671,10 +671,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster", "Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
"settings.general.row.showSessionProgressBar.title": "Oturum ilerleme çubuğunu göster",
"settings.general.row.showSessionProgressBar.description":
"Ajan çalışırken oturumun üst kısmında animasyonlu ilerleme çubuğunu göster",
"settings.general.row.wayland.title": "Yerel Wayland kullan", "settings.general.row.wayland.title": "Yerel Wayland kullan",
"settings.general.row.wayland.description": "settings.general.row.wayland.description":
"Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.", "Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.",
-3
View File
@@ -781,9 +781,6 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування", "settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі", "Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
"settings.general.row.showSessionProgressBar.title": "Показувати індикатор прогресу сесії",
"settings.general.row.showSessionProgressBar.description":
"Відображати анімований індикатор прогресу вгорі сесії, коли агент працює",
"settings.general.row.wayland.title": "Використовувати нативний Wayland", "settings.general.row.wayland.title": "Використовувати нативний Wayland",
"settings.general.row.wayland.description": "Вимкнути резервний X11 на Wayland. Потребує перезапуску.", "settings.general.row.wayland.description": "Вимкнути резервний X11 на Wayland. Потребує перезапуску.",
-2
View File
@@ -664,8 +664,6 @@ export const dict = {
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分", "settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分", "settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分", "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
"settings.general.row.showSessionProgressBar.title": "显示会话进度条",
"settings.general.row.showSessionProgressBar.description": "当智能体正在工作时,在会话顶部显示动画进度条",
"settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.title": "使用原生 Wayland",
"settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。", "settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。",
"settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。", "settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。",
-2
View File
@@ -650,8 +650,6 @@ export const dict = {
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊", "settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊", "settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊", "settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
"settings.general.row.showSessionProgressBar.title": "顯示工作階段進度列",
"settings.general.row.showSessionProgressBar.description": "當代理程式正在運作時,在工作階段頂部顯示動畫進度列",
"settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.title": "使用原生 Wayland",
"settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。", "settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。",
"settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。", "settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。",
+1 -38
View File
@@ -1,4 +1,5 @@
@import "@opencode-ai/ui/styles/tailwind"; @import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/session-ui/styles";
@import "@opencode-ai/ui/v2/styles/tailwind.css"; @import "@opencode-ai/ui/v2/styles/tailwind.css";
@font-face { @font-face {
@@ -23,44 +24,6 @@
} }
@layer components { @layer components {
@keyframes session-progress-whip {
0% {
clip-path: inset(0 100% 0 0 round 999px);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
48% {
clip-path: inset(0 0 0 0 round 999px);
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
}
100% {
clip-path: inset(0 0 0 100% round 999px);
}
}
[data-component="session-progress"] {
position: absolute;
inset: 0 0 auto;
height: 2px;
overflow: hidden;
pointer-events: none;
opacity: 1;
transition: opacity 220ms ease-out;
}
[data-component="session-progress"][data-state="hiding"] {
opacity: 0;
}
[data-component="session-progress-bar"] {
width: 100%;
height: 100%;
border-radius: 999px;
clip-path: inset(0 100% 0 0 round 999px);
will-change: clip-path;
}
[data-component="getting-started"] { [data-component="getting-started"] {
container-type: inline-size; container-type: inline-size;
container-name: getting-started; container-name: getting-started;
+12 -9
View File
@@ -1,8 +1,8 @@
import { DataProvider } from "@opencode-ai/ui/context" import { DataProvider } from "@opencode-ai/session-ui/context"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { type Accessor, createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
@@ -11,7 +11,7 @@ import { decode64 } from "@/utils/base64"
import { Schema } from "effect" import { Schema } from "effect"
import type { ServerConnection } from "@/context/server" import type { ServerConnection } from "@/context/server"
import { sessionHref } from "@/utils/session-route" import { sessionHref } from "@/utils/session-route"
import { useGlobal } from "@/context/global" import { useServerSync } from "@/context/server-sync"
export function DirectoryDataProvider( export function DirectoryDataProvider(
props: ParentProps<{ props: ParentProps<{
@@ -24,7 +24,7 @@ export function DirectoryDataProvider(
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
const global = useGlobal() const serverSync = useServerSync()
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory) const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
const slug = createMemo(() => base64Encode(directory())) const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => { const href = (sessionID: string) => {
@@ -50,15 +50,18 @@ export function DirectoryDataProvider(
.catch(() => {}), .catch(() => {}),
) )
createEffect(() => {
const sessionID = params.id
if (!sessionID) return
serverSync().session.pin(sessionID)
onCleanup(() => serverSync().session.unpin(sessionID))
})
return ( return (
<DataProvider <DataProvider
data={sync().data} data={sync().data}
directory={directory()} directory={directory()}
onNavigateToSession={(sessionID: string) => { onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
const server = props.server?.()
if (server && params.id) global.sessionPlacement.inherit(server, params.id, sessionID)
navigate(href(sessionID))
}}
onSessionHref={href} onSessionHref={href}
> >
<LocalProvider>{props.children}</LocalProvider> <LocalProvider>{props.children}</LocalProvider>
+3 -10
View File
@@ -60,7 +60,7 @@ import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useMarked } from "@opencode-ai/ui/context/marked" import { useMarked } from "@opencode-ai/ui/context/marked"
import { preloadMarkdown } from "@opencode-ai/ui/markdown-cache" import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
const HOME_SESSION_LIMIT = 64 const HOME_SESSION_LIMIT = 64
const HOME_ROW_LAYOUT = const HOME_ROW_LAYOUT =
@@ -220,10 +220,9 @@ export function NewHome() {
void directory.session void directory.session
.sync(record.session.id) .sync(record.session.id)
.then(() => { .then(() => {
const store = ctx.sync.child(record.session.directory)[0]
return Promise.all( return Promise.all(
(store.message[record.session.id] ?? []).flatMap((message) => (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
(store.part[message.id] ?? []).flatMap((part) => { (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
if (part.type !== "text" || !part.text) return [] if (part.type !== "text" || !part.text) return []
return preloadMarkdown(part.text, part.id, marked) return preloadMarkdown(part.text, part.id, marked)
}), }),
@@ -343,12 +342,6 @@ export function NewHome() {
if (!conn) return if (!conn) return
const directory = project?.worktree ?? session.directory const directory = project?.worktree ?? session.directory
const ctx = global.ensureServerCtx(conn) const ctx = global.ensureServerCtx(conn)
global.sessionPlacement.set({
server: ServerConnection.key(conn),
leafID: session.id,
rootID: session.id,
directory: session.directory,
})
ctx.projects.open(directory) ctx.projects.open(directory)
ctx.projects.touch(directory) ctx.projects.touch(directory)
startTransition(() => { startTransition(() => {
+1
View File
@@ -18,6 +18,7 @@ export default function NewLayout(props: ParentProps) {
createEffect(() => setV2Toast(true)) createEffect(() => setV2Toast(true))
createEffect(() => { createEffect(() => {
if (!notification.ready() || !params.id) return if (!notification.ready() || !params.id) return
if (notification.session.unseenCount(params.id) === 0) return
notification.session.markViewed(params.id) notification.session.markViewed(params.id)
}) })
+8 -103
View File
@@ -1,5 +1,4 @@
import { import {
batch,
createEffect, createEffect,
createMemo, createMemo,
createResource, createResource,
@@ -26,7 +25,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { Session, type Message } from "@opencode-ai/sdk/v2/client" import { Session } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
@@ -37,16 +36,7 @@ import { toaster } from "@opencode-ai/ui/toast"
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast" import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { clearWorkspaceTerminals } from "@/context/terminal" import { clearWorkspaceTerminals } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import {
clearSessionPrefetchInflight,
clearSessionPrefetch,
getSessionPrefetch,
isSessionPrefetchCurrent,
runSessionPrefetch,
setSessionPrefetch,
shouldSkipSessionPrefetch,
} from "@/context/global-sync/session-prefetch"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
@@ -684,7 +674,6 @@ export default function LegacyLayout(props: ParentProps) {
serverSDK().url serverSDK().url
prefetchToken.value += 1 prefetchToken.value += 1
clearSessionPrefetchInflight(serverSDK().scope)
prefetchQueues.clear() prefetchQueues.clear()
}) })
@@ -712,88 +701,12 @@ export default function LegacyLayout(props: ParentProps) {
return created return created
} }
const mergeByID = <T extends { id: string }>(current: T[], incoming: T[]) => {
if (current.length === 0) {
return incoming.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
}
const map = new Map<string, T>()
for (const item of current) {
map.set(item.id, item)
}
for (const item of incoming) {
map.set(item.id, item)
}
return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
}
async function prefetchMessages(directory: string, sessionID: string, token: number) { async function prefetchMessages(directory: string, sessionID: string, token: number) {
const [store, setStore] = serverSync().child(directory, { bootstrap: false }) await serverSync()
.session.prefetch(sessionID, prefetchChunk)
return runSessionPrefetch({ .catch(() => {})
scope: serverSDK().scope, if (prefetchToken.value !== token) return
directory, for (const stale of markPrefetched(directory, sessionID)) serverSync().session.evict(stale)
sessionID,
task: (rev) =>
retry(() => serverSDK().client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
if (!isSessionPrefetchCurrent(serverSDK().scope, directory, sessionID, rev)) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
const sorted = mergeByID([], next)
const stale = markPrefetched(directory, sessionID)
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
const meta = {
limit: sorted.length,
cursor,
complete: !cursor,
at: Date.now(),
}
if (stale.length > 0) {
clearSessionPrefetch(serverSDK().scope, directory, stale)
for (const id of stale) {
serverSync().todo.set(id, undefined)
}
}
const current = store.message[sessionID] ?? []
const merged = mergeByID(
current.filter((item): item is Message => !!item?.id),
sorted,
)
if (!isSessionPrefetchCurrent(serverSDK().scope, directory, sessionID, rev)) return
batch(() => {
if (stale.length > 0) {
setStore(
produce((draft) => {
dropSessionCaches(draft, stale)
}),
)
}
setStore("message", sessionID, reconcile(merged, { key: "id" }))
setSessionPrefetch({ scope: serverSDK().scope, directory, sessionID, ...meta })
for (const message of items) {
const currentParts = store.part[message.info.id] ?? []
const mergedParts = mergeByID(
currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
)
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
}
})
return meta
})
.catch(() => undefined),
})
} }
const pumpPrefetch = (directory: string) => { const pumpPrefetch = (directory: string) => {
@@ -820,15 +733,7 @@ export default function LegacyLayout(props: ParentProps) {
const directory = session.directory const directory = session.directory
if (!directory) return if (!directory) return
const [store] = serverSync().child(directory, { bootstrap: false }) const cached = untrack(() => !serverSync().session.shouldPrefetch(session.id, prefetchChunk))
const cached = untrack(() => {
const info = getSessionPrefetch(serverSDK().scope, directory, session.id)
return shouldSkipSessionPrefetch({
message: store.message[session.id] !== undefined,
info,
chunk: prefetchChunk,
})
})
if (cached) return if (cached) return
const q = queueFor(directory) const q = queueFor(directory)
@@ -15,7 +15,7 @@ export function useSessionTabAvatarState(
const hasPermissions = createMemo(() => { const hasPermissions = createMemo(() => {
if (!active()) return false if (!active()) return false
const [store] = globalSync().child(directory(), { bootstrap: false }) const [store] = globalSync().child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, store.permission, sessionId(), (item) => { return !!sessionPermissionRequest(store.session, globalSync().session.data.permission, sessionId(), (item) => {
return !permission.autoResponds(item, directory()) return !permission.autoResponds(item, directory())
}) })
}) })
@@ -23,8 +23,7 @@ export function useSessionTabAvatarState(
const loading = createMemo(() => { const loading = createMemo(() => {
if (!active()) return false if (!active()) return false
if (hasPermissions()) return false if (hasPermissions()) return false
const [store] = globalSync().child(directory(), { bootstrap: false }) return globalSync().session.data.session_working(sessionId())
return store.session_working(sessionId())
}) })
return { unread, loading } return { unread, loading }
} }
@@ -3,7 +3,7 @@ import { getProjectAvatarVariant } from "@/context/layout"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { SessionProgressIndicatorV2 } from "@opencode-ai/ui/v2/session-progress-indicator-v2" import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import { Show } from "solid-js" import { Show } from "solid-js"
export function SessionTabAvatar(props: { export function SessionTabAvatar(props: {
@@ -33,8 +33,10 @@ export const ProjectIcon = (props: {
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory))) const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
const hasPermissions = createMemo(() => const hasPermissions = createMemo(() =>
dirs().some((directory) => { dirs().some((directory) => {
const [store] = serverSync().child(directory, { bootstrap: false }) return hasProjectPermissions(serverSync().session.data.permission, (item) => {
return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory)) if (serverSync().session.get(item.sessionID)?.directory !== directory) return false
return !permission.autoResponds(item, directory)
})
}), }),
) )
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0)) const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
@@ -151,16 +153,23 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
const [sessionStore] = serverSync().child(props.session.directory) const [sessionStore] = serverSync().child(props.session.directory)
const hasPermissions = createMemo(() => { const hasPermissions = createMemo(() => {
return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => { return !!sessionPermissionRequest(
return !permission.autoResponds(item, props.session.directory) sessionStore.session,
}) serverSync().session.data.permission,
props.session.id,
(item) => {
return !permission.autoResponds(item, props.session.directory)
},
)
}) })
const isWorking = createMemo(() => { const isWorking = createMemo(() => {
if (hasPermissions()) return false if (hasPermissions()) return false
return sessionStore.session_working(props.session.id) return serverSync().session.data.session_working(props.session.id)
}) })
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)) const tint = createMemo(() =>
messageAgentColor(serverSync().session.data.message[props.session.id], sessionStore.agent),
)
const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded())) const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
const currentChild = createMemo(() => { const currentChild = createMemo(() => {
if (!props.showChild) return if (!props.showChild) return
@@ -304,8 +304,10 @@ export const SortableProject = (props: {
const projectStore = createMemo(() => serverSync().child(props.project.worktree, { bootstrap: false })[0]) const projectStore = createMemo(() => serverSync().child(props.project.worktree, { bootstrap: false })[0])
const isWorking = createMemo(() => const isWorking = createMemo(() =>
dirs().some((directory) => { dirs().some((directory) => {
const [store] = serverSync().child(directory, { bootstrap: false }) return Object.keys(serverSync().session.data.session_status).some((id) => {
return Object.keys(store.session_status).some((id) => store.session_working(id)) if (serverSync().session.get(id)?.directory !== directory) return false
return serverSync().session.data.session_working(id)
})
}), }),
) )
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
+2 -4
View File
@@ -25,7 +25,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { createAutoScroll } from "@opencode-ai/ui/hooks" import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
@@ -516,9 +516,7 @@ export default function Page() {
todoTimer = undefined todoTimer = undefined
if (!id) return if (!id) return
if (status === "idle" && !blocked) return if (status === "idle" && !blocked) return
const cached = untrack( const cached = untrack(() => sync().data.todo[id] !== undefined)
() => sync().data.todo[id] !== undefined || serverSync().data.session_todo[id] !== undefined,
)
todoFrame = requestAnimationFrame(() => { todoFrame = requestAnimationFrame(() => {
todoFrame = undefined todoFrame = undefined
@@ -50,7 +50,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const todos = createMemo((): Todo[] => { const todos = createMemo((): Todo[] => {
const id = params.id const id = params.id
if (!id) return [] if (!id) return []
return serverSync().data.session_todo[id] ?? [] return serverSync().session.data.todo[id] ?? []
}) })
const done = createMemo( const done = createMemo(
@@ -111,7 +111,6 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const clear = () => { const clear = () => {
const id = params.id const id = params.id
if (!id) return if (!id) return
serverSync().todo.set(id, [])
sync().set("todo", id, []) sync().set("todo", id, [])
} }
@@ -1,7 +1,7 @@
import { For, Show } from "solid-js" import { For, Show } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/sdk/v2" import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -2,7 +2,7 @@ import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
+3 -3
View File
@@ -2,10 +2,10 @@ import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch }
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileSearchHandle } from "@opencode-ai/ui/file" import type { FileSearchHandle } from "@opencode-ai/session-ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file" import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations" import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
import { sampledChecksum } from "@opencode-ai/core/util/encode" import { sampledChecksum } from "@opencode-ai/core/util/encode"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -1,12 +1,12 @@
import { createEffect, onCleanup, type JSX } from "solid-js" import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review" import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type { import type {
SessionReviewCommentActions, SessionReviewCommentActions,
SessionReviewCommentDelete, SessionReviewCommentDelete,
SessionReviewCommentUpdate, SessionReviewCommentUpdate,
} from "@opencode-ai/ui/session-review" } from "@opencode-ai/session-ui/session-review"
import type { SelectedLineRange } from "@/context/file" import type { SelectedLineRange } from "@/context/file"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
@@ -26,7 +26,7 @@ import {
Part as MessagePart, Part as MessagePart,
partDefaultOpen, partDefaultOpen,
type UserActions, type UserActions,
} from "@opencode-ai/ui/message-part" } from "@opencode-ai/session-ui/message-part"
import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -35,7 +35,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input" import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionRetry } from "@opencode-ai/ui/session-retry" import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
@@ -51,12 +51,11 @@ import type {
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover" import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { normalize } from "@opencode-ai/ui/session-diff" import { normalize } from "@opencode-ai/session-ui/session-diff"
import { useFileComponent } from "@opencode-ai/ui/context/file" import { useFileComponent } from "@opencode-ai/ui/context/file"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout" import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
@@ -95,8 +94,6 @@ const taskDescription = (part: PartType, sessionID: string) => {
if (typeof value === "string" && value) return value if (typeof value === "string" && value) return value
} }
const pace = (width: number) => Math.round(Math.max(1200, Math.min(3200, (Math.max(width, 360) * 2000) / 900)))
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]") const nested = current?.closest("[data-scrollable]")
@@ -567,18 +564,7 @@ export function MessageTimeline(props: {
open: false, open: false,
dismiss: null as "escape" | "outside" | null, dismiss: null as "escape" | "outside" | null,
}) })
const [bar, setBar] = createStore({
ms: pace(640),
})
let more: HTMLButtonElement | undefined let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
const updateTitleMetrics = () => {
if (!head || head.clientWidth <= 0) return
setBar("ms", pace(head.clientWidth))
}
createResizeObserver(() => head, updateTitleMetrics)
const bindListRoot = (root: HTMLDivElement) => { const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot()) return if (root === listRoot()) return
@@ -660,14 +646,14 @@ export function MessageTimeline(props: {
} }
const shareMutation = useMutation(() => ({ const shareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id, directory: sdk().directory }), mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
onError: (err) => { onError: (err) => {
console.error("Failed to share session", err) console.error("Failed to share session", err)
}, },
})) }))
const unshareMutation = useMutation(() => ({ const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id, directory: sdk().directory }), mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
onError: (err) => { onError: (err) => {
console.error("Failed to unshare session", err) console.error("Failed to unshare session", err)
}, },
@@ -1291,10 +1277,6 @@ export function MessageTimeline(props: {
> >
<Show when={showHeader()}> <Show when={showHeader()}>
<div <div
ref={(el) => {
head = el
updateTitleMetrics()
}}
data-session-title data-session-title
classList={{ classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
@@ -1306,17 +1288,6 @@ export function MessageTimeline(props: {
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
<Show when={workingStatus() !== "hidden" && settings.general.showSessionProgressBar()}>
<div data-component="session-progress" data-state={workingStatus()} aria-hidden="true">
<div
data-component="session-progress-bar"
style={{
background: tint() ?? "var(--icon-interactive-base)",
animation: `session-progress-whip ${bar.ms}ms infinite`,
}}
/>
</div>
</Show>
<div class="h-12 w-full flex items-center justify-between gap-2"> <div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3"> <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<div class="flex items-center min-w-0 grow-1"> <div class="flex items-center min-w-0 grow-1">
@@ -1,37 +1,29 @@
import type { Message, UserMessage } from "@opencode-ai/sdk/v2" import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js" import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" import { useServerSync } from "@/context/server-sync"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { same } from "@/utils/same" import { same } from "@/utils/same"
const emptyUserMessages: UserMessage[] = [] const emptyUserMessages: UserMessage[] = []
const sessionFreshness = 15_000
export function createTimelineModel(input: { export function createTimelineModel(input: {
sessionID: Accessor<string | undefined> sessionID: Accessor<string | undefined>
revertMessageID: Accessor<string | undefined> revertMessageID: Accessor<string | undefined>
}) { }) {
const sdk = useSDK() const serverSync = useServerSync()
const serverSDK = useServerSDK()
const sync = useSync() const sync = useSync()
let refreshFrame: number | undefined let refreshFrame: number | undefined
let refreshTimer: number | undefined let refreshTimer: number | undefined
const [resource] = createResource( const [resource] = createResource(
() => [sdk().directory, input.sessionID()] as const, () => input.sessionID(),
([directory, id]) => { (id) => {
clearRefresh() clearRefresh()
if (!id) return if (!id) return
const cached = untrack(() => sync().data.message[id] !== undefined) const cached = untrack(() => sync().data.message[id] !== undefined)
const stale = cached const stale = cached && !serverSync().session.fresh(id, sessionFreshness)
? (() => {
const info = getSessionPrefetch(serverSDK().scope, directory, id)
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
: false
refreshFrame = requestAnimationFrame(() => { refreshFrame = requestAnimationFrame(() => {
refreshFrame = undefined refreshFrame = undefined
@@ -1,6 +1,6 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, PartGroup, renderable } from "@opencode-ai/ui/message-part" import { groupParts, PartGroup, renderable } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect" import { Data, Equal } from "effect"
export type SummaryDiff = SnapshotFileDiff & { file: string } export type SummaryDiff = SnapshotFileDiff & { file: string }
@@ -1,7 +1,7 @@
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file" import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
@@ -1,38 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { createSessionPlacementStore } from "./session-placement"
describe("session placement", () => {
const local = ServerConnection.Key.make("http://localhost:4096")
const remote = ServerConnection.Key.make("https://example.com")
test("aliases a leaf and root without crossing servers", () => {
const store = createSessionPlacementStore()
store.set({ server: local, leafID: "child", rootID: "root", directory: "/repo" })
store.set({ server: remote, leafID: "child", rootID: "other", directory: "/remote" })
expect(store.get(local, "child")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.get(local, "root")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.get(remote, "child")).toEqual({ rootID: "other", directory: "/remote" })
})
test("inherits known placement for in-app child navigation", () => {
const store = createSessionPlacementStore()
store.set({ server: local, leafID: "parent", rootID: "root", directory: "/repo" })
expect(store.inherit(local, "parent", "child")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.get(local, "child")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.inherit(local, "missing", "unknown")).toBeUndefined()
})
test("bounds retained placement aliases", () => {
const store = createSessionPlacementStore()
for (let index = 0; index < 300; index++) {
store.set({ server: local, leafID: `leaf-${index}`, rootID: `root-${index}`, directory: `/repo/${index}` })
}
expect(store.size()).toBeLessThanOrEqual(256)
expect(store.get(local, "leaf-0")).toBeUndefined()
expect(store.get(local, "leaf-299")).toEqual({ rootID: "root-299", directory: "/repo/299" })
})
})
@@ -1,41 +0,0 @@
import { ServerConnection } from "@/context/server"
export type SessionPlacement = {
rootID: string
directory: string
}
export function createSessionPlacementStore() {
const placements = new Map<string, SessionPlacement>()
const limit = 256
const key = (server: ServerConnection.Key, sessionID: string) => `${server}\0${sessionID}`
const write = (id: string, placement: SessionPlacement) => {
placements.delete(id)
placements.set(id, placement)
while (placements.size > limit) placements.delete(placements.keys().next().value!)
}
return {
get(server: ServerConnection.Key, sessionID: string) {
const id = key(server, sessionID)
const placement = placements.get(id)
if (placement) write(id, placement)
return placement
},
set(input: SessionPlacement & { server: ServerConnection.Key; leafID: string }) {
const placement = { rootID: input.rootID, directory: input.directory }
write(key(input.server, input.leafID), placement)
write(key(input.server, input.rootID), placement)
return placement
},
inherit(server: ServerConnection.Key, sourceID: string, leafID: string) {
const placement = placements.get(key(server, sourceID))
if (!placement) return
write(key(server, leafID), placement)
return placement
},
size() {
return placements.size
},
}
}
@@ -36,4 +36,13 @@ describe("session routes", () => {
}), }),
).toBe(sessions.root) ).toBe(sessions.root)
}) })
test("rejects a parent cycle", async () => {
const sessions: Record<string, { id: string; parentID?: string }> = {
child: { id: "child", parentID: "parent" },
parent: { id: "parent", parentID: "child" },
}
expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child")
})
}) })
+7 -2
View File
@@ -18,8 +18,13 @@ export function requireServerKey(segment: string | undefined) {
type SessionParent = { id: string; parentID?: string } type SessionParent = { id: string; parentID?: string }
export async function rootSession(session: SessionParent, get: (sessionID: string) => Promise<SessionParent>) { export async function rootSession<T extends SessionParent>(session: T, get: (sessionID: string) => Promise<T>) {
const seen = new Set([session.id])
let current = session let current = session
while (current.parentID) current = await get(current.parentID) while (current.parentID) {
if (seen.has(current.parentID)) throw new Error(`Session parent cycle: ${current.parentID}`)
seen.add(current.parentID)
current = await get(current.parentID)
}
return current return current
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.17.9", "version": "1.17.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.17.9", "version": "1.17.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -196,6 +196,7 @@ export async function handler(
providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId) providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId)
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => { Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
if (v === "$ip") return headers.set(k, ip) if (v === "$ip") return headers.set(k, ip)
if (v === "$caller") return headers.set(k, `caller:${ip}`)
if (v === "$session") return headers.set(k, sessionId) if (v === "$session") return headers.set(k, sessionId)
if (v === "$model") return headers.set(k, model) if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId) if (v === "$request") return headers.set(k, requestId)
@@ -354,7 +355,7 @@ export async function handler(
responseLength += value.length responseLength += value.length
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
const parts = buffer.split(providerInfo.streamSeparator) const parts = buffer.split(/\r\n\r\n|\n\n|\r\r/)
buffer = parts.pop() ?? "" buffer = parts.pop() ?? ""
for (let part of parts) { for (let part of parts) {
@@ -139,7 +139,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
return encoder.encode(messages.join("")) return encoder.encode(messages.join(""))
} }
}, },
streamSeparator: "\n\n",
createUsageParser: () => { createUsageParser: () => {
let usage: Usage let usage: Usage
@@ -37,7 +37,6 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
return body return body
}, },
createBinaryStreamDecoder: () => undefined, createBinaryStreamDecoder: () => undefined,
streamSeparator: "\r\n\r\n",
createUsageParser: () => { createUsageParser: () => {
let usage: Usage let usage: Usage
@@ -37,7 +37,6 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
} }
}, },
createBinaryStreamDecoder: () => undefined, createBinaryStreamDecoder: () => undefined,
streamSeparator: "\n\n",
createUsageParser: () => { createUsageParser: () => {
let usage: Usage let usage: Usage
@@ -20,7 +20,6 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
}, },
modifyBody: (body: Record<string, any>) => body, modifyBody: (body: Record<string, any>) => body,
createBinaryStreamDecoder: () => undefined, createBinaryStreamDecoder: () => undefined,
streamSeparator: "\n\n",
createUsageParser: () => { createUsageParser: () => {
let usage: Usage let usage: Usage
@@ -44,7 +44,6 @@ export type ProviderHelper = (input: {
modifyHeaders: (headers: Headers, apiKey: string, stickyId: string) => void modifyHeaders: (headers: Headers, apiKey: string, stickyId: string) => void
modifyBody: (body: Record<string, any>) => Record<string, any> modifyBody: (body: Record<string, any>) => Record<string, any>
createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined
streamSeparator: string
createUsageParser: () => { createUsageParser: () => {
parse: (chunk: string) => void parse: (chunk: string) => void
retrieve: () => any retrieve: () => any
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.17.9", "version": "1.17.10",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.17.9", "version": "1.17.10",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -118,11 +118,12 @@ function toLakeEvent(time: string, data: Record<string, unknown>) {
request: string(data, "request"), request: string(data, "request"),
client: string(data, "client"), client: string(data, "client"),
user_agent: string(data, "user_agent"), user_agent: string(data, "user_agent"),
model: string(data, "model"),
model_tier: string(data, "model.tier"),
model_variant: string(data, "model.variant"), model_variant: string(data, "model.variant"),
source: string(data, "source"), source: string(data, "source"),
provider: string(data, "provider"), provider: string(data, "provider"),
provider_model: string(data, "provider.model"), provider_model: string(data, "provider.model"),
model: string(data, "model"),
llm_error_code: integer(data, "llm.error.code"), llm_error_code: integer(data, "llm.error.code"),
llm_error_message: string(data, "llm.error.message"), llm_error_message: string(data, "llm.error.message"),
error_response: string(data, "error.response"), error_response: string(data, "error.response"),
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.17.9", "version": "1.17.10",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.17.9", "version": "1.17.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.17.9", "version": "1.17.10",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+4 -5
View File
@@ -1,8 +1,8 @@
export * as Catalog from "./catalog" export * as Catalog from "./catalog"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { ModelRequest } from "./model-request"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Policy } from "./policy" import { Policy } from "./policy"
@@ -18,9 +18,7 @@ export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export const PolicyActions = Schema.Literals(["provider.use"]) export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = { export const Event = Catalog.Event
Updated: EventV2.define({ type: "catalog.updated", schema: {} }),
}
type Data = { type Data = {
providers: Map<ProviderV2.ID, ProviderRecord> providers: Map<ProviderV2.ID, ProviderRecord>
@@ -86,7 +84,8 @@ export const layer = Layer.effect(
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } } ? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
: model.api : model.api
const request = { const request = {
...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request), headers: { ...provider.request.headers, ...model.request.headers },
body: { ...provider.request.body, ...model.request.body },
variant: model.request.variant, variant: model.request.variant,
} }
return ModelV2.Info.make({ return ModelV2.Info.make({
+4 -15
View File
@@ -4,7 +4,6 @@ import { define } from "../../plugin/internal"
import { Effect } from "effect" import { Effect } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const Plugin = define({ export const Plugin = define({
@@ -59,15 +58,11 @@ export const Plugin = define({
Object.assign(provider.request.body, item.request.body) 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 ?? {})) { for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => { catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api } if (config.api !== undefined) model.api = { ...model.api, ...config.api }
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
if (config.capabilities !== undefined) { if (config.capabilities !== undefined) {
model.capabilities = { model.capabilities = {
tools: config.capabilities.tools, tools: config.capabilities.tools,
@@ -76,10 +71,8 @@ export const Plugin = define({
} }
} }
if (config.request !== undefined) { if (config.request !== undefined) {
ModelRequest.assign(model.request, { Object.assign(model.request.headers, config.request.headers)
headers: config.request.headers, Object.assign(model.request.body, config.request.body)
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
})
if (config.request.variant !== undefined) model.request.variant = config.request.variant if (config.request.variant !== undefined) model.request.variant = config.request.variant
} }
if (config.variants !== undefined) { if (config.variants !== undefined) {
@@ -90,15 +83,11 @@ export const Plugin = define({
id: variant.id, id: variant.id,
headers: {}, headers: {},
body: {}, body: {},
generation: {},
options: {},
} }
model.variants.push(existing) model.variants.push(existing)
} }
ModelRequest.assign(existing, { Object.assign(existing.headers, variant.headers)
headers: variant.headers, Object.assign(existing.body, variant.body)
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
})
} }
} }
if (config.cost !== undefined) { if (config.cost !== undefined) {
+36 -18
View File
@@ -84,15 +84,20 @@ export const layer = Layer.effect(
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id }) return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
} }
const patch = const moveChanges = input.moveChanges && source.directory !== destination.directory
input.moveChanges && source.directory !== destination.directory const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
? yield* git if (moveChanges && !sourceRepository)
.patch(current.location.directory) return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message }))) const patch = sourceRepository
: "" ? yield* git.change
.capture({ repository: sourceRepository, path: current.location.directory })
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
: Git.ChangeSet.make("")
if (patch) { if (patch) {
yield* git const repository = yield* git.repo.discover(directory)
.applyPatch({ directory, patch }) if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
yield* git.change
.apply({ repository, path: directory, changes: patch })
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message }))) .pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
} }
@@ -104,16 +109,29 @@ export const layer = Layer.effect(
}) })
if (patch) { if (patch) {
yield* git.softResetChanges(current.location.directory).pipe( const repository = yield* git.repo.discover(current.location.directory)
Effect.mapError( if (!repository)
(error) => return yield* new ResetSourceChangesError({
new ResetSourceChangesError({ directory: current.location.directory,
directory: current.location.directory, message: "Source is not a Git repository",
message: error.message, })
cause: error.cause, yield* git.change
}), .discard({
), repository,
) path: current.location.directory,
index: "preserve",
untracked: "remove",
})
.pipe(
Effect.mapError(
(error) =>
new ResetSourceChangesError({
directory: current.location.directory,
message: error.message,
cause: error.cause,
}),
),
)
} }
}) })
+23 -94
View File
@@ -1,46 +1,19 @@
export * as EventV2 from "./event" export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import { and, asc, eq, gt } from "drizzle-orm" import { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database" import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql" import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location" import { Location } from "./location"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { LayerNode } from "./effect/layer-node" import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util" import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( export const ID = Event.ID
Schema.brand("Event.ID"), export type ID = import("@opencode-ai/schema/event").ID
withStatics((schema) => ({ export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
create: () => schema.make("evt_" + Identifier.ascending()),
})),
)
export type ID = typeof ID.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type
readonly durable?: {
readonly version: number
readonly aggregate: string
}
readonly data: DataSchema
}
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
export type Payload<D extends Definition = Definition> = {
readonly id: ID
readonly type: D["type"]
readonly data: Data<D>
readonly durable?: {
readonly aggregateID: string
readonly seq: number
readonly version: number
}
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
}
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void> export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void> export type Unsubscribe = Effect.Effect<void>
@@ -74,52 +47,8 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
}, },
) {} ) {}
export function versionedType(type: string, version: number) { export const define = Event.define
return `${type}.${version}` export const versionedType = Event.versionedType
}
export const registry = new Map<string, Definition>()
const durableRegistry = new Map<string, Definition>()
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type
readonly durable?: {
readonly version: number
readonly aggregate: string
}
readonly schema: Fields
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
const Data = Schema.Struct(input.schema)
const Payload = Schema.Struct({
id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
location: Schema.optional(Location.Ref),
data: Data,
}).annotate({ identifier: input.type })
const definition = Object.assign(Payload, {
type: input.type,
...(input.durable === undefined ? {} : { durable: input.durable }),
data: Data,
})
const existing = registry.get(input.type)
if (
input.durable === undefined ||
existing?.durable === undefined ||
input.durable.version >= existing.durable.version
) {
registry.set(input.type, definition)
}
if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>>
}
export function definitions() {
return registry.values().toArray()
}
export interface PublishOptions { export interface PublishOptions {
readonly id?: ID readonly id?: ID
@@ -169,6 +98,7 @@ export const layerWith = (options?: LayerOptions) =>
typed: new Map<string, PubSub.PubSub<Payload>>(), typed: new Map<string, PubSub.PubSub<Payload>>(),
} }
const projectors = new Map<string, Subscriber[]>() const projectors = new Map<string, Subscriber[]>()
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
const listeners = new Array<Subscriber>() const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service const { db } = yield* Database.Service
@@ -194,6 +124,7 @@ export const layerWith = (options?: LayerOptions) =>
) )
function commitDurableEvent( function commitDurableEvent(
definition: Definition,
event: Payload, event: Payload,
input?: { input?: {
readonly seq: number readonly seq: number
@@ -204,7 +135,6 @@ export const layerWith = (options?: LayerOptions) =>
commit?: (seq: number) => Effect.Effect<void>, commit?: (seq: number) => Effect.Effect<void>,
) { ) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = registry.get(event.type)
const durable = definition?.durable const durable = definition?.durable
if (durable) { if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate] const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
@@ -238,9 +168,10 @@ export const layerWith = (options?: LayerOptions) =>
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
const latest = row?.seq ?? -1 const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync( const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
definition.data as Schema.Codec<unknown, unknown, never, never>, string,
)(event.data) as Record<string, unknown> unknown
>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) { if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die( yield* Effect.die(
new InvalidDurableEventError({ new InvalidDurableEventError({
@@ -356,9 +287,8 @@ export const layerWith = (options?: LayerOptions) =>
}) })
} }
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) { function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = registry.get(event.type)
if (!definition?.durable && commit) if (!definition?.durable && commit)
return yield* Effect.die( return yield* Effect.die(
new InvalidDurableEventError({ new InvalidDurableEventError({
@@ -367,7 +297,7 @@ export const layerWith = (options?: LayerOptions) =>
}), }),
) )
if (definition?.durable) { if (definition?.durable) {
const committed = yield* commitDurableEvent(event as Payload, undefined, commit) const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit)
if (committed) { if (committed) {
event = { event = {
...event, ...event,
@@ -416,6 +346,7 @@ export const layerWith = (options?: LayerOptions) =>
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID } ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined) : undefined)
return yield* publishEvent( return yield* publishEvent(
definition,
{ {
id: options?.id ?? ID.create(), id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}), ...(options?.metadata ? { metadata: options.metadata } : {}),
@@ -433,7 +364,7 @@ export const layerWith = (options?: LayerOptions) =>
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) { ) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = durableRegistry.get(event.type) const definition = Durable.get(event.type)
if (!definition?.durable) { if (!definition?.durable) {
yield* Effect.die( yield* Effect.die(
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }), new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
@@ -442,11 +373,9 @@ export const layerWith = (options?: LayerOptions) =>
const payload = { const payload = {
id: event.id, id: event.id,
type: definition.type, type: definition.type,
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)( data: Schema.decodeUnknownSync(definition.data)(event.data),
event.data,
),
} as Payload } as Payload
const committed = yield* commitDurableEvent(payload, { const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq, seq: event.seq,
aggregateID: event.aggregateID, aggregateID: event.aggregateID,
ownerID: options?.ownerID, ownerID: options?.ownerID,
@@ -530,8 +459,8 @@ export const layerWith = (options?: LayerOptions) =>
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all) const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
const decodeSerializedEvent = (event: SerializedEvent): Payload => { const decodeSerializedEvent = (event: SerializedEvent) => {
const definition = durableRegistry.get(event.type) const definition = Durable.get(event.type)
if (!definition?.durable) { if (!definition?.durable) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }) throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
} }
@@ -539,7 +468,7 @@ export const layerWith = (options?: LayerOptions) =>
id: event.id, id: event.id,
type: definition.type, type: definition.type,
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version }, durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data), data: Schema.decodeUnknownSync(definition.data)(event.data),
} }
} }
+6
View File
@@ -0,0 +1,6 @@
export * as File from "./file"
import { Revert } from "@opencode-ai/schema/revert"
export const Diff = Revert.FileDiff
export type Diff = typeof Diff.Type
+3 -15
View File
@@ -2,12 +2,11 @@ export * as FileSystem from "./filesystem"
import path from "path" import path from "path"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { EventV2 } from "./event"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { Location } from "./location" import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema" import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search" import { FileSystemSearch } from "./filesystem/search"
import { Entry, Match } from "@opencode-ai/schema/filesystem" import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem"
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem" export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
export const ReadInput = Schema.Struct({ export const ReadInput = Schema.Struct({
@@ -29,11 +28,7 @@ export const ListInput = Schema.Struct({
}) })
export type ListInput = typeof ListInput.Type export type ListInput = typeof ListInput.Type
export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({ export { FindInput }
query: Schema.String,
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
}) {}
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({ export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
pattern: Schema.String, pattern: Schema.String,
@@ -48,14 +43,7 @@ export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
limit: PositiveInt.pipe(Schema.optional), limit: PositiveInt.pipe(Schema.optional),
}) {} }) {}
export const Event = { export const Event = FileSystem.Event
Edited: EventV2.define({
type: "file.edited",
schema: {
file: Schema.String,
},
}),
}
export interface Interface { export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
+11 -4
View File
@@ -127,12 +127,19 @@ export const fffLayer = Layer.effect(
Fff.create({ Fff.create({
basePath: location.directory, basePath: location.directory,
aiMode: true, aiMode: true,
enableFsRootScanning: true,
enableHomeDirScanning: true,
}), }),
catch: (cause) => cause, catch: (cause) => cause,
}).pipe(Effect.orDie) }).pipe(
if (!result.ok) return yield* Effect.die(result.error) Effect.catch((error) => Effect.logWarning("failed to initialize fff", { error }).pipe(Effect.as(undefined))),
)
if (!result?.ok) {
if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
return Service.of({
find: () => Effect.succeed([]),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
})
}
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore)) yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
return Service.of({ return Service.of({
glob: (input) => glob: (input) =>
+4 -11
View File
@@ -3,7 +3,8 @@ export * as Watcher from "./watcher"
// @ts-ignore // @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper" import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
import { Cause, Context, Effect, Layer, Schema } from "effect" import { Cause, Context, Effect, Layer } from "effect"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
import path from "path" import path from "path"
import { Config } from "../config" import { Config } from "../config"
import { EventV2 } from "../event" import { EventV2 } from "../event"
@@ -19,15 +20,7 @@ declare const OPENCODE_LIBC: string | undefined
const SUBSCRIBE_TIMEOUT_MS = 10_000 const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = { export const Event = FileSystemWatcher.Event
Updated: EventV2.define({
type: "file.watcher.updated",
schema: {
file: Schema.String,
event: Schema.Literals(["add", "change", "unlink"]),
},
}),
}
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try { try {
@@ -119,7 +112,7 @@ export const layer = Layer.effect(
} }
if (location.vcs?.type === "git") { if (location.vcs?.type === "git") {
const resolved = yield* git.dir(location.directory) const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) { if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
+741 -198
View File
File diff suppressed because it is too large Load Diff

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