mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26ad7d7561 | |||
| 0fb44051ee |
@@ -52,9 +52,6 @@ _Avoid_: Request body, wire options
|
|||||||
**Generation Controls**:
|
**Generation Controls**:
|
||||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||||
@@ -102,8 +99,6 @@ The host-supplied environment overlay applied by the server when creating a PTY,
|
|||||||
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||||
- **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.
|
- **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.
|
- **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`.
|
|
||||||
- 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.
|
||||||
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
||||||
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||||
|
|||||||
@@ -29,13 +29,12 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"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/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@pierre/trees": "1.0.0-beta.4",
|
|
||||||
"@sentry/solid": "catalog:",
|
"@sentry/solid": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
@@ -53,7 +52,6 @@
|
|||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@tanstack/solid-query": "5.91.4",
|
"@tanstack/solid-query": "5.91.4",
|
||||||
"@tanstack/solid-virtual": "catalog:",
|
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -67,6 +65,7 @@
|
|||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
|
"virtua": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@happy-dom/global-registrator": "20.0.11",
|
"@happy-dom/global-registrator": "20.0.11",
|
||||||
@@ -86,7 +85,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lildax": "./bin/lildax.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
@@ -111,7 +110,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"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 +146,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"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 +173,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"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 +195,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"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 +219,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"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 +239,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -331,7 +330,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -385,7 +384,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.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -399,7 +398,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.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -411,7 +410,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -442,7 +441,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -458,10 +457,10 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.74",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -472,12 +471,12 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"effect": "4.0.0-beta.83",
|
"effect": "4.0.0-beta.74",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
"@smithy/util-utf8": "4.2.2",
|
"@smithy/util-utf8": "4.2.2",
|
||||||
@@ -495,7 +494,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -623,7 +622,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -661,7 +660,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -676,7 +675,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
@@ -690,7 +689,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -703,7 +702,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
"@opencode-ai/stats-core": "workspace:*",
|
"@opencode-ai/stats-core": "workspace:*",
|
||||||
@@ -736,7 +735,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"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",
|
||||||
@@ -755,7 +754,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -795,7 +794,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -822,13 +821,12 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@shikijs/stream": "catalog:",
|
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/bounds": "0.1.3",
|
"@solid-primitives/bounds": "0.1.3",
|
||||||
"@solid-primitives/event-listener": "2.4.5",
|
"@solid-primitives/event-listener": "2.4.5",
|
||||||
@@ -854,6 +852,7 @@
|
|||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
"strip-ansi": "7.1.2",
|
"strip-ansi": "7.1.2",
|
||||||
|
"virtua": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
@@ -871,7 +870,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -913,18 +912,15 @@
|
|||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
|
||||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
|
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||||
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
@@ -935,9 +931,9 @@
|
|||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.74",
|
||||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
@@ -948,17 +944,15 @@
|
|||||||
"@opentui/core": "0.3.4",
|
"@opentui/core": "0.3.4",
|
||||||
"@opentui/keymap": "0.3.4",
|
"@opentui/keymap": "0.3.4",
|
||||||
"@opentui/solid": "0.3.4",
|
"@opentui/solid": "0.3.4",
|
||||||
"@pierre/diffs": "1.2.10",
|
"@pierre/diffs": "1.1.0-beta.18",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
"@sentry/vite-plugin": "4.6.0",
|
"@sentry/vite-plugin": "4.6.0",
|
||||||
"@shikijs/stream": "4.2.0",
|
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@solidjs/meta": "0.29.4",
|
"@solidjs/meta": "0.29.4",
|
||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"@tanstack/solid-virtual": "3.13.28",
|
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
@@ -973,7 +967,7 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-rc.2",
|
"drizzle-kit": "1.0.0-rc.2",
|
||||||
"drizzle-orm": "1.0.0-rc.2",
|
"drizzle-orm": "1.0.0-rc.2",
|
||||||
"effect": "4.0.0-beta.83",
|
"effect": "4.0.0-beta.74",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
@@ -984,13 +978,14 @@
|
|||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
"remend": "1.3.0",
|
"remend": "1.3.0",
|
||||||
"semver": "7.7.4",
|
"semver": "7.7.4",
|
||||||
"shiki": "4.2.0",
|
"shiki": "3.20.0",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
"sst": "4.13.1",
|
"sst": "4.13.1",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
|
"virtua": "0.49.1",
|
||||||
"vite": "7.1.4",
|
"vite": "7.1.4",
|
||||||
"vite-plugin-solid": "2.11.10",
|
"vite-plugin-solid": "2.11.10",
|
||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
@@ -1328,13 +1323,13 @@
|
|||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||||
|
|
||||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="],
|
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.74", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.74" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-flpyqLPyr+THSe6ZCGRZl6hi+FqxbIXNSkslKGiRJAjbPabam9mSp7R3aC8biIMt6xE4Fd0LNfo4p2GplUkm2Q=="],
|
||||||
|
|
||||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="],
|
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74", "ioredis": "^5.7.0" } }, "sha512-/W16mKqxvhWINLjufzc0log1sl57exXQfwd+em398/zKCbmU3S7snXTDMN6w0ju2TtgK35qrsoGBXEochij6Sg=="],
|
||||||
|
|
||||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="],
|
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.74", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-C6C2hXixNcZXLaFF2u7B/FtOsqpdY7luaPuiGFBJza0P7EnYDkwaT3kB6lv7l/qctmkADc24qOsSCWIKRbC4jg=="],
|
||||||
|
|
||||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="],
|
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="],
|
||||||
|
|
||||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||||
|
|
||||||
@@ -2126,13 +2121,9 @@
|
|||||||
|
|
||||||
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
|
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
|
||||||
|
|
||||||
"@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="],
|
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="],
|
||||||
|
|
||||||
"@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
|
"@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="],
|
||||||
|
|
||||||
"@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="],
|
|
||||||
|
|
||||||
"@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="],
|
|
||||||
|
|
||||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||||
|
|
||||||
@@ -2336,17 +2327,13 @@
|
|||||||
|
|
||||||
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
|
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
|
||||||
|
|
||||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
|
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
|
||||||
|
|
||||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="],
|
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
|
||||||
|
|
||||||
"@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="],
|
"@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
|
||||||
|
|
||||||
"@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="],
|
"@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
|
||||||
|
|
||||||
"@shikijs/stream": ["@shikijs/stream@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-OaMUUStdIZ+l1GJad9uVACR3Xvgwo4y+RmEuDMU62cgFMMg1IBCaIFmvzAR2HiCpGtwoc/qPfpNnP+ivgrPXZg=="],
|
|
||||||
|
|
||||||
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
|
|
||||||
|
|
||||||
"@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="],
|
"@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="],
|
||||||
|
|
||||||
@@ -2590,10 +2577,6 @@
|
|||||||
|
|
||||||
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
|
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
|
||||||
|
|
||||||
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="],
|
|
||||||
|
|
||||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
|
|
||||||
|
|
||||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||||
|
|
||||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
|
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
|
||||||
@@ -3366,7 +3349,7 @@
|
|||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
"effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
|
"effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
||||||
|
|
||||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||||
|
|
||||||
@@ -4578,10 +4561,6 @@
|
|||||||
|
|
||||||
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||||
|
|
||||||
"preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="],
|
|
||||||
|
|
||||||
"preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="],
|
|
||||||
|
|
||||||
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||||
|
|
||||||
"pretty": ["pretty@2.0.0", "", { "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", "js-beautify": "^1.6.12" } }, "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w=="],
|
"pretty": ["pretty@2.0.0", "", { "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", "js-beautify": "^1.6.12" } }, "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w=="],
|
||||||
@@ -4864,7 +4843,7 @@
|
|||||||
|
|
||||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
"shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="],
|
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
|
||||||
|
|
||||||
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
|
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
|
||||||
|
|
||||||
@@ -5274,6 +5253,8 @@
|
|||||||
|
|
||||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||||
|
|
||||||
|
"virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="],
|
||||||
|
|
||||||
"vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],
|
"vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],
|
||||||
|
|
||||||
"vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "^8.12.1", "es-module-lexer": "^1.5.4", "fast-glob": "^3.3.2", "magic-string": "^0.30.11" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="],
|
"vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "^8.12.1", "es-module-lexer": "^1.5.4", "fast-glob": "^3.3.2", "magic-string": "^0.30.11" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="],
|
||||||
@@ -5522,8 +5503,6 @@
|
|||||||
|
|
||||||
"@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
"@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
|
|
||||||
"@astrojs/markdown-remark/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
|
|
||||||
|
|
||||||
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
|
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
|
||||||
|
|
||||||
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||||
@@ -5862,17 +5841,13 @@
|
|||||||
|
|
||||||
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||||
|
|
||||||
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||||
|
|
||||||
"@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
"@shikijs/langs/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||||
|
|
||||||
"@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
"@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||||
|
|
||||||
"@shikijs/stream/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
|
||||||
|
|
||||||
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
|
||||||
|
|
||||||
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||||
|
|
||||||
@@ -5898,10 +5873,6 @@
|
|||||||
|
|
||||||
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||||
|
|
||||||
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
|
||||||
|
|
||||||
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
|
||||||
|
|
||||||
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
@@ -5986,8 +5957,6 @@
|
|||||||
|
|
||||||
"astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
"astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
|
|
||||||
"astro/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
|
|
||||||
|
|
||||||
"astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
|
"astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
|
||||||
|
|
||||||
"astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
"astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
||||||
@@ -6232,9 +6201,9 @@
|
|||||||
|
|
||||||
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
"shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||||
|
|
||||||
"shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
"shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||||
|
|
||||||
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
@@ -6376,18 +6345,6 @@
|
|||||||
|
|
||||||
"@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
|
||||||
|
|
||||||
"@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
|
|
||||||
|
|
||||||
"@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
|
|
||||||
|
|
||||||
"@astrojs/markdown-remark/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
|
|
||||||
|
|
||||||
"@astrojs/markdown-remark/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
|
|
||||||
|
|
||||||
"@astrojs/markdown-remark/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
|
||||||
|
|
||||||
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="],
|
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="],
|
||||||
|
|
||||||
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
|
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
|
||||||
@@ -6684,8 +6641,6 @@
|
|||||||
|
|
||||||
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
|
||||||
|
|
||||||
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||||
@@ -6704,10 +6659,6 @@
|
|||||||
|
|
||||||
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
||||||
|
|
||||||
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||||
@@ -6756,18 +6707,6 @@
|
|||||||
|
|
||||||
"astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"astro/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
|
||||||
|
|
||||||
"astro/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
|
|
||||||
|
|
||||||
"astro/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
|
|
||||||
|
|
||||||
"astro/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
|
|
||||||
|
|
||||||
"astro/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
|
|
||||||
|
|
||||||
"astro/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
|
||||||
|
|
||||||
"astro/unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
"astro/unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
|
|
||||||
"astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
|
"astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
exact = true
|
exact = true
|
||||||
# Only install newly resolved package versions published at least 3 days ago.
|
# Only install newly resolved package versions published at least 3 days ago.
|
||||||
minimumReleaseAge = 259200
|
minimumReleaseAge = 259200
|
||||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||||
|
|
||||||
[test]
|
[test]
|
||||||
root = "./do-not-run-tests-from-root"
|
root = "./do-not-run-tests-from-root"
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=",
|
"x86_64-linux": "sha256-u3CBUflGrE1unVRTvvLKaDkvx5ZHXyRLd/SJOUUsRdc=",
|
||||||
"aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=",
|
"aarch64-linux": "sha256-hUu70IlOXXQ9apQe3IPOdQGf3ZSVCpvRuAWSKxipk/k=",
|
||||||
"aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=",
|
"aarch64-darwin": "sha256-N6Mqmo94q0rsskLcXoUGWCMJjQ9wCuuo0Lby3j6+Dps=",
|
||||||
"x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y="
|
"x86_64-darwin": "sha256-5691VKK2zWqAwe94CCjZuRjXEaswedevFtHxensVLfg="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-13
@@ -30,9 +30,9 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.74",
|
||||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
@@ -42,8 +42,6 @@
|
|||||||
"@opentui/core": "0.3.4",
|
"@opentui/core": "0.3.4",
|
||||||
"@opentui/keymap": "0.3.4",
|
"@opentui/keymap": "0.3.4",
|
||||||
"@opentui/solid": "0.3.4",
|
"@opentui/solid": "0.3.4",
|
||||||
"@tanstack/solid-virtual": "3.13.28",
|
|
||||||
"@shikijs/stream": "4.2.0",
|
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
@@ -53,7 +51,7 @@
|
|||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/diffs": "1.2.10",
|
"@pierre/diffs": "1.1.0-beta.18",
|
||||||
"opentui-spinner": "0.0.7",
|
"opentui-spinner": "0.0.7",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
@@ -61,7 +59,7 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-rc.2",
|
"drizzle-kit": "1.0.0-rc.2",
|
||||||
"drizzle-orm": "1.0.0-rc.2",
|
"drizzle-orm": "1.0.0-rc.2",
|
||||||
"effect": "4.0.0-beta.83",
|
"effect": "4.0.0-beta.74",
|
||||||
"ai": "6.0.168",
|
"ai": "6.0.168",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
@@ -78,9 +76,10 @@
|
|||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
"sst": "4.13.1",
|
"sst": "4.13.1",
|
||||||
"shiki": "4.2.0",
|
"shiki": "3.20.0",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
|
"virtua": "0.49.1",
|
||||||
"vite": "7.1.4",
|
"vite": "7.1.4",
|
||||||
"@solidjs/meta": "0.29.4",
|
"@solidjs/meta": "0.29.4",
|
||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
@@ -146,13 +145,10 @@
|
|||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
|
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch"
|
||||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
|
||||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
## Priorities
|
|
||||||
|
|
||||||
- Prioritise, in this order: stability, simplicity, performance.
|
|
||||||
- Before changing session or timeline code, record a production benchmark baseline and compare it after the change.
|
|
||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
- NEVER try to restart the app, or the server process, EVER.
|
- NEVER try to restart the app, or the server process, EVER.
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
- Prioritize stability, then simplicity, then measurement overhead.
|
|
||||||
- Use Playwright for scenario control, isolation, and completion checks.
|
|
||||||
- Use Chrome Performance traces for generic browser profiling.
|
|
||||||
- Use Electron `contentTracing` for packaged multi-process profiling.
|
|
||||||
- Keep custom probes only for product-specific measurements.
|
|
||||||
- Do not duplicate measurements across the harness, probes, and traces.
|
|
||||||
- Run benchmarks serially to avoid cross-test contention.
|
|
||||||
- Run benchmarks against production builds.
|
|
||||||
- Keep detailed profiling opt-in when it changes workload behavior.
|
|
||||||
- Preserve raw diagnostic data or use lossless representations.
|
|
||||||
- Do not enforce machine-dependent performance thresholds.
|
|
||||||
- Assert scenario completion and metric collection only.
|
|
||||||
- Keep normal test discovery free of manual benchmarks.
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Manual app performance suite
|
|
||||||
|
|
||||||
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
|
|
||||||
|
|
||||||
Run the suite explicitly from `packages/app`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bun run test:bench
|
|
||||||
```
|
|
||||||
|
|
||||||
PowerShell:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:PLAYWRIGHT_WORKERS = "1"
|
|
||||||
bun run test:bench
|
|
||||||
```
|
|
||||||
|
|
||||||
The suite contains:
|
|
||||||
|
|
||||||
- cold and hot session-tab timing
|
|
||||||
- cached session repaint and mutation tracing
|
|
||||||
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
|
|
||||||
|
|
||||||
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
|
|
||||||
|
|
||||||
New benchmarks should look like normal Playwright tests:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { benchmark, expect } from "../benchmark"
|
|
||||||
|
|
||||||
benchmark("measures one interaction", async ({ page, report }) => {
|
|
||||||
// Only scenario-specific setup and interaction belong here.
|
|
||||||
report({ durationMs: 42 })
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line.
|
|
||||||
|
|
||||||
```text
|
|
||||||
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
|
|
||||||
```
|
|
||||||
|
|
||||||
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
|
|
||||||
|
|
||||||
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
|
|
||||||
|
|
||||||
These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation.
|
|
||||||
|
|
||||||
CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling.
|
|
||||||
|
|
||||||
The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device.
|
|
||||||
|
|
||||||
Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts.
|
|
||||||
|
|
||||||
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
|
|
||||||
|
|
||||||
## Chrome traces
|
|
||||||
|
|
||||||
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \
|
|
||||||
bunx playwright test --config e2e/performance/playwright.config.ts \
|
|
||||||
timeline/session-tab-switch-benchmark.spec.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
|
|
||||||
|
|
||||||
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
|
|
||||||
```
|
|
||||||
|
|
||||||
INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`.
|
|
||||||
|
|
||||||
`e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration.
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test"
|
|
||||||
import { startChromeTrace } from "./chrome-trace"
|
|
||||||
|
|
||||||
type BenchmarkFixtures = {
|
|
||||||
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
|
|
||||||
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
|
|
||||||
benchmarkResult: void
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PerformancePageDiagnostics = {
|
|
||||||
navigations: string[]
|
|
||||||
stop: () => Promise<string | undefined>
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
|
|
||||||
|
|
||||||
export const benchmark = base.extend<BenchmarkFixtures>({
|
|
||||||
reportState: async ({}, use) => use({}),
|
|
||||||
report: async ({ reportState }, use) => {
|
|
||||||
await use((metrics, context = {}) => {
|
|
||||||
if (reportState.payload) throw new Error("Benchmark reported metrics more than once")
|
|
||||||
reportState.payload = { metrics, context }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
benchmarkResult: [
|
|
||||||
async ({ reportState }, use, testInfo) => {
|
|
||||||
await use()
|
|
||||||
const missing = !reportState.payload
|
|
||||||
console.log(
|
|
||||||
`BENCHMARK ${JSON.stringify({
|
|
||||||
schemaVersion: 2,
|
|
||||||
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
|
|
||||||
name: benchmarkName(testInfo),
|
|
||||||
status: missing ? "failed" : testInfo.status,
|
|
||||||
expectedStatus: testInfo.expectedStatus,
|
|
||||||
retry: testInfo.retry,
|
|
||||||
repeatEachIndex: testInfo.repeatEachIndex,
|
|
||||||
context: {
|
|
||||||
project: testInfo.project.name,
|
|
||||||
platform: process.platform,
|
|
||||||
...reportState.payload?.context,
|
|
||||||
},
|
|
||||||
metrics: reportState.payload?.metrics ?? null,
|
|
||||||
error: missing ? "Benchmark did not report metrics" : undefined,
|
|
||||||
})}`,
|
|
||||||
)
|
|
||||||
if (missing && testInfo.status === testInfo.expectedStatus)
|
|
||||||
throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`)
|
|
||||||
},
|
|
||||||
{ auto: true },
|
|
||||||
],
|
|
||||||
page: async ({ page }, use, testInfo) => {
|
|
||||||
const name = benchmarkName(testInfo)
|
|
||||||
const diagnostics = await observePerformancePage(page, name)
|
|
||||||
try {
|
|
||||||
await use(page)
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
await reportPerformancePage(name, diagnostics, testInfo)
|
|
||||||
} finally {
|
|
||||||
if (testInfo.status !== testInfo.expectedStatus) {
|
|
||||||
await testInfo.attach("performance-navigations", {
|
|
||||||
body: JSON.stringify(diagnostics.navigations, null, 2),
|
|
||||||
contentType: "application/json",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function benchmarkName(testInfo: TestInfo) {
|
|
||||||
return testInfo.titlePath.slice(1).join(" > ")
|
|
||||||
}
|
|
||||||
|
|
||||||
export { expect }
|
|
||||||
|
|
||||||
async function observePerformancePage(page: Page, name: string) {
|
|
||||||
const navigations: string[] = []
|
|
||||||
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
|
|
||||||
if (frame === page.mainFrame()) navigations.push(frame.url())
|
|
||||||
}
|
|
||||||
page.on("framenavigated", onNavigation)
|
|
||||||
const stopTrace = await startChromeTrace(page, name).catch((error) => {
|
|
||||||
page.off("framenavigated", onNavigation)
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
let stopping: Promise<string | undefined> | undefined
|
|
||||||
const diagnostics: PerformancePageDiagnostics = {
|
|
||||||
navigations,
|
|
||||||
stop() {
|
|
||||||
page.off("framenavigated", onNavigation)
|
|
||||||
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
pages.set(page, diagnostics)
|
|
||||||
return diagnostics
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withBenchmarkPage<T>(
|
|
||||||
browser: Browser,
|
|
||||||
name: string,
|
|
||||||
run: (page: Page) => Promise<T>,
|
|
||||||
testInfo?: TestInfo,
|
|
||||||
) {
|
|
||||||
const context = await browser.newContext()
|
|
||||||
try {
|
|
||||||
const page = await context.newPage()
|
|
||||||
const diagnostics = await observePerformancePage(page, name)
|
|
||||||
try {
|
|
||||||
return await run(page)
|
|
||||||
} finally {
|
|
||||||
await reportPerformancePage(name, diagnostics, testInfo)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await context.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) {
|
|
||||||
const trace = await diagnostics.stop()
|
|
||||||
console.log(
|
|
||||||
`BENCHMARK_PAGE ${JSON.stringify({
|
|
||||||
schemaVersion: 2,
|
|
||||||
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
|
|
||||||
name,
|
|
||||||
test: testInfo ? benchmarkName(testInfo) : undefined,
|
|
||||||
retry: testInfo?.retry,
|
|
||||||
repeatEachIndex: testInfo?.repeatEachIndex,
|
|
||||||
context: {
|
|
||||||
platform: process.platform,
|
|
||||||
trace,
|
|
||||||
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
|
|
||||||
},
|
|
||||||
navigations: diagnostics.navigations,
|
|
||||||
})}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function benchmarkDiagnostics(page: Page) {
|
|
||||||
const diagnostics = pages.get(page)
|
|
||||||
if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page")
|
|
||||||
return diagnostics
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import type { CDPSession, Page } from "@playwright/test"
|
|
||||||
import path from "node:path"
|
|
||||||
import { mkdir, open, rename } from "node:fs/promises"
|
|
||||||
import { Buffer } from "node:buffer"
|
|
||||||
import { createHash, randomUUID } from "node:crypto"
|
|
||||||
|
|
||||||
const categories = [
|
|
||||||
"-*",
|
|
||||||
"devtools.timeline",
|
|
||||||
"v8.execute",
|
|
||||||
"disabled-by-default-devtools.timeline",
|
|
||||||
"disabled-by-default-devtools.timeline.frame",
|
|
||||||
"toplevel",
|
|
||||||
"blink.console",
|
|
||||||
"blink.user_timing",
|
|
||||||
"latencyInfo",
|
|
||||||
"disabled-by-default-devtools.timeline.stack",
|
|
||||||
"disabled-by-default-v8.cpu_profiler",
|
|
||||||
]
|
|
||||||
|
|
||||||
export async function startChromeTrace(page: Page, name: string) {
|
|
||||||
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
|
|
||||||
if (!directory) return
|
|
||||||
|
|
||||||
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
|
|
||||||
const file = await prepareChromeTrace(directory, name, selectors)
|
|
||||||
const session = await page.context().newCDPSession(page)
|
|
||||||
try {
|
|
||||||
await session.send("Tracing.start", {
|
|
||||||
transferMode: "ReturnAsStream",
|
|
||||||
traceConfig: {
|
|
||||||
excludedCategories: categories
|
|
||||||
.filter((category) => category.startsWith("-"))
|
|
||||||
.map((category) => category.slice(1)),
|
|
||||||
includedCategories: [
|
|
||||||
...categories.filter((category) => !category.startsWith("-")),
|
|
||||||
...(selectors
|
|
||||||
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
await Promise.allSettled([session.detach()])
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
let stopping: Promise<string> | undefined
|
|
||||||
|
|
||||||
return () =>
|
|
||||||
(stopping ??= (async () => {
|
|
||||||
try {
|
|
||||||
const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) =>
|
|
||||||
session.once("Tracing.tracingComplete", resolve),
|
|
||||||
)
|
|
||||||
await session.send("Tracing.end")
|
|
||||||
const result = await complete
|
|
||||||
if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`)
|
|
||||||
const partial = `${file}.partial`
|
|
||||||
await writeProtocolStream(session, result.stream, partial)
|
|
||||||
if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`)
|
|
||||||
await rename(partial, file)
|
|
||||||
return file
|
|
||||||
} finally {
|
|
||||||
await Promise.allSettled([session.detach()])
|
|
||||||
}
|
|
||||||
})())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function prepareChromeTrace(
|
|
||||||
directory: string,
|
|
||||||
name: string,
|
|
||||||
selectors: boolean,
|
|
||||||
nonce = randomUUID().slice(0, 8),
|
|
||||||
) {
|
|
||||||
await mkdir(directory, { recursive: true })
|
|
||||||
const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"
|
|
||||||
const hash = createHash("sha256").update(name).digest("hex").slice(0, 8)
|
|
||||||
return path.join(
|
|
||||||
directory,
|
|
||||||
`${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeProtocolStream(session: CDPSession, handle: string, file: string) {
|
|
||||||
const output = await open(file, "wx")
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const chunk = await session.send("IO.read", { handle })
|
|
||||||
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
|
|
||||||
if (chunk.eof) break
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await Promise.allSettled([output.close(), session.send("IO.close", { handle })])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import config from "../../playwright.config"
|
|
||||||
|
|
||||||
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
|
|
||||||
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
|
|
||||||
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
|
|
||||||
|
|
||||||
export default {
|
|
||||||
...config,
|
|
||||||
testDir: ".",
|
|
||||||
testIgnore: "unit/**",
|
|
||||||
outputDir: "../test-results/performance",
|
|
||||||
fullyParallel: false,
|
|
||||||
workers: 1,
|
|
||||||
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
|
|
||||||
webServer: {
|
|
||||||
...config.webServer,
|
|
||||||
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
|
|
||||||
reuseExistingServer: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import config from "./playwright.config"
|
|
||||||
|
|
||||||
export default {
|
|
||||||
...config,
|
|
||||||
outputDir: "../test-results/performance-uncapped",
|
|
||||||
reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]],
|
|
||||||
use: {
|
|
||||||
...config.use,
|
|
||||||
launchOptions: {
|
|
||||||
args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { benchmark, expect } from "../benchmark"
|
|
||||||
import { expectSessionTitle } from "../../utils/waits"
|
|
||||||
import { fixture } from "./session-timeline-stress.fixture"
|
|
||||||
import {
|
|
||||||
collectCachedRepaintTrace,
|
|
||||||
compressCachedRepaintTrace,
|
|
||||||
installCachedRepaintProbe,
|
|
||||||
waitForCachedRepaintWindow,
|
|
||||||
} from "./session-tab-repaint-probe"
|
|
||||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
|
||||||
import {
|
|
||||||
installStressSessionTabs,
|
|
||||||
installTimelineSettings,
|
|
||||||
mockStressTimeline,
|
|
||||||
stressSessionHref,
|
|
||||||
} from "./timeline-test-helpers"
|
|
||||||
|
|
||||||
benchmark("samples cached session repaint after the click", async ({ page, report }) => {
|
|
||||||
benchmark.setTimeout(120_000)
|
|
||||||
await mockStressTimeline(page)
|
|
||||||
await installStressSessionTabs(page)
|
|
||||||
await installTimelineSettings(page)
|
|
||||||
await page.goto(stressSessionHref(fixture.targetID))
|
|
||||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
|
||||||
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
|
||||||
await page
|
|
||||||
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`)
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
|
||||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
|
||||||
|
|
||||||
await installCachedRepaintProbe(page, {
|
|
||||||
targetHref: stressSessionHref(fixture.targetID),
|
|
||||||
destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
|
|
||||||
source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
|
||||||
last: fixture.expected.targetMessageIDs.at(-1)!,
|
|
||||||
windowMs: 1_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
await page
|
|
||||||
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`)
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
await Promise.all([expectSessionTitle(page, fixture.expected.targetTitle), waitForCachedRepaintWindow(page, 1_000)])
|
|
||||||
const result = await collectCachedRepaintTrace(page)
|
|
||||||
report(compressCachedRepaintTrace(result))
|
|
||||||
expect(result.samples.length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test"
|
|
||||||
|
|
||||||
type CachedRepaintTrace = {
|
|
||||||
timeOriginEpochMs: number
|
|
||||||
startedAtPerformanceMs: number
|
|
||||||
samples: {
|
|
||||||
observedAtMs: number
|
|
||||||
root: number | undefined
|
|
||||||
scrollTop: number
|
|
||||||
scrollHeight: number
|
|
||||||
bottomErrorPx: number | undefined
|
|
||||||
last: boolean
|
|
||||||
rows: { key: string | undefined; node: number; top: number; bottom: number }[]
|
|
||||||
mounted: number
|
|
||||||
center: string | undefined
|
|
||||||
destination: string[]
|
|
||||||
source: string[]
|
|
||||||
}[]
|
|
||||||
mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[]
|
|
||||||
shifts: { occurredAtMs: number; value: number }[]
|
|
||||||
windowMs: number
|
|
||||||
running: boolean
|
|
||||||
stop: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installCachedRepaintProbe(
|
|
||||||
page: Page,
|
|
||||||
input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number },
|
|
||||||
) {
|
|
||||||
await page.evaluate(({ targetHref, destination, source, last, windowMs }) => {
|
|
||||||
const destinationIDs = new Set(destination)
|
|
||||||
const sourceIDs = new Set(source)
|
|
||||||
const nodeIDs = new WeakMap<Node, number>()
|
|
||||||
let nextNodeID = 1
|
|
||||||
const id = (node: Node) => {
|
|
||||||
const current = nodeIDs.get(node)
|
|
||||||
if (current) return current
|
|
||||||
nodeIDs.set(node, nextNodeID)
|
|
||||||
return nextNodeID++
|
|
||||||
}
|
|
||||||
const state: CachedRepaintTrace = {
|
|
||||||
timeOriginEpochMs: performance.timeOrigin,
|
|
||||||
startedAtPerformanceMs: 0,
|
|
||||||
samples: [],
|
|
||||||
mutations: [],
|
|
||||||
shifts: [],
|
|
||||||
windowMs,
|
|
||||||
running: false,
|
|
||||||
stop: () => {},
|
|
||||||
}
|
|
||||||
const recordShifts = (entries: PerformanceEntry[]) => {
|
|
||||||
if (!state.running) return
|
|
||||||
state.shifts.push(
|
|
||||||
...entries
|
|
||||||
.map((entry) => {
|
|
||||||
if (
|
|
||||||
entry.startTime < state.startedAtPerformanceMs ||
|
|
||||||
entry.startTime > state.startedAtPerformanceMs + state.windowMs
|
|
||||||
)
|
|
||||||
return
|
|
||||||
return {
|
|
||||||
occurredAtMs: entry.startTime - state.startedAtPerformanceMs,
|
|
||||||
value: (entry as PerformanceEntry & { value: number }).value,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries()))
|
|
||||||
shiftObserver.observe({ type: "layout-shift" })
|
|
||||||
const recordMutations = (entries: MutationRecord[]) => {
|
|
||||||
if (!state.running) return
|
|
||||||
const observedAtMs = performance.now() - state.startedAtPerformanceMs
|
|
||||||
if (observedAtMs > state.windowMs) return
|
|
||||||
const changed = entries.flatMap((entry) => [
|
|
||||||
...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })),
|
|
||||||
...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })),
|
|
||||||
])
|
|
||||||
if (changed.length) state.mutations.push({ observedAtMs, changed })
|
|
||||||
}
|
|
||||||
const mutationObserver = new MutationObserver(recordMutations)
|
|
||||||
mutationObserver.observe(document.documentElement, { childList: true, subtree: true })
|
|
||||||
state.stop = () => {
|
|
||||||
recordShifts(shiftObserver.takeRecords())
|
|
||||||
recordMutations(mutationObserver.takeRecords())
|
|
||||||
state.running = false
|
|
||||||
shiftObserver.disconnect()
|
|
||||||
mutationObserver.disconnect()
|
|
||||||
}
|
|
||||||
const sample = () => {
|
|
||||||
if (!state.running) return
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!state.running) return
|
|
||||||
const observedAtMs = performance.now() - state.startedAtPerformanceMs
|
|
||||||
if (observedAtMs > state.windowMs) return
|
|
||||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
|
||||||
element.querySelector("[data-timeline-row]"),
|
|
||||||
)
|
|
||||||
if (root) {
|
|
||||||
const view = root.getBoundingClientRect()
|
|
||||||
const rows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
|
||||||
.map((element) => ({
|
|
||||||
key: element.dataset.timelineKey,
|
|
||||||
node: id(element),
|
|
||||||
rect: element.getBoundingClientRect(),
|
|
||||||
}))
|
|
||||||
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
|
|
||||||
.map((item) => ({
|
|
||||||
key: item.key,
|
|
||||||
node: item.node,
|
|
||||||
top: item.rect.top - view.top,
|
|
||||||
bottom: item.rect.bottom - view.top,
|
|
||||||
}))
|
|
||||||
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
|
||||||
.filter((element) => {
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
return rect.bottom > view.top && rect.top < view.bottom
|
|
||||||
})
|
|
||||||
.map((element) => element.dataset.messageId!)
|
|
||||||
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
|
||||||
state.samples.push({
|
|
||||||
observedAtMs,
|
|
||||||
root: id(root),
|
|
||||||
scrollTop: root.scrollTop,
|
|
||||||
scrollHeight: root.scrollHeight,
|
|
||||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
|
||||||
last: messages.includes(last),
|
|
||||||
rows,
|
|
||||||
mounted: root.querySelectorAll("[data-timeline-key]").length,
|
|
||||||
center: document
|
|
||||||
.elementFromPoint(view.left + view.width / 2, view.top + view.height / 2)
|
|
||||||
?.textContent?.slice(0, 80),
|
|
||||||
destination: messages.filter((messageID) => destinationIDs.has(messageID)),
|
|
||||||
source: messages.filter((messageID) => sourceIDs.has(messageID)),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
state.samples.push({
|
|
||||||
observedAtMs,
|
|
||||||
root: undefined,
|
|
||||||
scrollTop: 0,
|
|
||||||
scrollHeight: 0,
|
|
||||||
bottomErrorPx: undefined,
|
|
||||||
last: false,
|
|
||||||
rows: [],
|
|
||||||
mounted: 0,
|
|
||||||
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
|
|
||||||
destination: [],
|
|
||||||
source: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
requestAnimationFrame(sample)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
document.addEventListener(
|
|
||||||
"click",
|
|
||||||
(event) => {
|
|
||||||
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
|
||||||
if (link?.getAttribute("href") !== targetHref) return
|
|
||||||
state.startedAtPerformanceMs = performance.now()
|
|
||||||
state.running = true
|
|
||||||
requestAnimationFrame(sample)
|
|
||||||
},
|
|
||||||
{ capture: true, once: true },
|
|
||||||
)
|
|
||||||
;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state
|
|
||||||
}, input)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function layoutShiftSample(entry: Pick<PerformanceEntry, "startTime"> & { value: number }, started: number) {
|
|
||||||
if (entry.startTime < started) return
|
|
||||||
return { occurredAtMs: entry.startTime - started, value: entry.value }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForCachedRepaintWindow(page: Page, durationMs: number) {
|
|
||||||
await page.waitForFunction((durationMs) => {
|
|
||||||
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash
|
|
||||||
return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs
|
|
||||||
}, durationMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function collectCachedRepaintTrace(page: Page) {
|
|
||||||
return page.evaluate(() => {
|
|
||||||
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash!
|
|
||||||
state.stop()
|
|
||||||
return state
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) {
|
|
||||||
const roots = trace.samples.map((sample) => sample.root)
|
|
||||||
const bottomErrors = trace.samples.flatMap((sample) =>
|
|
||||||
sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)],
|
|
||||||
)
|
|
||||||
const category = (sample: CachedRepaintTrace["samples"][number]) => {
|
|
||||||
if (sample.source.length) return "source"
|
|
||||||
if (sample.root === undefined || sample.rows.length === 0) return "blank"
|
|
||||||
if (!sample.destination.length) return "unknown"
|
|
||||||
if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct"
|
|
||||||
return "wrongDestination"
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
samples: trace.samples.length,
|
|
||||||
durationMs: trace.samples.at(-1)?.observedAtMs ?? 0,
|
|
||||||
firstSampleObservedMs: trace.samples[0]?.observedAtMs,
|
|
||||||
firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false,
|
|
||||||
blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length,
|
|
||||||
sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length,
|
|
||||||
wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length,
|
|
||||||
unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length,
|
|
||||||
rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length,
|
|
||||||
mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0,
|
|
||||||
mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)),
|
|
||||||
maxBottomErrorPx: Math.max(0, ...bottomErrors),
|
|
||||||
mutationBatches: trace.mutations.length,
|
|
||||||
addedNodes: trace.mutations.reduce(
|
|
||||||
(sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
removedNodes: trace.mutations.reduce(
|
|
||||||
(sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0),
|
|
||||||
maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compressCachedRepaintTrace(trace: CachedRepaintTrace) {
|
|
||||||
const samples: {
|
|
||||||
observedAtMs: number[]
|
|
||||||
state: Omit<CachedRepaintTrace["samples"][number], "observedAtMs">
|
|
||||||
}[] = []
|
|
||||||
for (const sample of trace.samples) {
|
|
||||||
const { observedAtMs, ...state } = sample
|
|
||||||
const previous = samples.at(-1)
|
|
||||||
if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) {
|
|
||||||
previous.observedAtMs.push(observedAtMs)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
samples.push({ observedAtMs: [observedAtMs], state })
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
timeOriginEpochMs: trace.timeOriginEpochMs,
|
|
||||||
startedAtPerformanceMs: trace.startedAtPerformanceMs,
|
|
||||||
windowMs: trace.windowMs,
|
|
||||||
summary: summarizeCachedRepaintTrace(trace),
|
|
||||||
samples,
|
|
||||||
mutations: trace.mutations,
|
|
||||||
shifts: trace.shifts,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { expectSessionTitle } from "../../utils/waits"
|
|
||||||
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
|
||||||
import { fixture } from "./session-timeline-stress.fixture"
|
|
||||||
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
|
|
||||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
|
||||||
|
|
||||||
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
|
|
||||||
|
|
||||||
benchmark("benchmarks cold and hot session tab switching", async ({ browser, report }, testInfo) => {
|
|
||||||
benchmark.setTimeout(180_000)
|
|
||||||
const results = { cold: [] as Result[], hot: [] as Result[] }
|
|
||||||
for (const mode of ["cold", "hot"] as const) {
|
|
||||||
for (let run = 0; run < 5; run++) {
|
|
||||||
results[mode].push(
|
|
||||||
await withBenchmarkPage(browser, `session-tab-switch-${mode}-${run}`, (page) => trial(page, mode), testInfo),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
report({ results, summary: summarize(results) })
|
|
||||||
})
|
|
||||||
|
|
||||||
async function trial(page: Page, mode: "cold" | "hot") {
|
|
||||||
await mockStressTimeline(page)
|
|
||||||
await installStressSessionTabs(page)
|
|
||||||
if (mode === "hot") {
|
|
||||||
await page.goto(stressSessionHref(fixture.targetID))
|
|
||||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
|
||||||
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
|
||||||
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
|
||||||
} else {
|
|
||||||
await page.goto(stressSessionHref(fixture.sourceID))
|
|
||||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
|
||||||
}
|
|
||||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
|
||||||
|
|
||||||
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
|
||||||
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
|
|
||||||
const lastID = fixture.expected.targetMessageIDs.at(-1)!
|
|
||||||
const href = stressSessionHref(fixture.targetID)
|
|
||||||
const result = await measureSessionSwitch(page, {
|
|
||||||
destinationIDs,
|
|
||||||
sourceIDs,
|
|
||||||
lastID,
|
|
||||||
href,
|
|
||||||
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarize(results: Record<"cold" | "hot", Result[]>) {
|
|
||||||
const stats = (values: (number | null)[]) => {
|
|
||||||
const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
|
|
||||||
return {
|
|
||||||
min: sorted[0] ?? null,
|
|
||||||
median: sorted[Math.floor(sorted.length / 2)] ?? null,
|
|
||||||
max: sorted.at(-1) ?? null,
|
|
||||||
missing: values.length - sorted.length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(results).map(([mode, values]) => [
|
|
||||||
mode,
|
|
||||||
{
|
|
||||||
firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
|
|
||||||
firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
|
|
||||||
stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
|
||||||
const href = stressSessionHref(sessionID)
|
|
||||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
await expectSessionTitle(page, title)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
export type SessionSwitchSample = {
|
|
||||||
observedAtMs: number
|
|
||||||
destination: string[]
|
|
||||||
source: string[]
|
|
||||||
hasVisibleRows: boolean
|
|
||||||
last: boolean
|
|
||||||
bottomErrorPx?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function classifySessionSwitch(samples: SessionSwitchSample[]) {
|
|
||||||
const firstDestination = samples.findIndex((sample) => sample.destination.length > 0)
|
|
||||||
const firstCorrect = samples.findIndex(isCorrectDestination)
|
|
||||||
const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
|
|
||||||
return {
|
|
||||||
firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
|
|
||||||
firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
|
|
||||||
stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
|
|
||||||
wrongDestinationSamples: samples
|
|
||||||
.slice(firstDestination)
|
|
||||||
.filter((sample) => sample.destination.length > 0 && !sample.last).length,
|
|
||||||
blankSamples: samples.filter((sample) => !sample.hasVisibleRows).length,
|
|
||||||
unknownSamples: samples.filter(
|
|
||||||
(sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0,
|
|
||||||
).length,
|
|
||||||
sourceSamples: samples.filter((sample) => sample.source.length > 0).length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCorrectDestination(sample: SessionSwitchSample) {
|
|
||||||
return (
|
|
||||||
sample.destination.length > 0 &&
|
|
||||||
sample.source.length === 0 &&
|
|
||||||
sample.last &&
|
|
||||||
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isStableSessionSwitch(samples: SessionSwitchSample[]) {
|
|
||||||
return samples.length === 3 && samples.every(isCorrectDestination)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isStableDestination(samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[]) {
|
|
||||||
return (
|
|
||||||
samples.length === 3 && samples.every((sample) => sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import { expect, type Page } from "@playwright/test"
|
|
||||||
import { classifySessionSwitch, isStableDestination, type SessionSwitchSample } from "./session-tab-switch-metrics"
|
|
||||||
|
|
||||||
type SessionSwitchProbe = {
|
|
||||||
samples: SessionSwitchSample[]
|
|
||||||
stop: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installSessionSwitchProbe(
|
|
||||||
page: Page,
|
|
||||||
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
|
|
||||||
) {
|
|
||||||
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
|
|
||||||
const destination = new Set(destinationIDs)
|
|
||||||
const source = new Set(sourceIDs)
|
|
||||||
const samples: SessionSwitchSample[] = []
|
|
||||||
let started: number | undefined
|
|
||||||
let running = true
|
|
||||||
const sample = () => {
|
|
||||||
if (!running || started === undefined) return
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!running || started === undefined) return
|
|
||||||
const observedAtMs = performance.now() - started
|
|
||||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
|
||||||
element.querySelector("[data-timeline-row]"),
|
|
||||||
)
|
|
||||||
if (root) {
|
|
||||||
const view = root.getBoundingClientRect()
|
|
||||||
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
|
||||||
.filter((element) => {
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
return rect.bottom > view.top && rect.top < view.bottom
|
|
||||||
})
|
|
||||||
.map((element) => element.dataset.messageId!)
|
|
||||||
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
return rect.bottom > view.top && rect.top < view.bottom
|
|
||||||
})
|
|
||||||
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
|
||||||
samples.push({
|
|
||||||
observedAtMs,
|
|
||||||
destination: visible.filter((id) => destination.has(id)),
|
|
||||||
source: visible.filter((id) => source.has(id)),
|
|
||||||
hasVisibleRows,
|
|
||||||
last: visible.includes(lastID),
|
|
||||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false })
|
|
||||||
}
|
|
||||||
requestAnimationFrame(sample)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
document.addEventListener(
|
|
||||||
"click",
|
|
||||||
(event) => {
|
|
||||||
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
|
||||||
if (link?.getAttribute("href") !== href) return
|
|
||||||
started = performance.now()
|
|
||||||
requestAnimationFrame(sample)
|
|
||||||
},
|
|
||||||
{ capture: true, once: true },
|
|
||||||
)
|
|
||||||
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
|
|
||||||
samples,
|
|
||||||
stop: () => {
|
|
||||||
running = false
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}, input)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForStableSessionSwitch(page: Page) {
|
|
||||||
await page.waitForFunction(() => {
|
|
||||||
const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples
|
|
||||||
if (!samples) return false
|
|
||||||
return samples.some((_, index) => {
|
|
||||||
const stable = samples.slice(index, index + 3)
|
|
||||||
return (
|
|
||||||
stable.length === 3 &&
|
|
||||||
stable.every(
|
|
||||||
(sample) =>
|
|
||||||
sample.destination.length > 0 &&
|
|
||||||
sample.source.length === 0 &&
|
|
||||||
sample.last &&
|
|
||||||
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectSessionSwitchResult(page: Page) {
|
|
||||||
const samples = await page.evaluate(() => {
|
|
||||||
const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
|
|
||||||
probe.stop()
|
|
||||||
return probe.samples
|
|
||||||
})
|
|
||||||
return classifySessionSwitch(samples)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function measureSessionSwitch(
|
|
||||||
page: Page,
|
|
||||||
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
|
|
||||||
) {
|
|
||||||
const { switch: run, ...probe } = input
|
|
||||||
await installSessionSwitchProbe(page, probe)
|
|
||||||
await run()
|
|
||||||
await waitForStableSessionSwitch(page)
|
|
||||||
return collectSessionSwitchResult(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForStableTimeline(page: Page, lastID: string) {
|
|
||||||
const samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[] = []
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
samples.push(
|
|
||||||
await page.evaluate(
|
|
||||||
(lastID) =>
|
|
||||||
new Promise<Pick<SessionSwitchSample, "last" | "bottomErrorPx">>((resolve) => {
|
|
||||||
requestAnimationFrame(() =>
|
|
||||||
setTimeout(() => {
|
|
||||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
|
||||||
element.querySelector("[data-timeline-row]"),
|
|
||||||
)
|
|
||||||
if (!root) {
|
|
||||||
resolve({ last: false })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const view = root.getBoundingClientRect()
|
|
||||||
const last = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].some((element) => {
|
|
||||||
if (element.dataset.messageId !== lastID) return false
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
return rect.bottom > view.top && rect.top < view.bottom
|
|
||||||
})
|
|
||||||
const spacer = root
|
|
||||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
|
||||||
?.getBoundingClientRect()
|
|
||||||
resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined })
|
|
||||||
}, 0),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
lastID,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return isStableDestination(samples.slice(-3))
|
|
||||||
},
|
|
||||||
{ timeout: 30_000, intervals: [0] },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
|
||||||
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
|
|
||||||
import { expect } from "../benchmark"
|
|
||||||
|
|
||||||
const directory = "C:/OpenCode/TimelineStateRegression"
|
|
||||||
const projectID = "proj_timeline_state_regression"
|
|
||||||
const sessionID = "ses_timeline_state_regression"
|
|
||||||
const userMessageID = "msg_user_regression"
|
|
||||||
const assistantMessageID = "msg_assistant_regression"
|
|
||||||
const editPartID = "prt_0001_edit"
|
|
||||||
export const textPartID = "prt_9999_text"
|
|
||||||
const title = "Timeline collapse state regression"
|
|
||||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
|
||||||
|
|
||||||
type EventPayload = {
|
|
||||||
directory: string
|
|
||||||
payload: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMessage = {
|
|
||||||
info: {
|
|
||||||
id: userMessageID,
|
|
||||||
sessionID,
|
|
||||||
role: "user",
|
|
||||||
time: { created: 1700000000000 },
|
|
||||||
summary: { diffs: [] },
|
|
||||||
agent: "build",
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
id: "prt_user_text",
|
|
||||||
sessionID,
|
|
||||||
messageID: userMessageID,
|
|
||||||
type: "text",
|
|
||||||
text: "Please edit the file.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const editPart = {
|
|
||||||
id: editPartID,
|
|
||||||
sessionID,
|
|
||||||
messageID: assistantMessageID,
|
|
||||||
type: "tool",
|
|
||||||
callID: "call_edit_regression",
|
|
||||||
tool: "edit",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: { filePath: "src/regression.ts" },
|
|
||||||
output: "Edited src/regression.ts",
|
|
||||||
title: "src/regression.ts",
|
|
||||||
metadata: {
|
|
||||||
filediff: {
|
|
||||||
file: "src/regression.ts",
|
|
||||||
additions: 1,
|
|
||||||
deletions: 1,
|
|
||||||
before: "export const value = 'before'\n",
|
|
||||||
after: "export const value = 'after'\n",
|
|
||||||
},
|
|
||||||
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
|
|
||||||
},
|
|
||||||
time: { start: 1700000001000, end: 1700000002000 },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamedTextPart = {
|
|
||||||
id: textPartID,
|
|
||||||
sessionID,
|
|
||||||
messageID: assistantMessageID,
|
|
||||||
type: "text",
|
|
||||||
text: "Streaming added a later assistant text part.",
|
|
||||||
}
|
|
||||||
|
|
||||||
const assistantMessage = {
|
|
||||||
info: {
|
|
||||||
id: assistantMessageID,
|
|
||||||
sessionID,
|
|
||||||
role: "assistant",
|
|
||||||
time: { created: 1700000001000 },
|
|
||||||
parentID: userMessageID,
|
|
||||||
modelID: model.modelID,
|
|
||||||
providerID: model.providerID,
|
|
||||||
mode: "build",
|
|
||||||
agent: "build",
|
|
||||||
path: { cwd: directory, root: directory },
|
|
||||||
cost: 0.01,
|
|
||||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
variant: "max",
|
|
||||||
},
|
|
||||||
parts: [editPart],
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) {
|
|
||||||
const events: EventPayload[] = []
|
|
||||||
let eventBatch = options.eventBatch
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
directory,
|
|
||||||
project: project(),
|
|
||||||
provider: provider(),
|
|
||||||
sessions: [session()],
|
|
||||||
pageMessages: () => ({
|
|
||||||
items: [
|
|
||||||
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
|
|
||||||
userMessage,
|
|
||||||
assistantMessage,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
events: () => events.splice(0, eventBatch),
|
|
||||||
eventRetry: 16,
|
|
||||||
})
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"settings.v3",
|
|
||||||
JSON.stringify({
|
|
||||||
general: {
|
|
||||||
editToolPartsExpanded: true,
|
|
||||||
shellToolPartsExpanded: true,
|
|
||||||
showReasoningSummaries: true,
|
|
||||||
showSessionProgressBar: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
await page.setViewportSize({ width: 1366, height: 768 })
|
|
||||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
|
||||||
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
|
||||||
await expectSessionTitle(page, title)
|
|
||||||
await expectAppVisible(scroller)
|
|
||||||
return {
|
|
||||||
scroller,
|
|
||||||
text,
|
|
||||||
transport: {
|
|
||||||
enqueue(payload: EventPayload | EventPayload[]) {
|
|
||||||
events.push(...(Array.isArray(payload) ? payload : [payload]))
|
|
||||||
},
|
|
||||||
pendingCount() {
|
|
||||||
return events.length
|
|
||||||
},
|
|
||||||
releaseAll() {
|
|
||||||
eventBatch = events.length
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async scrollToBottom() {
|
|
||||||
await scroller.evaluate((element) => {
|
|
||||||
element.scrollTop = element.scrollHeight
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async waitForStableGeometry() {
|
|
||||||
await expect
|
|
||||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
await page.waitForFunction((partID) => {
|
|
||||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
|
||||||
element.querySelector(`[data-timeline-part-id="${partID}"]`),
|
|
||||||
)
|
|
||||||
if (!root) return false
|
|
||||||
return new Promise<boolean>((resolve) => {
|
|
||||||
const height = root.scrollHeight
|
|
||||||
requestAnimationFrame(() =>
|
|
||||||
requestAnimationFrame(() =>
|
|
||||||
resolve(root.scrollHeight === height && root.scrollHeight - root.clientHeight - root.scrollTop <= 1),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, textPartID)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildInitialStreamEvent(deltaCount: number): EventPayload {
|
|
||||||
return {
|
|
||||||
directory,
|
|
||||||
payload: {
|
|
||||||
type: "message.part.updated",
|
|
||||||
properties: {
|
|
||||||
part: {
|
|
||||||
...streamedTextPart,
|
|
||||||
text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
|
|
||||||
return Array.from({ length: deltaCount }, (_, index) => ({
|
|
||||||
directory,
|
|
||||||
payload: {
|
|
||||||
type: "message.part.delta",
|
|
||||||
properties: {
|
|
||||||
messageID: assistantMessageID,
|
|
||||||
partID: textPartID,
|
|
||||||
field: "text",
|
|
||||||
delta: streamChunk(index + 1, deltaCount + 1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function performanceTurn(index: number) {
|
|
||||||
const suffix = String(index).padStart(4, "0")
|
|
||||||
const userID = `msg_0000_${suffix}_a_user`
|
|
||||||
const assistantID = `msg_0000_${suffix}_b_assistant`
|
|
||||||
const before = historicalSource(index, false)
|
|
||||||
const after = historicalSource(index, true)
|
|
||||||
const parts = [
|
|
||||||
...(index % 5 === 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: `prt_0000_${suffix}_reasoning`,
|
|
||||||
sessionID,
|
|
||||||
messageID: assistantID,
|
|
||||||
type: "reasoning",
|
|
||||||
text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
|
|
||||||
time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
id: `prt_0000_${suffix}_assistant`,
|
|
||||||
sessionID,
|
|
||||||
messageID: assistantID,
|
|
||||||
type: "text",
|
|
||||||
text: historicalMarkdown(index),
|
|
||||||
},
|
|
||||||
...(index % 8 === 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: `prt_0000_${suffix}_edit`,
|
|
||||||
sessionID,
|
|
||||||
messageID: assistantID,
|
|
||||||
type: "tool",
|
|
||||||
callID: `call_0000_${suffix}_edit`,
|
|
||||||
tool: "edit",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: { filePath: `src/history-${index}.ts` },
|
|
||||||
output: `Edited src/history-${index}.ts`,
|
|
||||||
title: `src/history-${index}.ts`,
|
|
||||||
metadata: {
|
|
||||||
filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
|
|
||||||
},
|
|
||||||
time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(index % 12 === 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: `prt_0000_${suffix}_write`,
|
|
||||||
sessionID,
|
|
||||||
messageID: assistantID,
|
|
||||||
type: "tool",
|
|
||||||
callID: `call_0000_${suffix}_write`,
|
|
||||||
tool: "write",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: { filePath: `src/generated-${index}.tsx`, content: after },
|
|
||||||
output: `Wrote src/generated-${index}.tsx`,
|
|
||||||
title: `src/generated-${index}.tsx`,
|
|
||||||
metadata: {
|
|
||||||
filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
|
|
||||||
},
|
|
||||||
time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(index % 16 === 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: `prt_0000_${suffix}_patch`,
|
|
||||||
sessionID,
|
|
||||||
messageID: assistantID,
|
|
||||||
type: "tool",
|
|
||||||
callID: `call_0000_${suffix}_patch`,
|
|
||||||
tool: "apply_patch",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: { patchText: realisticPatch(index) },
|
|
||||||
output: "Success. Updated src/components/SessionCard.tsx",
|
|
||||||
title: "src/components/SessionCard.tsx",
|
|
||||||
metadata: {
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
filePath: "src/components/SessionCard.tsx",
|
|
||||||
relativePath: "src/components/SessionCard.tsx",
|
|
||||||
type: "update",
|
|
||||||
additions: 8,
|
|
||||||
deletions: 3,
|
|
||||||
patch: realisticPatch(index),
|
|
||||||
before,
|
|
||||||
after,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
info: {
|
|
||||||
id: userID,
|
|
||||||
sessionID,
|
|
||||||
role: "user",
|
|
||||||
time: { created: 1690000000000 + index * 2_000 },
|
|
||||||
summary: { diffs: [] },
|
|
||||||
agent: "build",
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
id: `prt_0000_${suffix}_user`,
|
|
||||||
sessionID,
|
|
||||||
messageID: userID,
|
|
||||||
type: "text",
|
|
||||||
text: `Historical prompt ${index}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
info: {
|
|
||||||
id: assistantID,
|
|
||||||
sessionID,
|
|
||||||
role: "assistant",
|
|
||||||
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
|
|
||||||
parentID: userID,
|
|
||||||
modelID: model.modelID,
|
|
||||||
providerID: model.providerID,
|
|
||||||
mode: "build",
|
|
||||||
agent: "build",
|
|
||||||
path: { cwd: directory, root: directory },
|
|
||||||
cost: 0.01,
|
|
||||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
variant: "max",
|
|
||||||
finish: "stop",
|
|
||||||
},
|
|
||||||
parts,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function historicalMarkdown(index: number) {
|
|
||||||
const code = `import { For, Show, createSignal } from "solid-js"
|
|
||||||
|
|
||||||
type SessionRow = { id: string; title: string; active: boolean }
|
|
||||||
|
|
||||||
export function SessionList(props: { rows: SessionRow[] }) {
|
|
||||||
const [selected, setSelected] = createSignal<string>()
|
|
||||||
return (
|
|
||||||
<section aria-label="Sessions">
|
|
||||||
<For each={props.rows}>{(row) => (
|
|
||||||
<button classList={{ active: row.active }} onClick={() => setSelected(row.id)}>
|
|
||||||
<Show when={selected() === row.id} fallback={row.title}>{row.title.toUpperCase()}</Show>
|
|
||||||
</button>
|
|
||||||
)}</For>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}`
|
|
||||||
return `## Session renderer review ${index}
|
|
||||||
|
|
||||||
The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call.
|
|
||||||
|
|
||||||
| Concern | Current behavior | Verification |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| streaming | appends Markdown blocks | painted frames |
|
|
||||||
| geometry | anchors visible rows | DOM coordinates |
|
|
||||||
| tools | preserves expanded state | keyed remount probe |
|
|
||||||
|
|
||||||
> Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs.
|
|
||||||
|
|
||||||
${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function historicalSource(index: number, updated: boolean) {
|
|
||||||
const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()"
|
|
||||||
const limit = updated ? 24 : 20
|
|
||||||
return `import { createMemo, For } from "solid-js"
|
|
||||||
|
|
||||||
type Message = {
|
|
||||||
id: string
|
|
||||||
role: "user" | "assistant"
|
|
||||||
text: string
|
|
||||||
tokens: { input: number; output: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageSummary(props: { messages: Message[]; locale: string }) {
|
|
||||||
const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit}))
|
|
||||||
const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0))
|
|
||||||
return (
|
|
||||||
<article data-session-index="${index}">
|
|
||||||
<header>{total().toLocaleString(props.locale)} output tokens</header>
|
|
||||||
<For each={visible()}>{(message) => <p data-role={message.role}>{message.text.${method}}</p>}</For>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
function realisticPatch(index: number) {
|
|
||||||
return `*** Begin Patch
|
|
||||||
*** Update File: src/components/SessionCard.tsx
|
|
||||||
@@
|
|
||||||
-const title = props.session.title.toUpperCase()
|
|
||||||
-const messages = props.messages.slice(-20)
|
|
||||||
+const title = props.session.title.toLocaleUpperCase(props.locale)
|
|
||||||
+const messages = props.messages.filter((message) => message.text.trim()).slice(-24)
|
|
||||||
+const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0)
|
|
||||||
@@
|
|
||||||
- <h2>{title}</h2>
|
|
||||||
+ <h2 data-session-index="${index}">{title}</h2>
|
|
||||||
+ <span>{outputTokens.toLocaleString(props.locale)} output tokens</span>
|
|
||||||
*** End Patch`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function streamChunk(index: number, count: number) {
|
|
||||||
if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis`
|
|
||||||
if (index === count - 1)
|
|
||||||
return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete <!-- stream-${index} -->`
|
|
||||||
|
|
||||||
const section = Math.floor(index / 18) + 1
|
|
||||||
const fragments = [
|
|
||||||
` continues across three`,
|
|
||||||
` or four word`,
|
|
||||||
` provider deltas and`,
|
|
||||||
` closes in this fragment**. <!-- stream-${index} -->\n\n`,
|
|
||||||
`| Concern | State`,
|
|
||||||
` | Verification |\n|`,
|
|
||||||
` --- | ---`,
|
|
||||||
` | --- |\n|`,
|
|
||||||
` markdown | incremental |`,
|
|
||||||
` painted frames | <!-- stream-${index} -->\n\n`,
|
|
||||||
`\`\`\`tsx\nconst row: SessionRow`,
|
|
||||||
` = rows[index] ??`,
|
|
||||||
` fallback\nconst title =`,
|
|
||||||
` row.title.toLocaleUpperCase(locale)\n`,
|
|
||||||
`const selected = createMemo(()`,
|
|
||||||
` => row.id ===`,
|
|
||||||
` activeID()) // stream-${index}\n`,
|
|
||||||
`// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`,
|
|
||||||
]
|
|
||||||
return fragments[(index - 1) % fragments.length]!
|
|
||||||
}
|
|
||||||
|
|
||||||
function project() {
|
|
||||||
return {
|
|
||||||
id: projectID,
|
|
||||||
worktree: directory,
|
|
||||||
vcs: "git",
|
|
||||||
name: "timeline-state-regression",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
sandboxes: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function session() {
|
|
||||||
return {
|
|
||||||
id: sessionID,
|
|
||||||
slug: "timeline-state-regression",
|
|
||||||
projectID,
|
|
||||||
directory,
|
|
||||||
title,
|
|
||||||
version: "dev",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function provider() {
|
|
||||||
return {
|
|
||||||
all: [
|
|
||||||
{
|
|
||||||
id: "opencode",
|
|
||||||
name: "OpenCode",
|
|
||||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
connected: ["opencode"],
|
|
||||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
|
||||||
import {
|
|
||||||
buildInitialStreamEvent,
|
|
||||||
buildStreamDeltaEvents,
|
|
||||||
setupTimelineBenchmark,
|
|
||||||
textPartID,
|
|
||||||
} from "./session-timeline-benchmark.fixture"
|
|
||||||
import { startTimelineProfile } from "./session-timeline-profile"
|
|
||||||
import {
|
|
||||||
collectTimelineStreamMetrics,
|
|
||||||
installTimelineStreamProbe,
|
|
||||||
startTimelineStreamProbe,
|
|
||||||
} from "./session-timeline-stream-probe"
|
|
||||||
|
|
||||||
benchmark.describe("performance: session timeline streaming", () => {
|
|
||||||
benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => {
|
|
||||||
benchmark.setTimeout(480_000)
|
|
||||||
const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
|
|
||||||
const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
|
|
||||||
const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
|
|
||||||
const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
|
|
||||||
const minimal = process.env.TIMELINE_MINIMAL === "1"
|
|
||||||
const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
|
|
||||||
const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
|
|
||||||
const fixture = await setupTimelineBenchmark(page, {
|
|
||||||
historyTurns,
|
|
||||||
eventBatch,
|
|
||||||
})
|
|
||||||
|
|
||||||
fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
|
|
||||||
const contentStart = performance.now()
|
|
||||||
await expect(fixture.text).toBeVisible()
|
|
||||||
await expect(fixture.text).toContainText("Implementation plan")
|
|
||||||
const initialContentObservedMs = performance.now() - contentStart
|
|
||||||
await fixture.scrollToBottom()
|
|
||||||
await fixture.waitForStableGeometry()
|
|
||||||
|
|
||||||
const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
|
|
||||||
await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
|
|
||||||
const deltas = buildStreamDeltaEvents(deltaCount)
|
|
||||||
await startTimelineStreamProbe(page)
|
|
||||||
fixture.transport.enqueue(deltas)
|
|
||||||
|
|
||||||
await page.waitForFunction(
|
|
||||||
(finalIndex) =>
|
|
||||||
(
|
|
||||||
window as Window & {
|
|
||||||
__timelineStreamBenchmark?: { applied: { index: number }[] }
|
|
||||||
}
|
|
||||||
).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
|
|
||||||
deltaCount,
|
|
||||||
{ timeout: 420_000 },
|
|
||||||
)
|
|
||||||
await expect(fixture.text).toContainText("benchmark-complete")
|
|
||||||
await expect(fixture.text).toContainText("Streaming")
|
|
||||||
await fixture.waitForStableGeometry()
|
|
||||||
const metrics = await collectTimelineStreamMetrics(page, {
|
|
||||||
textPartID,
|
|
||||||
finalIndex: deltaCount,
|
|
||||||
navigations: benchmarkDiagnostics(page).navigations,
|
|
||||||
})
|
|
||||||
const delivered = deltas.length - fixture.transport.pendingCount()
|
|
||||||
await profile.stop()
|
|
||||||
|
|
||||||
report(
|
|
||||||
{
|
|
||||||
endToEndInitialContentObservedMs: initialContentObservedMs,
|
|
||||||
...metrics,
|
|
||||||
deliveredDeltas: delivered,
|
|
||||||
pendingDeltas: fixture.transport.pendingCount(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cpuThrottle,
|
|
||||||
profileCPU,
|
|
||||||
profileVisual,
|
|
||||||
minimal,
|
|
||||||
queuedDeltas: deltas.length,
|
|
||||||
historyTurns,
|
|
||||||
eventBatch,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await profile.reset()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { CDPSession, Page } from "@playwright/test"
|
|
||||||
|
|
||||||
export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
|
|
||||||
const cdp = await page.context().newCDPSession(page)
|
|
||||||
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: options.cpuThrottle })
|
|
||||||
if (options.profileCPU) {
|
|
||||||
await cdp.send("Profiler.enable")
|
|
||||||
await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
|
|
||||||
await cdp.send("Profiler.start")
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
async stop() {
|
|
||||||
if (!options.profileCPU) return
|
|
||||||
const result = await cdp.send("Profiler.stop")
|
|
||||||
const self = new Map<number, number>()
|
|
||||||
result.profile.samples?.forEach((id, index) => {
|
|
||||||
const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
|
|
||||||
self.set(id, (self.get(id) ?? 0) + duration)
|
|
||||||
})
|
|
||||||
console.log(
|
|
||||||
"timeline cpu profile",
|
|
||||||
JSON.stringify(
|
|
||||||
result.profile.nodes
|
|
||||||
.map((node) => ({
|
|
||||||
function: node.callFrame.functionName || "(anonymous)",
|
|
||||||
url: node.callFrame.url,
|
|
||||||
line: node.callFrame.lineNumber + 1,
|
|
||||||
selfMs: self.get(node.id) ?? 0,
|
|
||||||
}))
|
|
||||||
.filter((node) => node.selfMs > 1)
|
|
||||||
.sort((a, b) => b.selfMs - a.selfMs)
|
|
||||||
.slice(0, 40),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
async reset() {
|
|
||||||
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 })
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,547 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test"
|
|
||||||
|
|
||||||
const STREAM_MARKER_PATTERN = "stream-(\\d+)"
|
|
||||||
const STREAM_FRAGMENT_COUNT = 18
|
|
||||||
|
|
||||||
type TimelineProbeState = {
|
|
||||||
started: number
|
|
||||||
ended: number
|
|
||||||
profileVisual: boolean
|
|
||||||
minimal: boolean
|
|
||||||
frames: number[]
|
|
||||||
frameAt: number[]
|
|
||||||
applied: { at: number; index: number }[]
|
|
||||||
geometry: {
|
|
||||||
scrollTop: number
|
|
||||||
scrollHeight: number
|
|
||||||
clientHeight: number
|
|
||||||
distance: number
|
|
||||||
virtualHeight: number
|
|
||||||
headerHeight: number
|
|
||||||
}[]
|
|
||||||
blanks: number
|
|
||||||
longTasks: number[]
|
|
||||||
layoutShifts: number[]
|
|
||||||
visibleMounts: number
|
|
||||||
visibleUnmounts: number
|
|
||||||
visibleRows: Set<Element>
|
|
||||||
visibleSubtreeMounts: string[]
|
|
||||||
visibleSubtreeUnmounts: string[]
|
|
||||||
visibleSubtreeReplacements: number
|
|
||||||
visibleSubtreeDropouts: string[]
|
|
||||||
visibleSubtrees: Map<string, Element>
|
|
||||||
subtreeKeys: WeakMap<Element, string>
|
|
||||||
maxOverlap: number
|
|
||||||
maxGap: number
|
|
||||||
maxPartTopMovement: number
|
|
||||||
previousPartTop: number
|
|
||||||
slowFrames: {
|
|
||||||
duration: number
|
|
||||||
index: number
|
|
||||||
phase: "stream" | "boundary" | "complete" | "unknown"
|
|
||||||
tokenSpans: number
|
|
||||||
blocks: number
|
|
||||||
codeBlocks: number
|
|
||||||
height: number
|
|
||||||
distance: number
|
|
||||||
}[]
|
|
||||||
scroll: {
|
|
||||||
calls: number
|
|
||||||
callNoops: number
|
|
||||||
sameFrameCalls: number
|
|
||||||
assignments: number
|
|
||||||
assignmentNoops: number
|
|
||||||
lastCallFrame: number
|
|
||||||
frame: number
|
|
||||||
}
|
|
||||||
row: HTMLElement
|
|
||||||
markdown: HTMLElement
|
|
||||||
running: boolean
|
|
||||||
previous: number
|
|
||||||
cleanup: () => void
|
|
||||||
start: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installTimelineStreamProbe(
|
|
||||||
page: Page,
|
|
||||||
options: { textPartID: string; finalIndex: number; profileVisual: boolean; minimal: boolean },
|
|
||||||
) {
|
|
||||||
await page.evaluate(
|
|
||||||
({ textPartID, finalIndex, profileVisual, minimal, markerPattern, fragmentCount }) => {
|
|
||||||
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
|
|
||||||
const row = part?.closest<HTMLElement>("[data-timeline-row]")
|
|
||||||
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
|
|
||||||
const root = part?.closest<HTMLElement>(".scroll-view__viewport")
|
|
||||||
if (!part || !row || !markdown || !root) throw new Error("missing streaming benchmark nodes")
|
|
||||||
const viewport = root.getBoundingClientRect()
|
|
||||||
const state: TimelineProbeState = {
|
|
||||||
started: 0,
|
|
||||||
ended: Infinity,
|
|
||||||
profileVisual,
|
|
||||||
minimal,
|
|
||||||
frames: [],
|
|
||||||
frameAt: [],
|
|
||||||
applied: [],
|
|
||||||
geometry: [],
|
|
||||||
blanks: 0,
|
|
||||||
longTasks: [],
|
|
||||||
layoutShifts: [],
|
|
||||||
visibleMounts: 0,
|
|
||||||
visibleUnmounts: 0,
|
|
||||||
visibleRows: new Set(
|
|
||||||
[...root.querySelectorAll("[data-timeline-key]")].filter((element) => {
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
return rect.bottom > viewport.top && rect.top < viewport.bottom
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
visibleSubtreeMounts: [],
|
|
||||||
visibleSubtreeUnmounts: [],
|
|
||||||
visibleSubtreeReplacements: 0,
|
|
||||||
visibleSubtreeDropouts: [],
|
|
||||||
visibleSubtrees: new Map<string, Element>(),
|
|
||||||
subtreeKeys: new WeakMap<Element, string>(),
|
|
||||||
maxOverlap: 0,
|
|
||||||
maxGap: 0,
|
|
||||||
maxPartTopMovement: 0,
|
|
||||||
previousPartTop: part.getBoundingClientRect().top,
|
|
||||||
slowFrames: [],
|
|
||||||
scroll: {
|
|
||||||
calls: 0,
|
|
||||||
callNoops: 0,
|
|
||||||
sameFrameCalls: 0,
|
|
||||||
assignments: 0,
|
|
||||||
assignmentNoops: 0,
|
|
||||||
lastCallFrame: -1,
|
|
||||||
frame: 0,
|
|
||||||
},
|
|
||||||
row,
|
|
||||||
markdown,
|
|
||||||
running: false,
|
|
||||||
previous: 0,
|
|
||||||
cleanup: () => {},
|
|
||||||
start: () => {},
|
|
||||||
}
|
|
||||||
;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state
|
|
||||||
const scrollTo = Element.prototype.scrollTo
|
|
||||||
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
|
|
||||||
if (profileVisual) {
|
|
||||||
Element.prototype.scrollTo = function (...args) {
|
|
||||||
state.scroll.calls += 1
|
|
||||||
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
|
|
||||||
if (typeof top === "number") {
|
|
||||||
const target = Math.min(top, this.scrollHeight - this.clientHeight)
|
|
||||||
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
|
|
||||||
}
|
|
||||||
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
|
|
||||||
state.scroll.lastCallFrame = state.scroll.frame
|
|
||||||
return scrollTo.apply(this, args)
|
|
||||||
}
|
|
||||||
Object.defineProperty(Element.prototype, "scrollTop", {
|
|
||||||
configurable: true,
|
|
||||||
get: scrollTop.get,
|
|
||||||
set(value) {
|
|
||||||
state.scroll.assignments += 1
|
|
||||||
if (Math.abs(this.scrollTop - value) < 1) state.scroll.assignmentNoops += 1
|
|
||||||
scrollTop.set!.call(this, value)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordLongTasks = (entries: PerformanceEntry[]) => {
|
|
||||||
if (!state.running) return
|
|
||||||
state.longTasks.push(
|
|
||||||
...entries
|
|
||||||
.filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended)
|
|
||||||
.map((entry) => entry.duration),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const longTaskObserver = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
|
|
||||||
longTaskObserver.observe({ type: "longtask" })
|
|
||||||
const recordLayoutShifts = (entries: PerformanceEntry[]) => {
|
|
||||||
if (!state.running) return
|
|
||||||
state.layoutShifts.push(
|
|
||||||
...entries
|
|
||||||
.map((entry) => {
|
|
||||||
const shift = entry as LayoutShiftEntry
|
|
||||||
if (shift.startTime < state.started || shift.hadRecentInput) return
|
|
||||||
return shift.value
|
|
||||||
})
|
|
||||||
.filter((value): value is number => value !== undefined),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const layoutShiftObserver = profileVisual
|
|
||||||
? new PerformanceObserver((list) => recordLayoutShifts(list.getEntries()))
|
|
||||||
: undefined
|
|
||||||
layoutShiftObserver?.observe({ type: "layout-shift", buffered: true })
|
|
||||||
|
|
||||||
const visible = (element: Element) => {
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
const viewport = root.getBoundingClientRect()
|
|
||||||
const style = getComputedStyle(element)
|
|
||||||
return (
|
|
||||||
element.isConnected &&
|
|
||||||
rect.width > 0 &&
|
|
||||||
rect.height > 0 &&
|
|
||||||
rect.bottom > viewport.top &&
|
|
||||||
rect.top < viewport.bottom &&
|
|
||||||
style.display !== "none" &&
|
|
||||||
style.visibility !== "hidden" &&
|
|
||||||
Number(style.opacity) > 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const critical = [
|
|
||||||
"[data-timeline-part-id]",
|
|
||||||
'[data-component="edit-content"]',
|
|
||||||
'[data-component="apply-patch-file-diff"]',
|
|
||||||
'[data-component="file"]',
|
|
||||||
'[data-component="markdown-code"]',
|
|
||||||
"[data-markdown-block]",
|
|
||||||
].join(",")
|
|
||||||
const describe = (element: Element) => {
|
|
||||||
const cached = state.subtreeKeys.get(element)
|
|
||||||
if (!element.isConnected && cached) return cached
|
|
||||||
const part = element.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId ?? "unknown"
|
|
||||||
const block = element
|
|
||||||
.closest<HTMLElement>("[data-markdown-key]")
|
|
||||||
?.dataset.markdownKey?.replace(/:(?:code|full|live)$/, "")
|
|
||||||
const component =
|
|
||||||
element.getAttribute("data-component") ?? element.getAttribute("data-markdown-block") ?? element.tagName
|
|
||||||
const key = `${part}:${block ?? "root"}:${component}`
|
|
||||||
state.subtreeKeys.set(element, key)
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
const recordMutations = (records: MutationRecord[]) => {
|
|
||||||
if (!state.running) return
|
|
||||||
records.forEach((record) => {
|
|
||||||
record.addedNodes.forEach((node) => {
|
|
||||||
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && visible(node)) {
|
|
||||||
state.visibleMounts += 1
|
|
||||||
state.visibleRows.add(node)
|
|
||||||
}
|
|
||||||
if (!(node instanceof Element)) return
|
|
||||||
const added = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
|
|
||||||
added.forEach((element) => {
|
|
||||||
if (visible(element)) state.visibleSubtreeMounts.push(describe(element))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
record.removedNodes.forEach((node) => {
|
|
||||||
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && state.visibleRows.delete(node))
|
|
||||||
state.visibleUnmounts += 1
|
|
||||||
if (!(node instanceof Element)) return
|
|
||||||
const removed = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
|
|
||||||
removed.forEach((element) => {
|
|
||||||
const key = describe(element)
|
|
||||||
if (state.visibleSubtrees.get(key) === element) state.visibleSubtreeUnmounts.push(key)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const mutationObserver = profileVisual ? new MutationObserver(recordMutations) : undefined
|
|
||||||
mutationObserver?.observe(root, { childList: true, subtree: true })
|
|
||||||
const currentPart = () => root.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
|
|
||||||
const observeProgress = (at: number) => {
|
|
||||||
if (!state.running) return
|
|
||||||
const content = currentPart()?.textContent ?? ""
|
|
||||||
const index = content.includes("benchmark-complete")
|
|
||||||
? finalIndex
|
|
||||||
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
|
|
||||||
if (index >= 0 && index !== state.applied.at(-1)?.index) state.applied.push({ at, index })
|
|
||||||
}
|
|
||||||
const progressObserver = new MutationObserver(() => observeProgress(performance.now()))
|
|
||||||
progressObserver.observe(root, { characterData: true, childList: true, subtree: true })
|
|
||||||
state.cleanup = () => {
|
|
||||||
recordLongTasks(longTaskObserver.takeRecords())
|
|
||||||
recordLayoutShifts(layoutShiftObserver?.takeRecords() ?? [])
|
|
||||||
recordMutations(mutationObserver?.takeRecords() ?? [])
|
|
||||||
if (progressObserver.takeRecords().length) observeProgress(performance.now())
|
|
||||||
longTaskObserver.disconnect()
|
|
||||||
layoutShiftObserver?.disconnect()
|
|
||||||
mutationObserver?.disconnect()
|
|
||||||
progressObserver.disconnect()
|
|
||||||
if (!profileVisual) return
|
|
||||||
Element.prototype.scrollTo = scrollTo
|
|
||||||
Object.defineProperty(Element.prototype, "scrollTop", scrollTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sample = (now: number) => {
|
|
||||||
if (!state.running) return
|
|
||||||
state.frameAt.push(now)
|
|
||||||
observeProgress(now)
|
|
||||||
if (minimal) {
|
|
||||||
state.frames.push(now - state.previous)
|
|
||||||
state.previous = now
|
|
||||||
requestAnimationFrame(sample)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!state.running) return
|
|
||||||
state.scroll.frame += 1
|
|
||||||
const duration = now - state.previous
|
|
||||||
state.frames.push(duration)
|
|
||||||
state.previous = now
|
|
||||||
const virtualRoot = root.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
|
||||||
const header = root.querySelector<HTMLElement>("[data-session-title]")
|
|
||||||
state.geometry.push({
|
|
||||||
scrollTop: root.scrollTop,
|
|
||||||
scrollHeight: root.scrollHeight,
|
|
||||||
clientHeight: root.clientHeight,
|
|
||||||
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
|
|
||||||
virtualHeight: virtualRoot?.getBoundingClientRect().height ?? 0,
|
|
||||||
headerHeight: header?.getBoundingClientRect().height ?? 0,
|
|
||||||
})
|
|
||||||
const viewport = root.getBoundingClientRect()
|
|
||||||
if (profileVisual) {
|
|
||||||
const visibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
|
||||||
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
|
|
||||||
.filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom)
|
|
||||||
.sort((a, b) => a.rect.top - b.rect.top)
|
|
||||||
state.visibleRows = new Set(visibleRows.map((item) => item.element))
|
|
||||||
const rows = visibleRows.map((item) => item.rect)
|
|
||||||
rows.slice(1).forEach((rect, index) => {
|
|
||||||
const previous = rows[index]!
|
|
||||||
state.maxOverlap = Math.max(state.maxOverlap, previous.bottom - rect.top)
|
|
||||||
state.maxGap = Math.max(state.maxGap, rect.top - previous.bottom)
|
|
||||||
})
|
|
||||||
const partTop = part.getBoundingClientRect().top
|
|
||||||
state.maxPartTopMovement = Math.max(state.maxPartTopMovement, Math.abs(partTop - state.previousPartTop))
|
|
||||||
state.previousPartTop = partTop
|
|
||||||
}
|
|
||||||
const visibleRow = [...root.querySelectorAll<HTMLElement>("[data-timeline-row]")].some((element) => {
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
return rect.bottom > viewport.top && rect.top < viewport.bottom
|
|
||||||
})
|
|
||||||
if (!visibleRow) state.blanks += 1
|
|
||||||
if (profileVisual) {
|
|
||||||
const subtrees = new Map<string, { element: Element; rendered: boolean }>()
|
|
||||||
const visibleSubtrees = new Map<string, Element>()
|
|
||||||
root.querySelectorAll(critical).forEach((element) => {
|
|
||||||
const key = describe(element)
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
const style = getComputedStyle(element)
|
|
||||||
const rendered =
|
|
||||||
element.isConnected &&
|
|
||||||
rect.width > 0 &&
|
|
||||||
rect.height > 0 &&
|
|
||||||
style.display !== "none" &&
|
|
||||||
style.visibility !== "hidden" &&
|
|
||||||
Number(style.opacity) > 0
|
|
||||||
subtrees.set(key, { element, rendered })
|
|
||||||
if (rendered && rect.bottom > viewport.top && rect.top < viewport.bottom) {
|
|
||||||
const previous = state.visibleSubtrees.get(key)
|
|
||||||
if (previous && previous !== element && key.startsWith(`${textPartID}:`))
|
|
||||||
state.visibleSubtreeReplacements += 1
|
|
||||||
visibleSubtrees.set(key, element)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
state.visibleSubtrees.forEach((element, key) => {
|
|
||||||
const current = subtrees.get(key)
|
|
||||||
if (key.startsWith(`${textPartID}:`) && !current?.rendered) {
|
|
||||||
const markdown = part.querySelector<HTMLElement>('[data-component="markdown"]')
|
|
||||||
state.visibleSubtreeDropouts.push(
|
|
||||||
`${key}:projection=${markdown?.dataset.markdownProjectionLength}/${markdown?.dataset.markdownProjectionBlocks}:result=${markdown?.dataset.markdownResultLength}/${markdown?.dataset.markdownResultBlocks}:applied=${markdown?.dataset.markdownAppliedBlocks}:dom=${markdown?.children.length}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (element.matches('[data-component="file"]')) {
|
|
||||||
const hadLines = element.hasAttribute("data-profiler-had-lines")
|
|
||||||
const hasLines = element.shadowRoot?.querySelector("[data-line]") != null
|
|
||||||
if (hasLines) element.setAttribute("data-profiler-had-lines", "")
|
|
||||||
if (hadLines && !hasLines) state.visibleSubtreeDropouts.push(`${key}:shadow-lines`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
state.visibleSubtrees = visibleSubtrees
|
|
||||||
}
|
|
||||||
if (profileVisual && duration > 33.34) {
|
|
||||||
const livePart = currentPart()
|
|
||||||
const content = livePart?.textContent ?? ""
|
|
||||||
const complete = content.includes("benchmark-complete")
|
|
||||||
const index = complete
|
|
||||||
? finalIndex
|
|
||||||
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
|
|
||||||
state.slowFrames.push({
|
|
||||||
duration,
|
|
||||||
index,
|
|
||||||
phase: complete
|
|
||||||
? "complete"
|
|
||||||
: index >= 0 && index % fragmentCount === 0
|
|
||||||
? "boundary"
|
|
||||||
: index >= 0
|
|
||||||
? "stream"
|
|
||||||
: "unknown",
|
|
||||||
tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0,
|
|
||||||
blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0,
|
|
||||||
codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0,
|
|
||||||
height: livePart?.getBoundingClientRect().height ?? 0,
|
|
||||||
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
requestAnimationFrame(sample)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
state.start = () => {
|
|
||||||
state.started = performance.now()
|
|
||||||
state.previous = state.started
|
|
||||||
state.running = true
|
|
||||||
requestAnimationFrame(sample)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ ...options, markerPattern: STREAM_MARKER_PATTERN, fragmentCount: STREAM_FRAGMENT_COUNT },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startTimelineStreamProbe(page: Page) {
|
|
||||||
return page.evaluate(() => {
|
|
||||||
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
|
|
||||||
if (!state) throw new Error("missing streaming benchmark state")
|
|
||||||
state.start()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type LayoutShiftEntry = PerformanceEntry & { value: number; hadRecentInput?: boolean }
|
|
||||||
|
|
||||||
export function layoutShiftValue(
|
|
||||||
entry: Pick<LayoutShiftEntry, "startTime" | "value" | "hadRecentInput">,
|
|
||||||
start: number,
|
|
||||||
) {
|
|
||||||
if (entry.startTime < start || entry.hadRecentInput) return
|
|
||||||
return entry.value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeVisibleRow<T>(visible: Set<T>, row: T) {
|
|
||||||
return visible.delete(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function streamProgress(content: string) {
|
|
||||||
const index = Number(content.match(new RegExp(STREAM_MARKER_PATTERN, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
phase: content.includes("benchmark-complete")
|
|
||||||
? ("complete" as const)
|
|
||||||
: index >= 0 && index % STREAM_FRAGMENT_COUNT === 0
|
|
||||||
? ("boundary" as const)
|
|
||||||
: index >= 0
|
|
||||||
? ("stream" as const)
|
|
||||||
: ("unknown" as const),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function collectTimelineStreamMetrics(
|
|
||||||
page: Page,
|
|
||||||
options: { textPartID: string; finalIndex: number; navigations: string[] },
|
|
||||||
) {
|
|
||||||
return page.evaluate(({ textPartID, finalIndex, navigations }) => {
|
|
||||||
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
|
|
||||||
if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`)
|
|
||||||
state.ended = performance.now()
|
|
||||||
state.cleanup()
|
|
||||||
state.running = false
|
|
||||||
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
|
|
||||||
const row = part?.closest<HTMLElement>("[data-timeline-row]")
|
|
||||||
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
|
|
||||||
const sorted = state.frames.slice().sort((a, b) => a - b)
|
|
||||||
const duration = state.frames.reduce((sum, value) => sum + value, 0)
|
|
||||||
const longestSlowStreak = state.frames.reduce(
|
|
||||||
(result, value) => {
|
|
||||||
const current = value > 33.34 ? result.current + 1 : 0
|
|
||||||
return { current, longest: Math.max(result.longest, current) }
|
|
||||||
},
|
|
||||||
{ current: 0, longest: 0 },
|
|
||||||
).longest
|
|
||||||
const busyStart = state.applied.at(0)?.at
|
|
||||||
const completion = state.applied.find((value) => value.index === finalIndex)
|
|
||||||
const busyEnd = completion?.at
|
|
||||||
const busyFrames =
|
|
||||||
busyStart === undefined || busyEnd === undefined
|
|
||||||
? []
|
|
||||||
: state.frames.filter((_, index) => state.frameAt[index]! >= busyStart && state.frameAt[index]! <= busyEnd)
|
|
||||||
const busySorted = busyFrames.slice().sort((a, b) => a - b)
|
|
||||||
const busyDuration = busyFrames.reduce((sum, value) => sum + value, 0)
|
|
||||||
const completionObservedMs = (completion?.at ?? NaN) - state.started
|
|
||||||
const visual = state.profileVisual
|
|
||||||
? {
|
|
||||||
layoutShiftValueSum: state.layoutShifts.reduce((sum, value) => sum + value, 0),
|
|
||||||
maxLayoutShiftValue: Math.max(0, ...state.layoutShifts),
|
|
||||||
visibleMounts: state.visibleMounts,
|
|
||||||
visibleUnmounts: state.visibleUnmounts,
|
|
||||||
visibleSubtreeMounts: state.visibleSubtreeMounts,
|
|
||||||
visibleSubtreeUnmounts: [...new Set(state.visibleSubtreeUnmounts)],
|
|
||||||
visibleSubtreeReplacements: state.visibleSubtreeReplacements,
|
|
||||||
visibleSubtreeDropouts: [...new Set(state.visibleSubtreeDropouts)],
|
|
||||||
maxOverlapPx: state.maxOverlap,
|
|
||||||
maxGapPx: state.maxGap,
|
|
||||||
maxPartTopMovementPx: state.maxPartTopMovement,
|
|
||||||
slowestRafGaps: state.slowFrames
|
|
||||||
.sort((a, b) => b.duration - a.duration)
|
|
||||||
.slice(0, 20)
|
|
||||||
.map((frame) => ({
|
|
||||||
durationMs: frame.duration,
|
|
||||||
index: frame.index,
|
|
||||||
phase: frame.phase,
|
|
||||||
tokenSpans: frame.tokenSpans,
|
|
||||||
blocks: frame.blocks,
|
|
||||||
codeBlocks: frame.codeBlocks,
|
|
||||||
heightPx: frame.height,
|
|
||||||
distancePx: frame.distance,
|
|
||||||
})),
|
|
||||||
slowRafGapPhases: Object.fromEntries(
|
|
||||||
["stream", "boundary", "complete", "unknown"].map((phase) => {
|
|
||||||
const frames = state.slowFrames.filter((frame) => frame.phase === phase)
|
|
||||||
return [
|
|
||||||
phase,
|
|
||||||
{
|
|
||||||
count: frames.length,
|
|
||||||
totalMs: frames.reduce((sum, frame) => sum + frame.duration, 0),
|
|
||||||
maxMs: Math.max(0, ...frames.map((frame) => frame.duration)),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
scroll: state.scroll,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
const geometry = state.minimal
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
maxDistancePx: Math.max(0, ...state.geometry.map((sample) => sample.distance)),
|
|
||||||
finalDistancePx: state.geometry.at(-1)?.distance ?? 0,
|
|
||||||
final: state.geometry.at(-1),
|
|
||||||
distanceTransitionsPx: state.geometry
|
|
||||||
.map((sample) => Math.round(sample.distance))
|
|
||||||
.filter((value, index, values) => index === 0 || value !== values[index - 1]),
|
|
||||||
bottomDriftTransitions: state.geometry.slice(1).filter((value, index) => {
|
|
||||||
const previous = state.geometry[index]?.distance ?? 0
|
|
||||||
return previous <= 1 && value.distance > 1
|
|
||||||
}).length,
|
|
||||||
blankSamples: state.blanks,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
capabilities: { visual: state.profileVisual, geometry: !state.minimal },
|
|
||||||
completionObservedMs,
|
|
||||||
deltasPerSecond: Number.isFinite(completionObservedMs) ? finalIndex / (completionObservedMs / 1_000) : null,
|
|
||||||
rafGapSamples: state.frames.length,
|
|
||||||
rafCallbackRate: duration ? (state.frames.length * 1000) / duration : 0,
|
|
||||||
observedProgressWindowRafCallbackRate: busyDuration ? (busyFrames.length * 1000) / busyDuration : null,
|
|
||||||
observedProgressWindowRafGapP95Ms: busySorted[Math.floor(busySorted.length * 0.95)] ?? null,
|
|
||||||
observedProgressWindowRafGaps: busyFrames.length,
|
|
||||||
maxObservedProgressIndex: Math.max(-1, ...state.applied.map((value) => value.index)),
|
|
||||||
observedProgressTransitions: state.applied.length,
|
|
||||||
rafGapP50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0,
|
|
||||||
rafGapP95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0,
|
|
||||||
rafGapP99Ms: sorted[Math.floor(sorted.length * 0.99)] ?? 0,
|
|
||||||
maxRafGapMs: sorted.at(-1) ?? 0,
|
|
||||||
rafGapsOver33Ms: state.frames.filter((value) => value > 33.34).length,
|
|
||||||
rafGapsOver50Ms: state.frames.filter((value) => value > 50).length,
|
|
||||||
missedFrameBudgetEquivalents: state.frames.reduce(
|
|
||||||
(sum, value) => sum + Math.max(0, Math.round(value / 16.67) - 1),
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
longestRafGapOver33MsStreak: longestSlowStreak,
|
|
||||||
longTaskCount: state.longTasks.length,
|
|
||||||
longTaskTimeMs: state.longTasks.reduce((sum, value) => sum + value, 0),
|
|
||||||
visual,
|
|
||||||
geometry,
|
|
||||||
rowReplaced: row !== state.row,
|
|
||||||
markdownReplaced: markdown !== state.markdown,
|
|
||||||
domTextCharacters: part?.textContent?.length ?? 0,
|
|
||||||
}
|
|
||||||
}, options)
|
|
||||||
}
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
const words = [
|
|
||||||
"alpha",
|
|
||||||
"bravo",
|
|
||||||
"charlie",
|
|
||||||
"delta",
|
|
||||||
"echo",
|
|
||||||
"foxtrot",
|
|
||||||
"golf",
|
|
||||||
"hotel",
|
|
||||||
"india",
|
|
||||||
"juliet",
|
|
||||||
"kilo",
|
|
||||||
"lima",
|
|
||||||
"metro",
|
|
||||||
"nova",
|
|
||||||
"orbit",
|
|
||||||
"pixel",
|
|
||||||
"quartz",
|
|
||||||
"river",
|
|
||||||
"signal",
|
|
||||||
"vector",
|
|
||||||
]
|
|
||||||
|
|
||||||
const sourceID = "ses_smoke_source"
|
|
||||||
const targetID = "ses_smoke_target"
|
|
||||||
const directory = "C:/OpenCode/SmokeProject"
|
|
||||||
const projectID = "proj_smoke_timeline"
|
|
||||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
|
||||||
|
|
||||||
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
|
||||||
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
|
||||||
type Message = { info: MessageInfo; parts: MessagePart[] }
|
|
||||||
|
|
||||||
function lorem(seed: number, length: number) {
|
|
||||||
let out = ""
|
|
||||||
let i = seed
|
|
||||||
while (out.length < length) {
|
|
||||||
const word = words[i % words.length]
|
|
||||||
out += (out ? " " : "") + word
|
|
||||||
if (i % 17 === 0) out += ".\n\n"
|
|
||||||
i += 7
|
|
||||||
}
|
|
||||||
return out.slice(0, length)
|
|
||||||
}
|
|
||||||
|
|
||||||
function id(prefix: string, value: number) {
|
|
||||||
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
|
||||||
const messageID = id("msg_user", index)
|
|
||||||
return {
|
|
||||||
info: {
|
|
||||||
id: messageID,
|
|
||||||
sessionID,
|
|
||||||
role: "user",
|
|
||||||
time: { created: 1700000000000 + index * 10_000 },
|
|
||||||
summary: { diffs },
|
|
||||||
agent: "build",
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
id: id("prt_user_text", index),
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
type: "text",
|
|
||||||
text: lorem(index, textLength),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
|
||||||
const messageID = id("msg_assistant", index)
|
|
||||||
return {
|
|
||||||
info: {
|
|
||||||
id: messageID,
|
|
||||||
sessionID,
|
|
||||||
role: "assistant",
|
|
||||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
|
||||||
parentID,
|
|
||||||
modelID: model.modelID,
|
|
||||||
providerID: model.providerID,
|
|
||||||
mode: "build",
|
|
||||||
agent: "build",
|
|
||||||
path: { cwd: directory, root: directory },
|
|
||||||
cost: 0.01,
|
|
||||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
variant: "max",
|
|
||||||
finish: "stop",
|
|
||||||
},
|
|
||||||
parts: parts.map((part) => ({
|
|
||||||
...part,
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function textPart(index: number, partIndex: number, length: number): MessagePart {
|
|
||||||
const prose = lorem(index * 13 + partIndex, length)
|
|
||||||
const text =
|
|
||||||
index % 12 === 0
|
|
||||||
? `${prose}\n\n\`\`\`ts\n${code(index, 80)}\n\`\`\``
|
|
||||||
: index % 5 === 0
|
|
||||||
? `${prose}\n\n\`\`\`ts\nexport const value = "${lorem(index, 220)}"\n\`\`\``
|
|
||||||
: index % 7 === 0
|
|
||||||
? `${prose}\n\nThe wrapped inline value is \`${lorem(index, 180)}\`.`
|
|
||||||
: prose
|
|
||||||
return { id: id(`prt_text_${partIndex}`, index), type: "text", text }
|
|
||||||
}
|
|
||||||
|
|
||||||
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
|
|
||||||
return {
|
|
||||||
id: id(`prt_reasoning_${partIndex}`, index),
|
|
||||||
type: "reasoning",
|
|
||||||
text: lorem(index * 19 + partIndex, length),
|
|
||||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toolPart(
|
|
||||||
index: number,
|
|
||||||
partIndex: number,
|
|
||||||
tool: string,
|
|
||||||
input: Record<string, unknown>,
|
|
||||||
outputLength = 160,
|
|
||||||
): MessagePart {
|
|
||||||
const metadata =
|
|
||||||
tool === "apply_patch"
|
|
||||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
|
||||||
: tool === "edit" || tool === "write"
|
|
||||||
? {
|
|
||||||
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
|
|
||||||
diff: patch(index, outputLength),
|
|
||||||
preview: patch(index + 1, 420),
|
|
||||||
}
|
|
||||||
: tool === "question"
|
|
||||||
? { answers: [["Proceed"], ["Keep sample output"]] }
|
|
||||||
: {}
|
|
||||||
return {
|
|
||||||
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
|
||||||
type: "tool",
|
|
||||||
callID: id("call", index * 10 + partIndex),
|
|
||||||
tool,
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input,
|
|
||||||
output: lorem(index * 23 + partIndex, outputLength),
|
|
||||||
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
|
||||||
metadata,
|
|
||||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchFile(seed: number, type: "add" | "update" | "delete") {
|
|
||||||
return {
|
|
||||||
filePath: `src/generated/patch-${seed}.ts`,
|
|
||||||
relativePath: `src/generated/patch-${seed}.ts`,
|
|
||||||
type,
|
|
||||||
additions: (seed % 7) + 1,
|
|
||||||
deletions: type === "add" ? 0 : seed % 4,
|
|
||||||
patch: patch(seed, 520),
|
|
||||||
before: type === "add" ? undefined : code(seed, 18),
|
|
||||||
after: type === "delete" ? undefined : code(seed + 1, 24),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileDiff(file: string, seed: number) {
|
|
||||||
const lines = seed % 12 === 0 ? 300 : seed % 8 === 0 ? 2 : 38
|
|
||||||
const before = code(seed, lines, seed % 10 === 0 ? 280 : 32)
|
|
||||||
const after =
|
|
||||||
lines === 2
|
|
||||||
? before.replace("value1", "updatedValue1")
|
|
||||||
: lines === 300
|
|
||||||
? code(seed + 1, lines, seed % 10 === 0 ? 280 : 32)
|
|
||||||
: before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
|
|
||||||
return {
|
|
||||||
file,
|
|
||||||
additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
|
|
||||||
deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
|
|
||||||
before,
|
|
||||||
after,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function patch(seed: number, length: number) {
|
|
||||||
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function code(seed: number, lines: number, width = 32) {
|
|
||||||
return Array.from(
|
|
||||||
{ length: lines },
|
|
||||||
(_, index) => `export const value${index} = "${lorem(seed + index, width)}"`,
|
|
||||||
).join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
function turn(index: number): Message[] {
|
|
||||||
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
|
||||||
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
|
||||||
const parts = [
|
|
||||||
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
|
|
||||||
...(index % 3 === 0
|
|
||||||
? [
|
|
||||||
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
|
|
||||||
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
|
|
||||||
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
|
|
||||||
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
textPart(index, 2, 160 + (index % 6) * 90),
|
|
||||||
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
|
|
||||||
...(index % 6 === 0
|
|
||||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
|
||||||
: []),
|
|
||||||
...(index % 8 === 0
|
|
||||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
|
||||||
: []),
|
|
||||||
...(index % 7 === 0
|
|
||||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
|
||||||
: []),
|
|
||||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
|
||||||
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
|
||||||
...(index % 13 === 0
|
|
||||||
? [
|
|
||||||
toolPart(
|
|
||||||
index,
|
|
||||||
11,
|
|
||||||
"question",
|
|
||||||
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
|
|
||||||
120,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(index % 17 === 0
|
|
||||||
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
|
||||||
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
|
||||||
userMessage(sourceID, index + 1000, 120),
|
|
||||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
|
||||||
]).flat()
|
|
||||||
|
|
||||||
function renderable(part: MessagePart) {
|
|
||||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
|
||||||
if (part.type === "text") return !!part.text.trim()
|
|
||||||
if (part.type === "reasoning") return !!part.text.trim()
|
|
||||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
|
||||||
}
|
|
||||||
|
|
||||||
function orderedParts(message: Message) {
|
|
||||||
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fixture = {
|
|
||||||
directory,
|
|
||||||
project: {
|
|
||||||
id: projectID,
|
|
||||||
worktree: directory,
|
|
||||||
vcs: "git",
|
|
||||||
name: "smoke-project",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
sandboxes: [],
|
|
||||||
},
|
|
||||||
provider: {
|
|
||||||
all: [
|
|
||||||
{
|
|
||||||
id: "opencode",
|
|
||||||
name: "OpenCode",
|
|
||||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
connected: ["opencode"],
|
|
||||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
|
||||||
},
|
|
||||||
sessions: [
|
|
||||||
{
|
|
||||||
id: sourceID,
|
|
||||||
slug: "source",
|
|
||||||
projectID,
|
|
||||||
directory,
|
|
||||||
title: "Uncommitted changes inquiry",
|
|
||||||
version: "dev",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: targetID,
|
|
||||||
slug: "target",
|
|
||||||
projectID,
|
|
||||||
directory,
|
|
||||||
title: "Example Game: sample jump movement & sample physics analysis",
|
|
||||||
version: "dev",
|
|
||||||
time: { created: 1700000001000, updated: 1700000001000 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sourceID,
|
|
||||||
targetID,
|
|
||||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
|
|
||||||
expected: {
|
|
||||||
sourceTitle: "Uncommitted changes inquiry",
|
|
||||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
|
||||||
sourceMessageIDs: sourceMessages
|
|
||||||
.filter((message) => message.info.role === "user")
|
|
||||||
.map((message) => message.info.id),
|
|
||||||
targetMessageIDs: targetMessages
|
|
||||||
.filter((message) => message.info.role === "user")
|
|
||||||
.map((message) => message.info.id),
|
|
||||||
targetPartIDs: targetMessages.flatMap((message) =>
|
|
||||||
orderedParts(message)
|
|
||||||
.filter(renderable)
|
|
||||||
.map((part) => part.id),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
|
||||||
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
|
||||||
const end = before
|
|
||||||
? Math.max(
|
|
||||||
0,
|
|
||||||
messages.findIndex((message) => message.info.id === before),
|
|
||||||
)
|
|
||||||
: messages.length
|
|
||||||
const start = Math.max(0, end - limit)
|
|
||||||
return {
|
|
||||||
items: messages.slice(start, end),
|
|
||||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
|
||||||
import { fixture } from "./session-timeline-stress.fixture"
|
|
||||||
|
|
||||||
export async function installTimelineSettings(page: Page) {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"settings.v3",
|
|
||||||
JSON.stringify({
|
|
||||||
general: {
|
|
||||||
editToolPartsExpanded: true,
|
|
||||||
shellToolPartsExpanded: true,
|
|
||||||
showReasoningSummaries: true,
|
|
||||||
showSessionProgressBar: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mockStressTimeline(page: Page) {
|
|
||||||
return mockOpenCodeServer(page, {
|
|
||||||
sessions: fixture.sessions,
|
|
||||||
provider: fixture.provider,
|
|
||||||
directory: fixture.directory,
|
|
||||||
project: fixture.project,
|
|
||||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installStressSessionTabs(page: Page) {
|
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
|
||||||
await page.addInitScript(
|
|
||||||
({ directory, sourceID, targetID, dirBase64, server }) => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:server",
|
|
||||||
JSON.stringify({
|
|
||||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
|
||||||
lastProject: { local: directory },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:tabs",
|
|
||||||
JSON.stringify(
|
|
||||||
[sourceID, targetID].map((sessionId) => ({
|
|
||||||
type: "session",
|
|
||||||
server,
|
|
||||||
dirBase64,
|
|
||||||
sessionId,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
directory: fixture.directory,
|
|
||||||
sourceID: fixture.sourceID,
|
|
||||||
targetID: fixture.targetID,
|
|
||||||
dirBase64: base64Encode(fixture.directory),
|
|
||||||
server,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stressSessionHref(sessionID: string) {
|
|
||||||
return `/${base64Encode(fixture.directory)}/session/${sessionID}`
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import { mkdtemp, rm } from "node:fs/promises"
|
|
||||||
import path from "node:path"
|
|
||||||
import os from "node:os"
|
|
||||||
import { prepareChromeTrace } from "../chrome-trace"
|
|
||||||
|
|
||||||
test("creates the configured trace directory", async () => {
|
|
||||||
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-trace-"))
|
|
||||||
try {
|
|
||||||
const file = await prepareChromeTrace(path.join(root, "nested", "traces"), "session/tab", false, "test")
|
|
||||||
expect(file).toEndWith("-session-tab-458ed9e3-test.json")
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import { compressCachedRepaintTrace, layoutShiftSample } from "../timeline/session-tab-repaint-probe"
|
|
||||||
|
|
||||||
test("compresses repeated repaint states without losing frame samples", () => {
|
|
||||||
const state = {
|
|
||||||
root: 1,
|
|
||||||
scrollTop: 10,
|
|
||||||
scrollHeight: 20,
|
|
||||||
bottomErrorPx: 0,
|
|
||||||
last: true,
|
|
||||||
rows: [{ key: "row", node: 2, top: 0, bottom: 10 }],
|
|
||||||
mounted: 1,
|
|
||||||
center: "content",
|
|
||||||
}
|
|
||||||
const trace = {
|
|
||||||
timeOriginEpochMs: 1_000,
|
|
||||||
startedAtPerformanceMs: 100,
|
|
||||||
samples: [
|
|
||||||
{ observedAtMs: 16, ...state, destination: ["target"], source: [] },
|
|
||||||
{ observedAtMs: 32, ...state, destination: ["target"], source: [] },
|
|
||||||
{ observedAtMs: 48, ...state, scrollTop: 11, destination: ["target"], source: [] },
|
|
||||||
],
|
|
||||||
mutations: [{ observedAtMs: 20, changed: [{ type: "add", node: 2 }] }],
|
|
||||||
shifts: [{ occurredAtMs: 24, value: 0.1 }],
|
|
||||||
windowMs: 1_000,
|
|
||||||
running: false,
|
|
||||||
stop() {},
|
|
||||||
}
|
|
||||||
const compressed = compressCachedRepaintTrace(trace)
|
|
||||||
const samples = compressed.samples.flatMap((group) =>
|
|
||||||
group.observedAtMs.map((observedAtMs) => ({ observedAtMs, ...group.state })),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(samples).toEqual(trace.samples)
|
|
||||||
expect(compressed.mutations).toEqual(trace.mutations)
|
|
||||||
expect(compressed.shifts).toEqual(trace.shifts)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("records layout shifts at occurrence time within the probe window", () => {
|
|
||||||
expect(layoutShiftSample({ startTime: 99, value: 0.1 }, 100)).toBeUndefined()
|
|
||||||
expect(layoutShiftSample({ startTime: 124, value: 0.2 }, 100)).toEqual({ occurredAtMs: 24, value: 0.2 })
|
|
||||||
})
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import { classifySessionSwitch } from "../timeline/session-tab-switch-metrics"
|
|
||||||
|
|
||||||
test("counts source and blank samples before the destination is observed", () => {
|
|
||||||
const result = classifySessionSwitch([
|
|
||||||
{ observedAtMs: 16, destination: [], source: ["source"], hasVisibleRows: true, last: false },
|
|
||||||
{ observedAtMs: 32, destination: [], source: [], hasVisibleRows: false, last: false },
|
|
||||||
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
|
||||||
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
|
||||||
{ observedAtMs: 80, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(result.blankSamples).toBe(1)
|
|
||||||
expect(result.sourceSamples).toBe(1)
|
|
||||||
expect(result.unknownSamples).toBe(0)
|
|
||||||
expect(result.firstDestinationObservedMs).toBe(48)
|
|
||||||
expect(result.stableObservedMs).toBe(80)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not classify mixed source and destination content as correct", () => {
|
|
||||||
const result = classifySessionSwitch([
|
|
||||||
{
|
|
||||||
observedAtMs: 16,
|
|
||||||
destination: ["destination"],
|
|
||||||
source: ["source"],
|
|
||||||
hasVisibleRows: true,
|
|
||||||
last: true,
|
|
||||||
bottomErrorPx: 0,
|
|
||||||
},
|
|
||||||
{ observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
|
||||||
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
|
||||||
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(result.firstCorrectObservedMs).toBe(32)
|
|
||||||
expect(result.stableObservedMs).toBe(64)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("reports missing correctness without throwing", () => {
|
|
||||||
const result = classifySessionSwitch([
|
|
||||||
{
|
|
||||||
observedAtMs: 16,
|
|
||||||
destination: ["destination"],
|
|
||||||
source: ["source"],
|
|
||||||
hasVisibleRows: true,
|
|
||||||
last: true,
|
|
||||||
bottomErrorPx: 0,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(result.firstDestinationObservedMs).toBe(16)
|
|
||||||
expect(result.firstCorrectObservedMs).toBeNull()
|
|
||||||
expect(result.stableObservedMs).toBeNull()
|
|
||||||
})
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import { streamChunk } from "../timeline/session-timeline-benchmark.fixture"
|
|
||||||
import { streamProgress } from "../timeline/session-timeline-stream-probe"
|
|
||||||
|
|
||||||
test("classifies emitted stream markers using the fixture cycle", () => {
|
|
||||||
expect(streamProgress("before stream-17 after stream-18")).toEqual({ index: 18, phase: "boundary" })
|
|
||||||
expect(streamProgress("before stream-18 after stream-19")).toEqual({ index: 19, phase: "stream" })
|
|
||||||
expect(streamProgress("benchmark-complete stream-36")).toEqual({ index: 36, phase: "complete" })
|
|
||||||
expect(streamProgress("no marker")).toEqual({ index: -1, phase: "unknown" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("emits progress markers at fixture boundaries", () => {
|
|
||||||
expect(streamProgress(streamChunk(18, 160))).toEqual({ index: 18, phase: "boundary" })
|
|
||||||
})
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import { layoutShiftValue, removeVisibleRow } from "../timeline/session-timeline-stream-probe"
|
|
||||||
|
|
||||||
test("excludes layout shifts before the probe window and recent input", () => {
|
|
||||||
expect(layoutShiftValue({ startTime: 9, value: 0.1 }, 10)).toBeUndefined()
|
|
||||||
expect(layoutShiftValue({ startTime: 10, value: 0.2, hadRecentInput: true }, 10)).toBeUndefined()
|
|
||||||
expect(layoutShiftValue({ startTime: 11, value: 0.3 }, 10)).toBe(0.3)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("classifies removed rows from their last painted visibility", () => {
|
|
||||||
const row = {}
|
|
||||||
const visible = new Set([row])
|
|
||||||
|
|
||||||
expect(removeVisibleRow(visible, row)).toBe(true)
|
|
||||||
expect(removeVisibleRow(visible, row)).toBe(false)
|
|
||||||
})
|
|
||||||
@@ -147,8 +147,7 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
|
|
||||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||||
await expectAppVisible(wrapper)
|
await expectAppVisible(wrapper)
|
||||||
const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first()
|
await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first())
|
||||||
await expectAppVisible(file)
|
|
||||||
await markDiffProbe(page)
|
await markDiffProbe(page)
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
@@ -160,15 +159,7 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||||
const siblingProbe = await readDiffProbe(page)
|
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
|
||||||
expect(siblingProbe).toEqual({
|
|
||||||
fileMarker: "before",
|
|
||||||
frameMarker: "before",
|
|
||||||
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
|
||||||
rowMarker: "before",
|
|
||||||
shadowRoots: 0,
|
|
||||||
toolMarker: "before",
|
|
||||||
})
|
|
||||||
|
|
||||||
await markDiffProbe(page)
|
await markDiffProbe(page)
|
||||||
events.push({
|
events.push({
|
||||||
@@ -182,73 +173,7 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
|
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
|
||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
)
|
)
|
||||||
expect(await readDiffProbe(page)).toEqual({
|
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
|
||||||
fileMarker: "before",
|
|
||||||
frameMarker: "before",
|
|
||||||
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
|
||||||
rowMarker: "before",
|
|
||||||
shadowRoots: 0,
|
|
||||||
toolMarker: "before",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
|
|
||||||
const events: EventPayload[] = []
|
|
||||||
const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("")
|
|
||||||
const after = [100, 300, 500, 700, 900].reduce(
|
|
||||||
(result, index) =>
|
|
||||||
result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`),
|
|
||||||
lines,
|
|
||||||
)
|
|
||||||
const part = {
|
|
||||||
...editPart,
|
|
||||||
state: {
|
|
||||||
...editPart.state,
|
|
||||||
metadata: {
|
|
||||||
...editPart.state.metadata,
|
|
||||||
filediff: {
|
|
||||||
file: "src/regression.ts",
|
|
||||||
additions: 1,
|
|
||||||
deletions: 1,
|
|
||||||
before: lines,
|
|
||||||
after,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
|
|
||||||
await configurePage(page)
|
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
|
||||||
await expectSessionTitle(page, title)
|
|
||||||
|
|
||||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
|
||||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
|
|
||||||
const diff = wrapper.locator('[data-component="edit-content"]').first()
|
|
||||||
await expectAppVisible(diff)
|
|
||||||
await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500)
|
|
||||||
const samples = await wrapper.evaluate(async (element) => {
|
|
||||||
const root = element.closest<HTMLElement>(".scroll-view__viewport")!
|
|
||||||
element.scrollIntoView({ block: "start" })
|
|
||||||
const result = []
|
|
||||||
for (const offset of [0, 120, 240, 360, 480]) {
|
|
||||||
root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
|
|
||||||
await new Promise(requestAnimationFrame)
|
|
||||||
const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
|
|
||||||
const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
|
|
||||||
result.push({
|
|
||||||
offset,
|
|
||||||
trigger: trigger.getBoundingClientRect().y,
|
|
||||||
diff: diff.getBoundingClientRect().y,
|
|
||||||
bottom: element.getBoundingClientRect().bottom,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
|
|
||||||
expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
|
|
||||||
expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -322,16 +247,10 @@ async function markDiffProbe(page: Page) {
|
|||||||
.evaluate((element) => {
|
.evaluate((element) => {
|
||||||
const tool = element as HTMLElement
|
const tool = element as HTMLElement
|
||||||
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
||||||
const row = tool.closest<HTMLElement>("[data-timeline-key]")
|
|
||||||
const frame = tool.closest<HTMLElement>("[data-timeline-row]")
|
|
||||||
if (!file) throw new Error("missing edit diff file")
|
if (!file) throw new Error("missing edit diff file")
|
||||||
if (!row) throw new Error("missing virtual timeline row")
|
|
||||||
if (!frame) throw new Error("missing timeline row frame")
|
|
||||||
|
|
||||||
tool.dataset.timelineProbe = "before"
|
tool.dataset.timelineProbe = "before"
|
||||||
file.dataset.timelineProbe = "before"
|
file.dataset.timelineProbe = "before"
|
||||||
row.dataset.timelineProbe = "before"
|
|
||||||
frame.dataset.timelineProbe = "before"
|
|
||||||
window.__timelineDiffProbe.reset()
|
window.__timelineDiffProbe.reset()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -343,15 +262,10 @@ async function readDiffProbe(page: Page) {
|
|||||||
.evaluate((element) => {
|
.evaluate((element) => {
|
||||||
const tool = element as HTMLElement
|
const tool = element as HTMLElement
|
||||||
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
||||||
const row = tool.closest<HTMLElement>("[data-timeline-key]")
|
|
||||||
const frame = tool.closest<HTMLElement>("[data-timeline-row]")
|
|
||||||
return {
|
return {
|
||||||
fileMarker: file?.dataset.timelineProbe,
|
fileMarker: file?.dataset.timelineProbe,
|
||||||
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
|
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
|
||||||
toolMarker: tool.dataset.timelineProbe,
|
toolMarker: tool.dataset.timelineProbe,
|
||||||
rowMarker: row?.dataset.timelineProbe,
|
|
||||||
rowKey: row?.dataset.timelineKey,
|
|
||||||
frameMarker: frame?.dataset.timelineProbe,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -386,15 +300,14 @@ function readExpanded(element: Element) {
|
|||||||
return !!content && content.getBoundingClientRect().height > 0
|
return !!content && content.getBoundingClientRect().height > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) {
|
async function mockServer(page: Page, events: EventPayload[]) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: project(),
|
project: project(),
|
||||||
provider: provider(),
|
provider: provider(),
|
||||||
sessions: [session()],
|
sessions: [session()],
|
||||||
pageMessages: () => ({ items: messages }),
|
pageMessages: () => ({ items: [userMessage, assistantMessage] }),
|
||||||
events: () => events.splice(0, 1),
|
events: () => events.splice(0),
|
||||||
eventRetry: 16,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ test.describe("regression: session timeline context group resize", () => {
|
|||||||
const samples = await sampleExpansion(page)
|
const samples = await sampleExpansion(page)
|
||||||
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
|
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
|
||||||
|
|
||||||
|
console.log("context resize samples", JSON.stringify(samples, null, 2))
|
||||||
|
|
||||||
expect(samples[0]?.overlap).toBe(0)
|
expect(samples[0]?.overlap).toBe(0)
|
||||||
expect(visibleOverlap).toEqual([])
|
expect(visibleOverlap).toEqual([])
|
||||||
expect(samples.at(-1)?.expanded).toBe("true")
|
expect(samples.at(-1)?.expanded).toBe("true")
|
||||||
@@ -113,15 +115,13 @@ async function sampleExpansion(page: Page) {
|
|||||||
|
|
||||||
let frame = 1
|
let frame = 1
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
setTimeout(() => {
|
capture(frame, "raf")
|
||||||
capture(frame, "painted")
|
frame += 1
|
||||||
frame += 1
|
if (frame > 8) {
|
||||||
if (frame > 8) {
|
resolve(samples)
|
||||||
resolve(samples)
|
return
|
||||||
return
|
}
|
||||||
}
|
requestAnimationFrame(tick)
|
||||||
requestAnimationFrame(tick)
|
|
||||||
}, 0)
|
|
||||||
}
|
}
|
||||||
requestAnimationFrame(tick)
|
requestAnimationFrame(tick)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -30,284 +30,6 @@ type SmokeWindow = Window & {
|
|||||||
test.describe("smoke: session timeline", () => {
|
test.describe("smoke: session timeline", () => {
|
||||||
test.setTimeout(240_000)
|
test.setTimeout(240_000)
|
||||||
|
|
||||||
test("keeps the visible message fixed while prepending history", async ({ page }) => {
|
|
||||||
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
sessions: fixture.sessions,
|
|
||||||
provider: fixture.provider,
|
|
||||||
directory: fixture.directory,
|
|
||||||
project: fixture.project,
|
|
||||||
pageMessages,
|
|
||||||
messageDelay: 3_000,
|
|
||||||
onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }),
|
|
||||||
})
|
|
||||||
await configureSmokePage(page, fixture.directory)
|
|
||||||
|
|
||||||
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
|
||||||
await waitForTimelineStable(page)
|
|
||||||
const scroller = timelineScroller(page)
|
|
||||||
await pointAtTimeline(page)
|
|
||||||
const deadline = Date.now() + 120_000
|
|
||||||
while (!requests.some((request) => request.before && request.phase === "start")) {
|
|
||||||
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
|
|
||||||
await page.mouse.wheel(0, -240)
|
|
||||||
await page.waitForTimeout(20)
|
|
||||||
}
|
|
||||||
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
|
|
||||||
for (let index = 0; index < 12; index++) {
|
|
||||||
await page.mouse.wheel(0, -120)
|
|
||||||
await page.waitForTimeout(20)
|
|
||||||
}
|
|
||||||
const keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"]
|
|
||||||
const positions = () =>
|
|
||||||
scroller.evaluate((element, keys) => {
|
|
||||||
const top = element.getBoundingClientRect().top
|
|
||||||
return Object.fromEntries(
|
|
||||||
keys.map((key) => {
|
|
||||||
const row = element.querySelector<HTMLElement>(`[data-timeline-part-id="${key}"]`)
|
|
||||||
if (!row) throw new Error(`Missing stable timeline key: ${key}`)
|
|
||||||
return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}, keys)
|
|
||||||
const before = await positions()
|
|
||||||
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
|
|
||||||
|
|
||||||
await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true)
|
|
||||||
await waitForTimelineStable(page)
|
|
||||||
await expect.poll(positions).toEqual(before)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves the timeline gap above the composer", async ({ page }) => {
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
sessions: fixture.sessions,
|
|
||||||
provider: fixture.provider,
|
|
||||||
directory: fixture.directory,
|
|
||||||
project: fixture.project,
|
|
||||||
pageMessages,
|
|
||||||
})
|
|
||||||
await configureSmokePage(page, fixture.directory)
|
|
||||||
|
|
||||||
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
|
||||||
await waitForTimelineStable(page)
|
|
||||||
const scroller = timelineScroller(page)
|
|
||||||
await scroller.evaluate((element) => {
|
|
||||||
element.scrollTop = element.scrollHeight
|
|
||||||
})
|
|
||||||
await waitForTimelineStable(page)
|
|
||||||
|
|
||||||
const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]')
|
|
||||||
await expect(spacer).toBeVisible()
|
|
||||||
expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64)
|
|
||||||
await expect
|
|
||||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("paints cached session tabs at the latest message", async ({ page }) => {
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
sessions: fixture.sessions,
|
|
||||||
provider: fixture.provider,
|
|
||||||
directory: fixture.directory,
|
|
||||||
project: fixture.project,
|
|
||||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
|
||||||
})
|
|
||||||
await configureSmokePage(page, fixture.directory)
|
|
||||||
await page.addInitScript(
|
|
||||||
({ dirBase64, sourceID, targetID }) => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:tabs",
|
|
||||||
JSON.stringify(
|
|
||||||
[sourceID, targetID].map((sessionId) => ({
|
|
||||||
type: "session",
|
|
||||||
server: "http://127.0.0.1:4096",
|
|
||||||
dirBase64,
|
|
||||||
sessionId,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
|
||||||
)
|
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
|
|
||||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
|
||||||
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
|
||||||
|
|
||||||
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
|
||||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
|
||||||
await page.evaluate(
|
|
||||||
({ destination, last }) => {
|
|
||||||
const ids = new Set(destination)
|
|
||||||
const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = []
|
|
||||||
const firstPaintNodes = new WeakSet<Node>()
|
|
||||||
let firstPaint = false
|
|
||||||
let removedFirstPaintNodes = 0
|
|
||||||
let running = true
|
|
||||||
new MutationObserver((records) => {
|
|
||||||
if (!firstPaint || !running) return
|
|
||||||
records.forEach((record) =>
|
|
||||||
record.removedNodes.forEach((node) => {
|
|
||||||
if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1
|
|
||||||
if (!(node instanceof Element)) return
|
|
||||||
node.querySelectorAll("*").forEach((element) => {
|
|
||||||
if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}).observe(document.documentElement, { childList: true, subtree: true })
|
|
||||||
const sample = () => {
|
|
||||||
if (!running) return
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!running) return
|
|
||||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
|
||||||
element.querySelector("[data-timeline-row]"),
|
|
||||||
)
|
|
||||||
if (root) {
|
|
||||||
const view = root.getBoundingClientRect()
|
|
||||||
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
|
||||||
.filter((element) => {
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
return rect.bottom > view.top && rect.top < view.bottom
|
|
||||||
})
|
|
||||||
.map((element) => element.dataset.messageId!)
|
|
||||||
.filter((id) => ids.has(id))
|
|
||||||
const bottom = root
|
|
||||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
|
||||||
?.getBoundingClientRect()
|
|
||||||
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
|
||||||
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
|
|
||||||
firstPaint = true
|
|
||||||
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
|
|
||||||
const rect = row.getBoundingClientRect()
|
|
||||||
if (rect.bottom <= view.top || rect.top >= view.bottom) return
|
|
||||||
firstPaintNodes.add(row)
|
|
||||||
row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
requestAnimationFrame(sample)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
;(
|
|
||||||
window as Window & {
|
|
||||||
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
|
|
||||||
}
|
|
||||||
).__sessionTabPaint = {
|
|
||||||
samples,
|
|
||||||
removed: () => removedFirstPaintNodes,
|
|
||||||
stop: () => {
|
|
||||||
running = false
|
|
||||||
},
|
|
||||||
}
|
|
||||||
requestAnimationFrame(sample)
|
|
||||||
},
|
|
||||||
{ destination, last },
|
|
||||||
)
|
|
||||||
|
|
||||||
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
|
||||||
await page.waitForFunction(() =>
|
|
||||||
(
|
|
||||||
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
|
|
||||||
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
|
|
||||||
)
|
|
||||||
await page.waitForTimeout(200)
|
|
||||||
const first = await page.evaluate(() => {
|
|
||||||
const probe = (
|
|
||||||
window as Window & {
|
|
||||||
__sessionTabPaint?: {
|
|
||||||
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
|
|
||||||
removed: () => number
|
|
||||||
stop: () => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
).__sessionTabPaint!
|
|
||||||
probe.stop()
|
|
||||||
return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() }
|
|
||||||
})
|
|
||||||
expect(first.first?.last).toBe(true)
|
|
||||||
expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
|
||||||
expect(first.removed).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("paints a cold session tab at the latest message", async ({ page }) => {
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
sessions: fixture.sessions,
|
|
||||||
provider: fixture.provider,
|
|
||||||
directory: fixture.directory,
|
|
||||||
project: fixture.project,
|
|
||||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
|
||||||
})
|
|
||||||
await configureSmokePage(page, fixture.directory)
|
|
||||||
await page.addInitScript(
|
|
||||||
({ dirBase64, sourceID, targetID }) => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:tabs",
|
|
||||||
JSON.stringify(
|
|
||||||
[sourceID, targetID].map((sessionId) => ({
|
|
||||||
type: "session",
|
|
||||||
server: "http://127.0.0.1:4096",
|
|
||||||
dirBase64,
|
|
||||||
sessionId,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
|
||||||
)
|
|
||||||
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
|
|
||||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
|
||||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
|
||||||
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
|
||||||
await page.evaluate(
|
|
||||||
({ destination, last }) => {
|
|
||||||
const ids = new Set(destination)
|
|
||||||
const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = []
|
|
||||||
const sample = () => {
|
|
||||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
|
||||||
element.querySelector("[data-timeline-row]"),
|
|
||||||
)
|
|
||||||
if (root) {
|
|
||||||
const view = root.getBoundingClientRect()
|
|
||||||
const spacer = root
|
|
||||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
|
||||||
?.getBoundingClientRect()
|
|
||||||
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].filter((element) => {
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
return rect.bottom > view.top && rect.top < view.bottom
|
|
||||||
})
|
|
||||||
samples.push({
|
|
||||||
destination: messages.some((element) => ids.has(element.dataset.messageId!)),
|
|
||||||
last: messages.some((element) => element.dataset.messageId === last),
|
|
||||||
bottomError: spacer ? spacer.bottom - view.bottom : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
|
||||||
}
|
|
||||||
;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples
|
|
||||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
|
||||||
},
|
|
||||||
{ destination, last },
|
|
||||||
)
|
|
||||||
|
|
||||||
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
|
||||||
await page.waitForFunction(() =>
|
|
||||||
(window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some(
|
|
||||||
(sample) => sample.destination,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const result = await page.evaluate(() => {
|
|
||||||
const samples = (
|
|
||||||
window as Window & {
|
|
||||||
__coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }>
|
|
||||||
}
|
|
||||||
).__coldTabSamples!
|
|
||||||
return samples.find((sample) => sample.destination)!
|
|
||||||
})
|
|
||||||
expect(result.last).toBe(true)
|
|
||||||
expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
||||||
const errors = trackPageErrors(page)
|
const errors = trackPageErrors(page)
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
@@ -705,14 +427,6 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
|
|||||||
await expectSessionTitle(page, expectedTitle)
|
await expectSessionTitle(page, expectedTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
|
|
||||||
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}`
|
|
||||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
await expectSessionTitle(page, title)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectSessionReady(page: Page) {
|
async function expectSessionReady(page: Page) {
|
||||||
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ export interface MockServerConfig {
|
|||||||
project: unknown
|
project: unknown
|
||||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||||
messageDelay?: number
|
|
||||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
|
||||||
events?: () => unknown[]
|
events?: () => unknown[]
|
||||||
eventRetry?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
@@ -44,13 +41,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||||
const appPort = new URL(
|
if (url.port !== targetPort) return route.fallback()
|
||||||
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
|
|
||||||
).port
|
|
||||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
|
||||||
|
|
||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
if (path === "/global/event" || path === "/event") return sse(route, config.events?.())
|
||||||
if (path === "/global/health") return json(route, { healthy: true })
|
if (path === "/global/health") return json(route, { healthy: true })
|
||||||
if (emptyObject.has(path)) return json(route, {})
|
if (emptyObject.has(path)) return json(route, {})
|
||||||
if (emptyList.has(path)) return json(route, [])
|
if (emptyList.has(path)) return json(route, [])
|
||||||
@@ -66,17 +60,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
|
|
||||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||||
if (messagesMatch) {
|
if (messagesMatch) {
|
||||||
const before = url.searchParams.get("before") ?? undefined
|
|
||||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
|
||||||
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
|
||||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||||
|
const before = url.searchParams.get("before") ?? undefined
|
||||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
|
||||||
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
|
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
|
return json(route, {})
|
||||||
return route.fallback()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,10 +83,10 @@ function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sse(route: Route, events?: unknown[], retry?: number) {
|
function sse(route: Route, events?: unknown[]) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "text/event-stream",
|
contentType: "text/event-stream",
|
||||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.9",
|
"version": "1.17.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -17,15 +17,13 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit && bun run test:virtualizer",
|
"test": "bun run test:unit",
|
||||||
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
||||||
"test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts",
|
|
||||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:local": "playwright test",
|
"test:e2e:local": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
"test:e2e:report": "playwright show-report e2e/playwright-report"
|
||||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
|
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -48,7 +46,6 @@
|
|||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@pierre/trees": "1.0.0-beta.4",
|
|
||||||
"@sentry/solid": "catalog:",
|
"@sentry/solid": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
@@ -66,7 +63,6 @@
|
|||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@tanstack/solid-query": "5.91.4",
|
"@tanstack/solid-query": "5.91.4",
|
||||||
"@tanstack/solid-virtual": "catalog:",
|
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -79,6 +75,7 @@
|
|||||||
"shiki": "catalog:",
|
"shiki": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
"tailwindcss": "catalog:"
|
"tailwindcss": "catalog:",
|
||||||
|
"virtua": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const reuse = !process.env.CI
|
|||||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
|
|
||||||
outputDir: "./e2e/test-results",
|
outputDir: "./e2e/test-results",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
expect: {
|
expect: {
|
||||||
|
|||||||
+48
-91
@@ -69,8 +69,8 @@ const SessionRoute = Object.assign(
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!settings.general.newLayoutDesigns()) return
|
if (!settings.general.newLayoutDesigns()) return
|
||||||
if (params.id || search.draftId) return
|
if (params.id || search.draftId) return
|
||||||
if (!tabs.ready() || !sdk().directory) return
|
if (!tabs.ready() || !sdk.directory) return
|
||||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
tabs.newDraft({ server: server.key, directory: sdk.directory }, search.prompt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,45 +82,6 @@ const SessionRoute = Object.assign(
|
|||||||
{ preload: Session.preload },
|
{ preload: Session.preload },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
|
||||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
|
||||||
// Notification/Models + the visual Layout) for that server.
|
|
||||||
function SelectedServerLayout(props: ParentProps) {
|
|
||||||
return (
|
|
||||||
<ServerKey>
|
|
||||||
<ServerSDKProvider>
|
|
||||||
<ServerSyncProvider>
|
|
||||||
<ServerScopedShell>{props.children}</ServerScopedShell>
|
|
||||||
</ServerSyncProvider>
|
|
||||||
</ServerSDKProvider>
|
|
||||||
</ServerKey>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wraps /new-session. It resolves the draft's target server and provides the
|
|
||||||
// server-scoped shell for that server — without ServerKey, so the page never depends
|
|
||||||
// on the globally "selected" server.
|
|
||||||
function DraftServerLayout(props: ParentProps) {
|
|
||||||
const server = useServer()
|
|
||||||
const tabs = useTabs()
|
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
|
||||||
const conn = createMemo(() => {
|
|
||||||
const id = search.draftId
|
|
||||||
if (!id) return undefined
|
|
||||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
|
|
||||||
if (!draft) return undefined
|
|
||||||
return server.list.find((c) => ServerConnection.key(c) === draft.server)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ServerSDKProvider server={conn}>
|
|
||||||
<ServerSyncProvider server={conn}>
|
|
||||||
<ServerScopedShell>{props.children}</ServerScopedShell>
|
|
||||||
</ServerSyncProvider>
|
|
||||||
</ServerSDKProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraftRoute() {
|
function DraftRoute() {
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
@@ -134,15 +95,19 @@ function DraftRoute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ResolvedDraftRoute(props: { draftID: string }) {
|
function ResolvedDraftRoute(props: { draftID: string }) {
|
||||||
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const draft = createMemo(() =>
|
const draft = createMemo(() =>
|
||||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
|
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const current = draft()
|
||||||
|
if (current && current.server !== server.key) server.setActive(current.server)
|
||||||
|
})
|
||||||
|
|
||||||
// Key on the directory so retargeting the draft's project re-instantiates the
|
// Key on the directory so retargeting the draft's project re-instantiates the
|
||||||
// directory-scoped providers while keeping the same draft id. The draft's target
|
// SDK/data providers for the new directory while keeping the same draft id.
|
||||||
// server is provided by DraftServerLayout, so changing only the server updates the
|
|
||||||
// SDK/sync hooks without remounting the composer.
|
|
||||||
const directory = () => draft()?.directory
|
const directory = () => draft()?.directory
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -206,36 +171,27 @@ function BodyDesignClass() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-agnostic providers shared across every route. These live in the shared
|
function AppShellProviders(props: ParentProps) {
|
||||||
// shell (router root) so they stay mounted regardless of the active server/route.
|
|
||||||
function SharedProviders(props: ParentProps) {
|
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<BodyDesignClass />
|
<BodyDesignClass />
|
||||||
<CommandProvider>
|
<PermissionProvider>
|
||||||
<HighlightsProvider>{props.children}</HighlightsProvider>
|
<LayoutProvider>
|
||||||
</CommandProvider>
|
<NotificationProvider>
|
||||||
|
<ModelsProvider>
|
||||||
|
<CommandProvider>
|
||||||
|
<HighlightsProvider>
|
||||||
|
<Layout>{props.children}</Layout>
|
||||||
|
</HighlightsProvider>
|
||||||
|
</CommandProvider>
|
||||||
|
</ModelsProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</PermissionProvider>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
|
|
||||||
// each per-route server layout so they resolve to that route's server (selected vs
|
|
||||||
// draft). The Layout remounts when crossing between those groups.
|
|
||||||
function ServerScopedShell(props: ParentProps) {
|
|
||||||
return (
|
|
||||||
<PermissionProvider>
|
|
||||||
<LayoutProvider>
|
|
||||||
<NotificationProvider>
|
|
||||||
<ModelsProvider>
|
|
||||||
<Layout>{props.children}</Layout>
|
|
||||||
</ModelsProvider>
|
|
||||||
</NotificationProvider>
|
|
||||||
</LayoutProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionProviders(props: ParentProps) {
|
function SessionProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<TerminalProvider>
|
<TerminalProvider>
|
||||||
@@ -260,6 +216,17 @@ function DraftProviders(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||||
|
return (
|
||||||
|
<AppShellProviders>
|
||||||
|
{/*<Suspense fallback={<Loading />}>*/}
|
||||||
|
{props.appChildren}
|
||||||
|
{props.children}
|
||||||
|
{/*</Suspense>*/}
|
||||||
|
</AppShellProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
@@ -418,20 +385,6 @@ export function AppInterface(props: {
|
|||||||
router?: Component<BaseRouterProps>
|
router?: Component<BaseRouterProps>
|
||||||
disableHealthCheck?: boolean
|
disableHealthCheck?: boolean
|
||||||
}) {
|
}) {
|
||||||
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
|
|
||||||
// Command/Highlights) and stays mounted across every route. The server-scoped
|
|
||||||
// providers and the visual Layout live in the per-route layouts below, so they
|
|
||||||
// resolve to that route's server (selected for most routes, the draft's server for
|
|
||||||
// /new-session). appChildren is server-agnostic, so it renders here once.
|
|
||||||
const ServerShell = (shellProps: ParentProps) => (
|
|
||||||
<QueryProvider>
|
|
||||||
<SharedProviders>
|
|
||||||
{props.children}
|
|
||||||
{shellProps.children}
|
|
||||||
</SharedProviders>
|
|
||||||
</QueryProvider>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerProvider
|
<ServerProvider
|
||||||
defaultServer={props.defaultServer}
|
defaultServer={props.defaultServer}
|
||||||
@@ -444,19 +397,23 @@ export function AppInterface(props: {
|
|||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<ServerShell>{routerProps.children}</ServerShell>
|
<ServerKey>
|
||||||
|
<QueryProvider>
|
||||||
|
<ServerSDKProvider>
|
||||||
|
<ServerSyncProvider>
|
||||||
|
<RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
|
||||||
|
</ServerSyncProvider>
|
||||||
|
</ServerSDKProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
</ServerKey>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Route component={SelectedServerLayout}>
|
<Route path="/" component={HomeRoute} />
|
||||||
<Route path="/" component={HomeRoute} />
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
<Route component={DraftServerLayout}>
|
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Dynamic>
|
</Dynamic>
|
||||||
</ConnectionGate>
|
</ConnectionGate>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const provider = createMemo(
|
const provider = createMemo(
|
||||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
|
||||||
)
|
)
|
||||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||||
{
|
{
|
||||||
@@ -52,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
const [auth] = createResource(
|
const [auth] = createResource(
|
||||||
() => props.provider,
|
() => props.provider,
|
||||||
async () => {
|
async () => {
|
||||||
const cached = serverSync().data.provider_auth[props.provider]
|
const cached = serverSync.data.provider_auth[props.provider]
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
const res = await serverSDK().client.provider.auth()
|
const res = await serverSDK.client.provider.auth()
|
||||||
if (!alive.value) return fallback()
|
if (!alive.value) return fallback()
|
||||||
serverSync().set("provider_auth", res.data ?? {})
|
serverSync.set("provider_auth", res.data ?? {})
|
||||||
return res.data?.[props.provider] ?? fallback()
|
return res.data?.[props.provider] ?? fallback()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
|
||||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||||
@@ -158,8 +158,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await serverSDK()
|
await serverSDK.client.provider.oauth
|
||||||
.client.provider.oauth.authorize(
|
.authorize(
|
||||||
{
|
{
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
method: index,
|
method: index,
|
||||||
@@ -331,7 +331,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
await serverSDK().client.global.dispose()
|
await serverSDK.client.global.dispose()
|
||||||
dialog.close()
|
dialog.close()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
@@ -409,7 +409,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
await serverSDK().client.auth.set({
|
await serverSDK.client.auth.set({
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
@@ -480,8 +480,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
const result = await serverSDK()
|
const result = await serverSDK.client.provider.oauth
|
||||||
.client.provider.oauth.callback({
|
.callback({
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
method: store.methodIndex,
|
method: store.methodIndex,
|
||||||
code,
|
code,
|
||||||
@@ -533,8 +533,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const result = await serverSDK()
|
const result = await serverSDK.client.provider.oauth
|
||||||
.client.provider.oauth.callback({
|
.callback({
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
method: store.methodIndex,
|
method: store.methodIndex,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
const output = validateCustomProvider({
|
const output = validateCustomProvider({
|
||||||
form,
|
form,
|
||||||
t: language.t,
|
t: language.t,
|
||||||
disabledProviders: serverSync().data.config.disabled_providers ?? [],
|
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
||||||
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
|
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setForm("err", output.err)
|
setForm("err", output.err)
|
||||||
@@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
const disabledProviders = serverSync.data.config.disabled_providers ?? []
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
if (result.key) {
|
if (result.key) {
|
||||||
await serverSDK().client.auth.set({
|
await serverSDK.client.auth.set({
|
||||||
providerID: result.providerID,
|
providerID: result.providerID,
|
||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await serverSync().updateConfig({
|
await serverSync.updateConfig({
|
||||||
provider: { [result.providerID]: result.config },
|
provider: { [result.providerID]: result.config },
|
||||||
disabled_providers: nextDisabled,
|
disabled_providers: nextDisabled,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ export const DialogFork: Component = () => {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return []
|
if (!sessionID) return []
|
||||||
|
|
||||||
const msgs = sync().data.message[sessionID] ?? []
|
const msgs = sync.data.message[sessionID] ?? []
|
||||||
const result: ForkableMessage[] = []
|
const result: ForkableMessage[] = []
|
||||||
|
|
||||||
for (const message of msgs) {
|
for (const message of msgs) {
|
||||||
if (message.role !== "user") continue
|
if (message.role !== "user") continue
|
||||||
|
|
||||||
const parts = sync().data.part[message.id] ?? []
|
const parts = sync.data.part[message.id] ?? []
|
||||||
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
||||||
if (!textPart) continue
|
if (!textPart) continue
|
||||||
|
|
||||||
@@ -61,15 +61,15 @@ export const DialogFork: Component = () => {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const parts = sync().data.part[item.id] ?? []
|
const parts = sync.data.part[item.id] ?? []
|
||||||
const restored = extractPromptFromParts(parts, {
|
const restored = extractPromptFromParts(parts, {
|
||||||
directory: sdk().directory,
|
directory: sdk.directory,
|
||||||
attachmentName: language.t("common.attachment"),
|
attachmentName: language.t("common.attachment"),
|
||||||
})
|
})
|
||||||
const dir = base64Encode(sdk().directory)
|
const dir = base64Encode(sdk.directory)
|
||||||
|
|
||||||
sdk()
|
sdk.client.session
|
||||||
.client.session.fork({ sessionID, messageID: item.id })
|
.fork({ sessionID, messageID: item.id })
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
if (!forked.data) {
|
if (!forked.data) {
|
||||||
showToast({ title: language.t("common.requestFailed") })
|
showToast({ title: language.t("common.requestFailed") })
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
.directory-picker-v2-body {
|
|
||||||
display: flex;
|
|
||||||
min-height: 0;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 2px 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.directory-picker-v2-path {
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.directory-picker-v2-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.directory-picker-v2-suggestions {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 20;
|
|
||||||
top: 36px;
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 4px;
|
|
||||||
border: 1px solid var(--v2-border-border-base);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--v2-background-bg-layer-02);
|
|
||||||
box-shadow: var(--v2-elevation-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
.directory-picker-v2-suggestions button {
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--v2-text-text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: left;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.directory-picker-v2-suggestions button:hover,
|
|
||||||
.directory-picker-v2-suggestions button[data-active] {
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
background: var(--v2-overlay-simple-overlay-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.directory-picker-v2-browser {
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
isolation: isolate;
|
|
||||||
min-height: 0;
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid var(--v2-border-border-base);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.directory-picker-v2-tree {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
--trees-bg-override: transparent;
|
|
||||||
--trees-fg-override: var(--v2-text-text-base);
|
|
||||||
--trees-fg-muted-override: var(--v2-text-text-muted);
|
|
||||||
--trees-bg-muted-override: transparent;
|
|
||||||
--trees-selected-bg-override: transparent;
|
|
||||||
--trees-selected-fg-override: var(--v2-text-text-base);
|
|
||||||
--trees-selected-focused-border-color-override: transparent;
|
|
||||||
--trees-focus-ring-color-override: transparent;
|
|
||||||
--trees-focus-ring-width-override: 0px;
|
|
||||||
--trees-focus-ring-offset-override: 0px;
|
|
||||||
--trees-border-color-override: var(--v2-border-border-base);
|
|
||||||
--trees-font-family-override: var(--font-family-sans);
|
|
||||||
--trees-font-size-override: 12px;
|
|
||||||
--trees-item-height: 24px;
|
|
||||||
--trees-border-radius-override: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.directory-picker-v2-state {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
inset: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: var(--v2-text-text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.directory-picker-v2-selection {
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--v2-text-text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 16px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
import "@pierre/trees/web-components"
|
|
||||||
import { FileTree } from "@pierre/trees"
|
|
||||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import {
|
|
||||||
absoluteTreePath,
|
|
||||||
activeTreeNavigation,
|
|
||||||
advanceTreePreload,
|
|
||||||
nextSuggestionIndex,
|
|
||||||
nextTreeScrollTop,
|
|
||||||
pickerFileSearchQuery,
|
|
||||||
pickerAbsoluteInput,
|
|
||||||
pickerMode,
|
|
||||||
preloadTreeDirectories,
|
|
||||||
cleanPickerInput,
|
|
||||||
createDirectorySearch,
|
|
||||||
currentPickerSuggestions,
|
|
||||||
displayPickerPath,
|
|
||||||
pickerParent,
|
|
||||||
pickerRoot,
|
|
||||||
} from "./directory-picker-domain"
|
|
||||||
import "./dialog-select-directory-v2.css"
|
|
||||||
|
|
||||||
interface DialogSelectDirectoryV2Props {
|
|
||||||
title?: string
|
|
||||||
multiple?: boolean
|
|
||||||
onSelect: (result: string | string[] | null) => void
|
|
||||||
server: ServerConnection.Any
|
|
||||||
mode?: "directory" | "file"
|
|
||||||
start?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|
||||||
const global = useGlobal()
|
|
||||||
const { sync, sdk } = global.createServerCtx(props.server)
|
|
||||||
const dialog = useDialog()
|
|
||||||
const language = useLanguage()
|
|
||||||
const policy = pickerMode(props.mode ?? "directory", props.start)
|
|
||||||
const action = {
|
|
||||||
file: language.t("dialog.directory.action.selectFile"),
|
|
||||||
directory: language.t("dialog.directory.action.selectFolder"),
|
|
||||||
}
|
|
||||||
const [root, setRoot] = createSignal("")
|
|
||||||
const [input, setInput] = createSignal("")
|
|
||||||
const [selected, setSelected] = createSignal("")
|
|
||||||
const [suggestionsOpen, setSuggestionsOpen] = createSignal(false)
|
|
||||||
const [activeSuggestion, setActiveSuggestion] = createSignal(-1)
|
|
||||||
const [loading, setLoading] = createSignal(false)
|
|
||||||
const [error, setError] = createSignal(false)
|
|
||||||
const [rootValid, setRootValid] = createSignal(false)
|
|
||||||
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
|
|
||||||
const advanced = new Set<string>()
|
|
||||||
let tree: FileTree | undefined
|
|
||||||
let container: HTMLDivElement | undefined
|
|
||||||
let pathArea: HTMLDivElement | undefined
|
|
||||||
let navigation = 0
|
|
||||||
|
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
|
||||||
const [fallbackPath] = createResource(
|
|
||||||
() => (missingBase() ? true : undefined),
|
|
||||||
() =>
|
|
||||||
sdk.client.path
|
|
||||||
.get()
|
|
||||||
.then((result) => result.data)
|
|
||||||
.catch(() => undefined),
|
|
||||||
{ initialValue: undefined },
|
|
||||||
)
|
|
||||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
|
||||||
const start = createMemo(
|
|
||||||
() =>
|
|
||||||
props.start ||
|
|
||||||
sync.data.path.home ||
|
|
||||||
sync.data.path.directory ||
|
|
||||||
fallbackPath()?.home ||
|
|
||||||
fallbackPath()?.directory,
|
|
||||||
)
|
|
||||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
|
||||||
const [suggestions] = createResource(input, async (value) => {
|
|
||||||
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
|
||||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
|
||||||
if (!typed || typed === current) return { query: value, items: [] }
|
|
||||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
|
||||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
|
||||||
const files = await sdk.client.find
|
|
||||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
|
||||||
.then((result) => result.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
const results = [
|
|
||||||
...directories,
|
|
||||||
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
|
|
||||||
]
|
|
||||||
return {
|
|
||||||
query: value,
|
|
||||||
items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
|
|
||||||
|
|
||||||
async function load(path: string, generation: number, preload = true) {
|
|
||||||
const key = path.replace(/\/+$/, "")
|
|
||||||
setError(false)
|
|
||||||
const absolute = absoluteTreePath(root(), key)
|
|
||||||
const request =
|
|
||||||
listings.get(key) ??
|
|
||||||
sdk.client.file
|
|
||||||
.list({ directory: absolute, path: "" })
|
|
||||||
.then((result) => result.data ?? [])
|
|
||||||
.catch(() => undefined)
|
|
||||||
listings.set(key, request)
|
|
||||||
const nodes = await request
|
|
||||||
if (!activeTreeNavigation(generation, navigation)) return false
|
|
||||||
if (!nodes) {
|
|
||||||
listings.delete(key)
|
|
||||||
if (!key) setError(true)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
|
||||||
if (preload && advanceTreePreload(advanced, key)) {
|
|
||||||
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function navigate(path: string) {
|
|
||||||
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
|
|
||||||
if (!value) return
|
|
||||||
const token = ++navigation
|
|
||||||
setLoading(true)
|
|
||||||
setRootValid(false)
|
|
||||||
setSelected("")
|
|
||||||
setSuggestionsOpen(false)
|
|
||||||
setActiveSuggestion(-1)
|
|
||||||
setRoot(value)
|
|
||||||
setInput(displayPickerPath(value, value, home()))
|
|
||||||
listings.clear()
|
|
||||||
advanced.clear()
|
|
||||||
tree?.resetPaths([])
|
|
||||||
const valid = await load("", token)
|
|
||||||
if (!activeTreeNavigation(token, navigation)) return
|
|
||||||
setRootValid(valid)
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function complete() {
|
|
||||||
const items = currentSuggestions()
|
|
||||||
const match = items[activeSuggestion()] ?? items[0]
|
|
||||||
if (!match) return
|
|
||||||
const value = displayPickerPath(match.absolute, input(), home())
|
|
||||||
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
|
|
||||||
if (match.type === "file") {
|
|
||||||
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "")
|
|
||||||
setSuggestionsOpen(false)
|
|
||||||
setActiveSuggestion(-1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
|
|
||||||
if (suggestion.type === "directory") {
|
|
||||||
void navigate(suggestion.absolute)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setInput(displayPickerPath(suggestion.absolute, input(), home()))
|
|
||||||
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "")
|
|
||||||
setSuggestionsOpen(false)
|
|
||||||
setActiveSuggestion(-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveSuggestion(delta: -1 | 1) {
|
|
||||||
setSuggestionsOpen(true)
|
|
||||||
setActiveSuggestion((current) => nextSuggestionIndex(current, delta, currentSuggestions().length))
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeSuggestionValue() {
|
|
||||||
const items = currentSuggestions()
|
|
||||||
return items[activeSuggestion()] ?? items[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyActions: Partial<Record<string, () => void>> = {
|
|
||||||
ArrowDown: () => moveSuggestion(1),
|
|
||||||
ArrowUp: () => moveSuggestion(-1),
|
|
||||||
Enter: () => {
|
|
||||||
const suggestion = activeSuggestionValue()
|
|
||||||
if (suggestion) chooseSuggestion(suggestion)
|
|
||||||
if (!suggestion) void navigate(input())
|
|
||||||
},
|
|
||||||
Tab: complete,
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInputKey(event: KeyboardEvent) {
|
|
||||||
const action = keyActions[event.key]
|
|
||||||
if (!action) return
|
|
||||||
if (event.key === "Tab" && event.shiftKey) return
|
|
||||||
event.preventDefault()
|
|
||||||
action()
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolve() {
|
|
||||||
const path = policy.result(root(), selected(), rootValid())
|
|
||||||
if (!path) return
|
|
||||||
props.onSelect(props.multiple ? [path] : path)
|
|
||||||
dialog.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const closeSuggestions = (event: PointerEvent) => {
|
|
||||||
if (pathArea?.contains(event.target as Node)) return
|
|
||||||
setSuggestionsOpen(false)
|
|
||||||
setActiveSuggestion(-1)
|
|
||||||
}
|
|
||||||
document.addEventListener("pointerdown", closeSuggestions)
|
|
||||||
onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
|
|
||||||
tree = new FileTree({
|
|
||||||
paths: [],
|
|
||||||
flattenEmptyDirectories: false,
|
|
||||||
initialExpansion: "closed",
|
|
||||||
stickyFolders: true,
|
|
||||||
unsafeCSS: `
|
|
||||||
button[data-type="item"] {
|
|
||||||
background: transparent !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
button[data-type="item"]:hover {
|
|
||||||
background: var(--v2-overlay-simple-overlay-hover) !important;
|
|
||||||
}
|
|
||||||
button[data-type="item"]:focus-visible {
|
|
||||||
outline: none !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
[data-file-tree-virtualized-scroll] {
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
onExpansionChange(change) {
|
|
||||||
if (change.expanded) void load(change.path, navigation)
|
|
||||||
},
|
|
||||||
onSelectionChange(paths) {
|
|
||||||
const path = paths.at(-1)
|
|
||||||
setSelected(path ? (policy.selection(root(), path) ?? "") : "")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!container) return
|
|
||||||
tree.render({ containerWrapper: container })
|
|
||||||
tree.getFileTreeContainer()?.classList.add("directory-picker-v2-tree")
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const path = start()
|
|
||||||
if (!path || root()) return
|
|
||||||
void navigate(path)
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => tree?.cleanUp())
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
|
|
||||||
<div class="directory-picker-v2-body">
|
|
||||||
<div class="directory-picker-v2-path" ref={pathArea}>
|
|
||||||
<TextInputV2
|
|
||||||
value={input()}
|
|
||||||
autofocus
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck={false}
|
|
||||||
class="!w-full"
|
|
||||||
onInput={(event) => {
|
|
||||||
setInput(cleanPickerInput(event.currentTarget.value))
|
|
||||||
setSelected("")
|
|
||||||
setSuggestionsOpen(true)
|
|
||||||
setActiveSuggestion(-1)
|
|
||||||
}}
|
|
||||||
role="combobox"
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-expanded={suggestionsOpen()}
|
|
||||||
aria-controls="directory-picker-v2-suggestions"
|
|
||||||
aria-activedescendant={
|
|
||||||
activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined
|
|
||||||
}
|
|
||||||
onKeyDown={handleInputKey}
|
|
||||||
/>
|
|
||||||
<div class="directory-picker-v2-actions">
|
|
||||||
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(home())}>
|
|
||||||
~
|
|
||||||
</ButtonV2>
|
|
||||||
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}>
|
|
||||||
{language.t("dialog.directory.root")}
|
|
||||||
</ButtonV2>
|
|
||||||
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}>
|
|
||||||
{language.t("dialog.directory.parent")}
|
|
||||||
</ButtonV2>
|
|
||||||
</div>
|
|
||||||
<Show when={suggestionsOpen() && currentSuggestions().length > 0}>
|
|
||||||
<div id="directory-picker-v2-suggestions" role="listbox" class="directory-picker-v2-suggestions">
|
|
||||||
<For each={currentSuggestions()}>
|
|
||||||
{(suggestion, index) => (
|
|
||||||
<button
|
|
||||||
id={`directory-picker-v2-suggestion-${index()}`}
|
|
||||||
role="option"
|
|
||||||
aria-selected={index() === activeSuggestion()}
|
|
||||||
data-active={index() === activeSuggestion() ? "" : undefined}
|
|
||||||
onPointerMove={() => setActiveSuggestion(index())}
|
|
||||||
onClick={() => chooseSuggestion(suggestion)}
|
|
||||||
>
|
|
||||||
{displayPickerPath(suggestion.absolute, input(), home())}
|
|
||||||
{suggestion.type === "directory" ? "/" : ""}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="directory-picker-v2-browser"
|
|
||||||
ref={container}
|
|
||||||
onWheel={(event) => {
|
|
||||||
const scroller = tree
|
|
||||||
?.getFileTreeContainer()
|
|
||||||
?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
|
|
||||||
if (!scroller) return
|
|
||||||
const next = nextTreeScrollTop(
|
|
||||||
scroller.scrollTop,
|
|
||||||
event.deltaY,
|
|
||||||
scroller.scrollHeight,
|
|
||||||
scroller.clientHeight,
|
|
||||||
)
|
|
||||||
if (next === scroller.scrollTop) return
|
|
||||||
event.preventDefault()
|
|
||||||
scroller.scrollTop = next
|
|
||||||
scroller.dispatchEvent(new Event("scroll"))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={loading()}>
|
|
||||||
<div class="directory-picker-v2-state">{language.t("common.loading")}</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={!loading() && error()}>
|
|
||||||
<div class="directory-picker-v2-state">{language.t("dialog.directory.readError")}</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
|
||||||
{language.t("common.cancel")}
|
|
||||||
</ButtonV2>
|
|
||||||
<ButtonV2 variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}>
|
|
||||||
{action[policy.action]}
|
|
||||||
</ButtonV2>
|
|
||||||
</DialogFooter>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,11 +4,12 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import type { ListRef } from "@opencode-ai/ui/list"
|
import type { ListRef } from "@opencode-ai/ui/list"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import fuzzysort from "fuzzysort"
|
||||||
import { createMemo, createResource, createSignal } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
|
import { ServerSDK } from "@/context/server-sdk"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -23,9 +24,89 @@ type Row = {
|
|||||||
group: "recent" | "folders"
|
group: "recent" | "folders"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanInput(value: string) {
|
||||||
|
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||||
|
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(input: string) {
|
||||||
|
const v = input.replaceAll("\\", "/")
|
||||||
|
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
||||||
|
return v.replace(/\/+/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDriveRoot(input: string) {
|
||||||
|
const v = normalizePath(input)
|
||||||
|
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailing(input: string) {
|
||||||
|
const v = normalizeDriveRoot(input)
|
||||||
|
if (v === "/") return v
|
||||||
|
if (v === "//") return v
|
||||||
|
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||||
|
return v.replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPath(base: string | undefined, rel: string) {
|
||||||
|
const b = trimTrailing(base ?? "")
|
||||||
|
const r = trimTrailing(rel).replace(/^\/+/, "")
|
||||||
|
if (!b) return r
|
||||||
|
if (!r) return b
|
||||||
|
if (b.endsWith("/")) return b + r
|
||||||
|
return b + "/" + r
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootOf(input: string) {
|
||||||
|
const v = normalizeDriveRoot(input)
|
||||||
|
if (v.startsWith("//")) return "//"
|
||||||
|
if (v.startsWith("/")) return "/"
|
||||||
|
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentOf(input: string) {
|
||||||
|
const v = trimTrailing(input)
|
||||||
|
if (v === "/") return v
|
||||||
|
if (v === "//") return v
|
||||||
|
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||||
|
|
||||||
|
const i = v.lastIndexOf("/")
|
||||||
|
if (i <= 0) return "/"
|
||||||
|
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
||||||
|
return v.slice(0, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeOf(input: string) {
|
||||||
|
const raw = normalizeDriveRoot(input.trim())
|
||||||
|
if (!raw) return "relative" as const
|
||||||
|
if (raw.startsWith("~")) return "tilde" as const
|
||||||
|
if (rootOf(raw)) return "absolute" as const
|
||||||
|
return "relative" as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function tildeOf(absolute: string, home: string) {
|
||||||
|
const full = trimTrailing(absolute)
|
||||||
|
if (!home) return ""
|
||||||
|
|
||||||
|
const hn = trimTrailing(home)
|
||||||
|
const lc = full.toLowerCase()
|
||||||
|
const hc = hn.toLowerCase()
|
||||||
|
if (lc === hc) return "~"
|
||||||
|
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPath(path: string, input: string, home: string) {
|
||||||
|
const full = trimTrailing(path)
|
||||||
|
if (modeOf(input) === "absolute") return full
|
||||||
|
return tildeOf(full, home) || full
|
||||||
|
}
|
||||||
|
|
||||||
function toRow(absolute: string, home: string, group: Row["group"]): Row {
|
function toRow(absolute: string, home: string, group: Row["group"]): Row {
|
||||||
const full = displayPickerPath(absolute, "", "")
|
const full = trimTrailing(absolute)
|
||||||
const tilde = displayPickerPath(full, "~", home)
|
const tilde = tildeOf(full, home)
|
||||||
const withSlash = (value: string) => {
|
const withSlash = (value: string) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
if (value.endsWith("/")) return value
|
if (value.endsWith("/")) return value
|
||||||
@@ -47,6 +128,120 @@ function uniqueRows(rows: Row[]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefined; home: () => string }) {
|
||||||
|
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||||
|
let current = 0
|
||||||
|
|
||||||
|
const scoped = (value: string) => {
|
||||||
|
const base = args.start()
|
||||||
|
if (!base) return
|
||||||
|
|
||||||
|
const raw = normalizeDriveRoot(value)
|
||||||
|
if (!raw) return { directory: trimTrailing(base), path: "" }
|
||||||
|
|
||||||
|
const h = args.home()
|
||||||
|
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
|
||||||
|
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
|
||||||
|
|
||||||
|
const root = rootOf(raw)
|
||||||
|
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
|
||||||
|
return { directory: trimTrailing(base), path: raw }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs = async (dir: string) => {
|
||||||
|
const key = trimTrailing(dir)
|
||||||
|
const existing = cache.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const request = args.sdk.client.file
|
||||||
|
.list({ directory: key, path: "" })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
.then((nodes) =>
|
||||||
|
nodes
|
||||||
|
.filter((n) => n.type === "directory")
|
||||||
|
.map((n) => ({
|
||||||
|
name: n.name,
|
||||||
|
absolute: trimTrailing(normalizeDriveRoot(n.absolute)),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
cache.set(key, request)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = async (dir: string, query: string, limit: number) => {
|
||||||
|
const items = await dirs(dir)
|
||||||
|
if (!query) return items.slice(0, limit).map((x) => x.absolute)
|
||||||
|
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
|
||||||
|
}
|
||||||
|
|
||||||
|
return async (filter: string) => {
|
||||||
|
const token = ++current
|
||||||
|
const active = () => token === current
|
||||||
|
|
||||||
|
const value = cleanInput(filter)
|
||||||
|
const scopedInput = scoped(value)
|
||||||
|
if (!scopedInput) return [] as string[]
|
||||||
|
|
||||||
|
const raw = normalizeDriveRoot(value)
|
||||||
|
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
||||||
|
const query = normalizeDriveRoot(scopedInput.path)
|
||||||
|
|
||||||
|
const find = () =>
|
||||||
|
args.sdk.client.find
|
||||||
|
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
|
||||||
|
if (!isPath) {
|
||||||
|
const results = await find()
|
||||||
|
if (!active()) return []
|
||||||
|
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = query.replace(/^\/+/, "").split("/")
|
||||||
|
const head = segments.slice(0, segments.length - 1).filter((x) => x && x !== ".")
|
||||||
|
const tail = segments[segments.length - 1] ?? ""
|
||||||
|
|
||||||
|
const cap = 12
|
||||||
|
const branch = 4
|
||||||
|
let paths = [scopedInput.directory]
|
||||||
|
for (const part of head) {
|
||||||
|
if (!active()) return []
|
||||||
|
if (part === "..") {
|
||||||
|
paths = paths.map(parentOf)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
|
||||||
|
if (!active()) return []
|
||||||
|
paths = Array.from(new Set(next)).slice(0, cap)
|
||||||
|
if (paths.length === 0) return [] as string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
||||||
|
if (!active()) return []
|
||||||
|
const deduped = Array.from(new Set(out))
|
||||||
|
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
||||||
|
const expand = !raw.endsWith("/")
|
||||||
|
if (!expand || !tail) {
|
||||||
|
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
|
||||||
|
return items.slice(0, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const needle = tail.toLowerCase()
|
||||||
|
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
|
||||||
|
const target = exact[0]
|
||||||
|
if (!target) return deduped.slice(0, 50)
|
||||||
|
|
||||||
|
const children = await match(target, "", 30)
|
||||||
|
if (!active()) return []
|
||||||
|
const items = Array.from(new Set([...deduped, ...children]))
|
||||||
|
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
|
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
|
||||||
@@ -73,10 +268,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||||
)
|
)
|
||||||
|
|
||||||
const directories = createDirectorySearch({
|
const directories = useDirectorySearch({
|
||||||
sdk,
|
sdk,
|
||||||
home,
|
home,
|
||||||
base: start,
|
start,
|
||||||
})
|
})
|
||||||
|
|
||||||
const recentProjects = createMemo(() => {
|
const recentProjects = createMemo(() => {
|
||||||
@@ -141,7 +336,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
|
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
|
||||||
}
|
}
|
||||||
ref={(r) => (list = r)}
|
ref={(r) => (list = r)}
|
||||||
onFilter={(value) => setFilter(cleanPickerInput(value))}
|
onFilter={(value) => setFilter(cleanInput(value))}
|
||||||
onKeyEvent={(e, item) => {
|
onKeyEvent={(e, item) => {
|
||||||
if (e.key !== "Tab") return
|
if (e.key !== "Tab") return
|
||||||
if (e.shiftKey) return
|
if (e.shiftKey) return
|
||||||
@@ -150,7 +345,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const value = displayPickerPath(item.absolute, filter(), home())
|
const value = displayPath(item.absolute, filter(), home())
|
||||||
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
||||||
}}
|
}}
|
||||||
onSelect={(path) => {
|
onSelect={(path) => {
|
||||||
@@ -159,7 +354,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const path = displayPickerPath(item.absolute, filter(), home())
|
const path = displayPath(item.absolute, filter(), home())
|
||||||
if (path === "~") {
|
if (path === "~") {
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div class="w-full flex items-center justify-between rounded-md">
|
||||||
|
|||||||
@@ -7,25 +7,18 @@ import { List } from "@opencode-ai/ui/list"
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
|
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { useServer } from "@/context/server"
|
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
|
||||||
const DialogSelectFileV2 = lazy(() =>
|
|
||||||
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
|
||||||
)
|
|
||||||
|
|
||||||
type EntryType = "command" | "file" | "session"
|
type EntryType = "command" | "file" | "session"
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
@@ -182,7 +175,7 @@ function createFileEntries(props: {
|
|||||||
function createSessionEntries(props: {
|
function createSessionEntries(props: {
|
||||||
workspaces: () => string[]
|
workspaces: () => string[]
|
||||||
label: (directory: string) => string
|
label: (directory: string) => string
|
||||||
serverSDK: ServerSDK
|
serverSDK: ReturnType<typeof useServerSDK>
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}) {
|
}) {
|
||||||
const state: {
|
const state: {
|
||||||
@@ -271,9 +264,6 @@ function createSessionEntries(props: {
|
|||||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
|
||||||
const server = useServer()
|
|
||||||
const settings = useSettings()
|
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -302,21 +292,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||||
return dirs
|
return dirs
|
||||||
})
|
})
|
||||||
const homedir = createMemo(() => serverSync().data.path.home)
|
const homedir = createMemo(() => serverSync.data.path.home)
|
||||||
const label = (directory: string) => {
|
const label = (directory: string) => {
|
||||||
const current = project()
|
const current = project()
|
||||||
const kind =
|
const kind =
|
||||||
current && directory === current.worktree
|
current && directory === current.worktree
|
||||||
? language.t("workspace.type.local")
|
? language.t("workspace.type.local")
|
||||||
: language.t("workspace.type.sandbox")
|
: language.t("workspace.type.sandbox")
|
||||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
const path = home ? directory.replace(home, "~") : directory
|
const path = home ? directory.replace(home, "~") : directory
|
||||||
const name = store.vcs?.branch ?? getFilename(directory)
|
const name = store.vcs?.branch ?? getFilename(directory)
|
||||||
return `${kind} : ${name || path}`
|
return `${kind} : ${name || path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
|
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
|
||||||
|
|
||||||
const items = async (text: string) => {
|
const items = async (text: string) => {
|
||||||
const query = text.trim()
|
const query = text.trim()
|
||||||
@@ -393,21 +383,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
state.cleanup?.()
|
state.cleanup?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) {
|
|
||||||
return (
|
|
||||||
<DialogSelectFileV2
|
|
||||||
server={server.current}
|
|
||||||
mode="file"
|
|
||||||
start={projectDirectory()}
|
|
||||||
title={language.t("session.header.searchFiles")}
|
|
||||||
onSelect={(result) => {
|
|
||||||
if (typeof result !== "string") return
|
|
||||||
open(result)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
|
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
|
||||||
<List
|
<List
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
Object.entries(sync().data.mcp ?? {})
|
Object.entries(sync.data.mcp ?? {})
|
||||||
.map(([name, status]) => ({ name, status: status.status }))
|
.map(([name, status]) => ({ name, status: status.status }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
)
|
)
|
||||||
@@ -48,7 +48,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const mcpStatus = () => sync().data.mcp[i.name]
|
const mcpStatus = () => sync.data.mcp[i.name]
|
||||||
const status = () => mcpStatus()?.status
|
const status = () => mcpStatus()?.status
|
||||||
const statusLabel = () => {
|
const statusLabel = () => {
|
||||||
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import {
|
|
||||||
absoluteTreePath,
|
|
||||||
activeTreeNavigation,
|
|
||||||
advanceTreePreload,
|
|
||||||
nextSuggestionIndex,
|
|
||||||
nextTreeScrollTop,
|
|
||||||
pickerTreeEntries,
|
|
||||||
pickerSearchEntries,
|
|
||||||
pickerFileSearchQuery,
|
|
||||||
pickerMode,
|
|
||||||
preloadTreeDirectories,
|
|
||||||
selectedTreePath,
|
|
||||||
treeEntries,
|
|
||||||
treePathWithin,
|
|
||||||
currentPickerSuggestions,
|
|
||||||
createDirectorySearch,
|
|
||||||
displayPickerPath,
|
|
||||||
pickerParent,
|
|
||||||
pickerRoot,
|
|
||||||
pickerAbsoluteInput,
|
|
||||||
} from "./directory-picker-domain"
|
|
||||||
|
|
||||||
test("maps server directory entries into Pierre paths", () => {
|
|
||||||
expect(
|
|
||||||
treeEntries("src/", [
|
|
||||||
{ name: "components", type: "directory" },
|
|
||||||
{ name: "index.ts", type: "file" },
|
|
||||||
]),
|
|
||||||
).toEqual(["src/components/", "src/index.ts"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("maps Pierre paths back to the selected server root", () => {
|
|
||||||
expect(absoluteTreePath("C:/Users/luke", "src/components/")).toBe("C:/Users/luke/src/components")
|
|
||||||
expect(absoluteTreePath("C:/", "")).toBe("C:/")
|
|
||||||
expect(absoluteTreePath("C:/", "README.md")).toBe("C:/README.md")
|
|
||||||
expect(absoluteTreePath("/home/luke", "README.md")).toBe("/home/luke/README.md")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes files only when the picker selects files", () => {
|
|
||||||
const nodes = [
|
|
||||||
{ name: "components", type: "directory" as const },
|
|
||||||
{ name: "index.ts", type: "file" as const },
|
|
||||||
]
|
|
||||||
expect(pickerTreeEntries("", nodes, "directory")).toEqual(["components/"])
|
|
||||||
expect(pickerTreeEntries("", nodes, "file")).toEqual(["components/", "index.ts"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes files in file autocomplete while preserving directory navigation", () => {
|
|
||||||
const nodes = [
|
|
||||||
{ name: "src", absolute: "/repo/src", type: "directory" as const },
|
|
||||||
{ name: "README.md", absolute: "/repo/README.md", type: "file" as const },
|
|
||||||
]
|
|
||||||
expect(pickerSearchEntries(nodes, "directory")).toEqual([nodes[0]])
|
|
||||||
expect(pickerSearchEntries(nodes, "file")).toEqual(nodes)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("centralizes file and directory selection policy", () => {
|
|
||||||
const file = pickerMode("file", "/repo")
|
|
||||||
expect(file.includeFiles).toBeTrue()
|
|
||||||
expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts")
|
|
||||||
expect(file.selection("/repo", "src/")).toBeUndefined()
|
|
||||||
expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts")
|
|
||||||
expect(file.selection("/tmp", "example.txt")).toBeUndefined()
|
|
||||||
expect(file.navigation("/repo/src")).toBe("/repo/src")
|
|
||||||
expect(file.navigation("/tmp")).toBeUndefined()
|
|
||||||
|
|
||||||
const directory = pickerMode("directory")
|
|
||||||
expect(directory.includeFiles).toBeFalse()
|
|
||||||
expect(directory.selection("/repo", "src/")).toBe("/repo/src")
|
|
||||||
expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos")
|
|
||||||
expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo")
|
|
||||||
expect(directory.navigation("/tmp")).toBe("/tmp")
|
|
||||||
expect(directory.result("/repo", "")).toBe("/repo")
|
|
||||||
expect(directory.result("C:/Users/luke", "")).toBe("C:\\Users\\luke")
|
|
||||||
expect(directory.result("//Server/Share/repo", "")).toBe("\\\\Server\\Share\\repo")
|
|
||||||
expect(directory.result("/repo", "", false)).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("accepts mutations only from the active navigation", () => {
|
|
||||||
expect(activeTreeNavigation(3, 3)).toBeTrue()
|
|
||||||
expect(activeTreeNavigation(2, 3)).toBeFalse()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves POSIX case while matching Windows drives case-insensitively", () => {
|
|
||||||
expect(treePathWithin("/repo", "/Repo")).toBeFalse()
|
|
||||||
expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue()
|
|
||||||
expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue()
|
|
||||||
expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe("src/file.ts")
|
|
||||||
expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse()
|
|
||||||
expect(treePathWithin("/", "/src")).toBeTrue()
|
|
||||||
expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts")
|
|
||||||
expect(pickerMode("file", "C:/").selection("C:/", "file.ts")).toBe("file.ts")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("displays paths using the selected server path format", () => {
|
|
||||||
expect(displayPickerPath("C:/Users/luke/repos", "C:/Users/luke/repos", "C:/Users/luke")).toBe(
|
|
||||||
"C:\\Users\\luke\\repos",
|
|
||||||
)
|
|
||||||
expect(displayPickerPath("C:/Users/luke/repos", "C:\\Users\\luke\\repos", "C:/Users/luke")).toBe(
|
|
||||||
"C:\\Users\\luke\\repos",
|
|
||||||
)
|
|
||||||
expect(displayPickerPath("/home/luke/repos", "repos", "/home/luke")).toBe("~/repos")
|
|
||||||
expect(displayPickerPath("/home/luke/repos", "~/repos", "/home/luke")).toBe("~/repos")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("treats the server share prefix as the UNC root", () => {
|
|
||||||
expect(pickerRoot("//Server/Share/repo/src")).toBe("//Server/Share")
|
|
||||||
expect(pickerRoot("\\\\Server\\Share\\repo\\src")).toBe("//Server/Share")
|
|
||||||
expect(pickerParent("//Server/Share")).toBe("//Server/Share")
|
|
||||||
expect(pickerParent("//Server/Share/repo")).toBe("//Server/Share")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("resolves relative input against the current picker root", () => {
|
|
||||||
expect(pickerAbsoluteInput("src", "/home/luke", "/home/luke/repo")).toBe("/home/luke/repo/src")
|
|
||||||
expect(pickerAbsoluteInput("../other", "/home/luke", "/home/luke/repo")).toBe("/home/luke/other")
|
|
||||||
expect(pickerAbsoluteInput("~/.config", "/home/luke", "/home/luke/repo")).toBe("/home/luke/.config")
|
|
||||||
expect(pickerAbsoluteInput("src", "C:/Users/luke", "C:/Users/luke/repo")).toBe("C:/Users/luke/repo/src")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("exposes autocomplete results only for their source query", () => {
|
|
||||||
const result = { query: "/repo/src", items: ["/repo/src/index.ts"] }
|
|
||||||
expect(currentPickerSuggestions(result, "/repo/src")).toEqual(result.items)
|
|
||||||
expect(currentPickerSuggestions(result, "/repo/test")).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("scopes file autocomplete to the current browser root", () => {
|
|
||||||
expect(pickerFileSearchQuery("/home/luke/repos", "/home/luke/repos/src/in", "/home/luke")).toBe("src/in")
|
|
||||||
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("resolves directory autocomplete from the current browser root", async () => {
|
|
||||||
const directories: string[] = []
|
|
||||||
const sdk = {
|
|
||||||
client: {
|
|
||||||
find: {
|
|
||||||
files: (input: { directory: string }) => {
|
|
||||||
directories.push(input.directory)
|
|
||||||
return Promise.resolve({ data: [] })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
|
||||||
let base = "/repo"
|
|
||||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
|
|
||||||
|
|
||||||
await search("components")
|
|
||||||
base = "/repo/src"
|
|
||||||
await search("components")
|
|
||||||
|
|
||||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("identifies the next directory level to preload", () => {
|
|
||||||
expect(
|
|
||||||
preloadTreeDirectories("src/", [
|
|
||||||
{ name: "components", type: "directory" },
|
|
||||||
{ name: "index.ts", type: "file" },
|
|
||||||
{ name: "utils", type: "directory" },
|
|
||||||
]),
|
|
||||||
).toEqual(["src/components/", "src/utils/"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("advances preloading once for every expanded directory", () => {
|
|
||||||
const advanced = new Set<string>()
|
|
||||||
expect(advanceTreePreload(advanced, "")).toBeTrue()
|
|
||||||
expect(advanceTreePreload(advanced, "")).toBeFalse()
|
|
||||||
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("clamps bridged tree wheel scrolling", () => {
|
|
||||||
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
|
|
||||||
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
|
|
||||||
expect(nextTreeScrollTop(290, 40, 500, 200)).toBe(300)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("wraps autocomplete keyboard navigation", () => {
|
|
||||||
expect(nextSuggestionIndex(-1, 1, 4)).toBe(0)
|
|
||||||
expect(nextSuggestionIndex(3, 1, 4)).toBe(0)
|
|
||||||
expect(nextSuggestionIndex(0, -1, 4)).toBe(3)
|
|
||||||
expect(nextSuggestionIndex(0, 1, 0)).toBe(-1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns absolute directories and relative files", () => {
|
|
||||||
expect(selectedTreePath("/home/luke/repo", "src/", "directory")).toBe("/home/luke/repo/src")
|
|
||||||
expect(selectedTreePath("/home/luke/repo", "src/index.ts", "file")).toBe("src/index.ts")
|
|
||||||
expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts")
|
|
||||||
expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined()
|
|
||||||
})
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
export function treeEntries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
|
|
||||||
const prefix = parent.replace(/^\/+|\/+$/g, "")
|
|
||||||
return nodes.map((node) => {
|
|
||||||
const path = prefix ? `${prefix}/${node.name}` : node.name
|
|
||||||
return node.type === "directory" ? path + "/" : path
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickerTreeEntries(
|
|
||||||
parent: string,
|
|
||||||
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
|
|
||||||
mode: "directory" | "file",
|
|
||||||
) {
|
|
||||||
return treeEntries(parent, mode === "directory" ? nodes.filter((node) => node.type === "directory") : nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickerSearchEntries<T extends { type: "file" | "directory" }>(
|
|
||||||
nodes: readonly T[],
|
|
||||||
mode: "directory" | "file",
|
|
||||||
) {
|
|
||||||
return mode === "directory" ? nodes.filter((node) => node.type === "directory") : [...nodes]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickerMode(mode: "directory" | "file", base?: string) {
|
|
||||||
if (mode === "file") {
|
|
||||||
return {
|
|
||||||
includeFiles: true,
|
|
||||||
action: "file" as const,
|
|
||||||
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
|
|
||||||
return treeEntries(parent, nodes)
|
|
||||||
},
|
|
||||||
navigation(path: string) {
|
|
||||||
return treePathWithin(base, path) ? path : undefined
|
|
||||||
},
|
|
||||||
result(root: string, selected: string) {
|
|
||||||
return selected || undefined
|
|
||||||
},
|
|
||||||
selection(root: string, path: string) {
|
|
||||||
if (!treePathWithin(base, root)) return
|
|
||||||
return selectedTreePath(root, path, "file", base)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
includeFiles: false,
|
|
||||||
action: "directory" as const,
|
|
||||||
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
|
|
||||||
return treeEntries(
|
|
||||||
parent,
|
|
||||||
nodes.filter((node) => node.type === "directory"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigation(path: string) {
|
|
||||||
return path
|
|
||||||
},
|
|
||||||
result(root: string, selected: string, valid = true) {
|
|
||||||
if (!valid) return
|
|
||||||
return selected || (root ? nativePickerPath(root) : undefined)
|
|
||||||
},
|
|
||||||
selection(root: string, path: string) {
|
|
||||||
return selectedTreePath(root, path, "directory")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickerFileSearchQuery(root: string, input: string, home: string) {
|
|
||||||
const value = input
|
|
||||||
.replace(/\\/g, "/")
|
|
||||||
.replace(/^~(?=\/|$)/, home)
|
|
||||||
.replace(/\/+$/, "")
|
|
||||||
const base = root.replace(/\\/g, "/").replace(/\/+$/, "")
|
|
||||||
if (value === base) return ""
|
|
||||||
if (value.startsWith(base + "/")) return value.slice(base.length + 1)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickerAbsoluteInput(input: string, home: string, current: string) {
|
|
||||||
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
|
|
||||||
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
|
|
||||||
return canonicalPickerPath(absolute)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function treePathWithin(base: string | undefined, path: string) {
|
|
||||||
return pickerRelativePath(base, path) !== undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canonicalPickerPath(path: string) {
|
|
||||||
const value = normalizePickerDrive(path)
|
|
||||||
const root = pickerRoot(value)
|
|
||||||
const parts = value.slice(root.length).split("/")
|
|
||||||
const resolved = parts.reduce<string[]>((output, part) => {
|
|
||||||
if (!part || part === ".") return output
|
|
||||||
if (part === "..") {
|
|
||||||
output.pop()
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
output.push(part)
|
|
||||||
return output
|
|
||||||
}, [])
|
|
||||||
return joinPickerPath(root, resolved.join("/"))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickerRelativePath(base: string | undefined, path: string) {
|
|
||||||
if (!base) return
|
|
||||||
const rootPath = canonicalPickerPath(base)
|
|
||||||
const targetPath = canonicalPickerPath(path)
|
|
||||||
const insensitive = /^[A-Za-z]:\//.test(rootPath) || rootPath.startsWith("//")
|
|
||||||
const root = insensitive ? rootPath.toLowerCase() : rootPath
|
|
||||||
const target = insensitive ? targetPath.toLowerCase() : targetPath
|
|
||||||
if (target === root) return ""
|
|
||||||
const prefix = root.endsWith("/") ? root : root + "/"
|
|
||||||
if (!target.startsWith(prefix)) return
|
|
||||||
return targetPath.slice(prefix.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function currentPickerSuggestions<T>(result: { query: string; items: readonly T[] } | undefined, query: string) {
|
|
||||||
if (result?.query !== query) return []
|
|
||||||
return result.items
|
|
||||||
}
|
|
||||||
|
|
||||||
export function preloadTreeDirectories(
|
|
||||||
parent: string,
|
|
||||||
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
|
|
||||||
) {
|
|
||||||
return treeEntries(
|
|
||||||
parent,
|
|
||||||
nodes.filter((node) => node.type === "directory"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advanceTreePreload(advanced: Set<string>, path: string) {
|
|
||||||
if (advanced.has(path)) return false
|
|
||||||
advanced.add(path)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activeTreeNavigation(request: number, current: number) {
|
|
||||||
return request === current
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
|
|
||||||
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nextSuggestionIndex(current: number, delta: -1 | 1, count: number) {
|
|
||||||
if (count === 0) return -1
|
|
||||||
return (current + delta + count) % count
|
|
||||||
}
|
|
||||||
|
|
||||||
export function absoluteTreePath(root: string, path: string) {
|
|
||||||
const base = trimPickerPath(root)
|
|
||||||
const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
|
|
||||||
if (!relative) return base || "/"
|
|
||||||
if (!base || base === "/") return "/" + relative
|
|
||||||
if (base.endsWith("/")) return base + relative
|
|
||||||
return `${base}/${relative}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectedTreePath(root: string, path: string, mode: "directory" | "file", base?: string) {
|
|
||||||
const directory = path.endsWith("/")
|
|
||||||
if (mode === "file") {
|
|
||||||
if (directory) return
|
|
||||||
if (!base) return path
|
|
||||||
const absolute = absoluteTreePath(root, path)
|
|
||||||
return pickerRelativePath(base, absolute)
|
|
||||||
}
|
|
||||||
return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nativePickerPath(path: string) {
|
|
||||||
const value = trimPickerPath(path)
|
|
||||||
if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import fuzzysort from "fuzzysort"
|
|
||||||
import { ServerSDK } from "@/context/server-sdk"
|
|
||||||
|
|
||||||
export function cleanPickerInput(value: string) {
|
|
||||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
|
||||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePickerPath(input: string) {
|
|
||||||
const value = input.replaceAll("\\", "/")
|
|
||||||
if (value.startsWith("//") && !value.startsWith("///")) return "//" + value.slice(2).replace(/\/+/g, "/")
|
|
||||||
return value.replace(/\/+/g, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePickerDrive(input: string) {
|
|
||||||
const value = normalizePickerPath(input)
|
|
||||||
if (/^[A-Za-z]:$/.test(value)) return value + "/"
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trimPickerPath(input: string) {
|
|
||||||
const value = normalizePickerDrive(input)
|
|
||||||
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
|
|
||||||
return value.replace(/\/+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function joinPickerPath(base: string | undefined, relative: string) {
|
|
||||||
const root = trimPickerPath(base ?? "")
|
|
||||||
const path = trimPickerPath(relative).replace(/^\/+/, "")
|
|
||||||
if (!root) return path
|
|
||||||
if (!path) return root
|
|
||||||
if (root.endsWith("/")) return root + path
|
|
||||||
return root + "/" + path
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickerRoot(input: string) {
|
|
||||||
const value = normalizePickerDrive(input)
|
|
||||||
if (value.startsWith("//")) {
|
|
||||||
const [server, share] = value.slice(2).split("/")
|
|
||||||
if (server && share) return `//${server}/${share}`
|
|
||||||
return "//"
|
|
||||||
}
|
|
||||||
if (value.startsWith("/")) return "/"
|
|
||||||
if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickerParent(input: string) {
|
|
||||||
const value = trimPickerPath(input)
|
|
||||||
const root = pickerRoot(value)
|
|
||||||
if (value === root) return value
|
|
||||||
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
|
|
||||||
const index = value.lastIndexOf("/")
|
|
||||||
if (index < root.length) return root
|
|
||||||
if (index <= 0) return "/"
|
|
||||||
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
|
|
||||||
return value.slice(0, index)
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickerTilde(absolute: string, home: string) {
|
|
||||||
const path = trimPickerPath(absolute)
|
|
||||||
if (!home) return ""
|
|
||||||
const root = trimPickerPath(home)
|
|
||||||
if (/^[A-Za-z]:\//.test(root)) return ""
|
|
||||||
if (path === root) return "~"
|
|
||||||
if (path.startsWith(root + "/")) return "~" + path.slice(root.length)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function displayPickerPath(path: string, input: string, home: string) {
|
|
||||||
const value = trimPickerPath(path)
|
|
||||||
if (/^[A-Za-z]:\//.test(trimPickerPath(home)) || /^[A-Za-z]:\//.test(value)) return value.replaceAll("/", "\\")
|
|
||||||
return pickerTilde(value, home) || value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
|
|
||||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
|
||||||
let current = 0
|
|
||||||
|
|
||||||
const scoped = (value: string) => {
|
|
||||||
const base = args.base()
|
|
||||||
if (!base) return
|
|
||||||
const raw = normalizePickerDrive(value)
|
|
||||||
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
|
||||||
const home = args.home()
|
|
||||||
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
|
||||||
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
|
||||||
const root = pickerRoot(raw)
|
|
||||||
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
|
||||||
return { directory: trimPickerPath(base), path: raw }
|
|
||||||
}
|
|
||||||
|
|
||||||
const directories = async (directory: string) => {
|
|
||||||
const key = trimPickerPath(directory)
|
|
||||||
const existing = cache.get(key)
|
|
||||||
if (existing) return existing
|
|
||||||
const request = args.sdk.client.file
|
|
||||||
.list({ directory: key, path: "" })
|
|
||||||
.then((result) => result.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
.then((nodes) =>
|
|
||||||
nodes
|
|
||||||
.filter((node) => node.type === "directory")
|
|
||||||
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
|
||||||
)
|
|
||||||
cache.set(key, request)
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = async (directory: string, query: string, limit: number) => {
|
|
||||||
const items = await directories(directory)
|
|
||||||
if (!query) return items.slice(0, limit).map((item) => item.absolute)
|
|
||||||
return fuzzysort.go(query, items, { key: "name", limit }).map((item) => item.obj.absolute)
|
|
||||||
}
|
|
||||||
|
|
||||||
return async (filter: string) => {
|
|
||||||
const token = ++current
|
|
||||||
const active = () => token === current
|
|
||||||
const value = cleanPickerInput(filter)
|
|
||||||
const input = scoped(value)
|
|
||||||
if (!input) return [] as string[]
|
|
||||||
const raw = normalizePickerDrive(value)
|
|
||||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
|
||||||
const query = normalizePickerDrive(input.path)
|
|
||||||
if (!pathInput) {
|
|
||||||
const results = await args.sdk.client.find
|
|
||||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
|
||||||
.then((result) => result.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
if (!active()) return []
|
|
||||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
|
||||||
}
|
|
||||||
const segments = query.replace(/^\/+/, "").split("/")
|
|
||||||
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
|
|
||||||
const tail = segments.at(-1) ?? ""
|
|
||||||
let paths = [input.directory]
|
|
||||||
for (const part of head) {
|
|
||||||
if (!active()) return []
|
|
||||||
if (part === "..") {
|
|
||||||
paths = paths.map(pickerParent)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
paths = Array.from(new Set((await Promise.all(paths.map((path) => match(path, part, 4)))).flat())).slice(0, 12)
|
|
||||||
if (!active() || paths.length === 0) return []
|
|
||||||
}
|
|
||||||
const matches = Array.from(new Set((await Promise.all(paths.map((path) => match(path, tail, 50)))).flat()))
|
|
||||||
if (!active()) return []
|
|
||||||
const base = raw.startsWith("~") ? trimPickerPath(input.directory) : ""
|
|
||||||
if (raw.endsWith("/") || !tail) return Array.from(new Set([base, ...matches].filter(Boolean))).slice(0, 50)
|
|
||||||
const target = matches.find((path) => getFilename(path).toLowerCase() === tail.toLowerCase())
|
|
||||||
if (!target) return matches.slice(0, 50)
|
|
||||||
const children = await match(target, "", 30)
|
|
||||||
if (!active()) return []
|
|
||||||
return Array.from(new Set([base, ...matches, ...children].filter(Boolean))).slice(0, 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { lazy } from "solid-js"
|
|
||||||
import { DialogSelectDirectory } from "./dialog-select-directory"
|
import { DialogSelectDirectory } from "./dialog-select-directory"
|
||||||
import { directoryPickerKind } from "./directory-picker-policy"
|
import { directoryPickerKind } from "./directory-picker-policy"
|
||||||
|
|
||||||
const DialogSelectDirectoryV2 = lazy(() =>
|
|
||||||
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
|
||||||
)
|
|
||||||
|
|
||||||
type DirectoryPickerInput = {
|
type DirectoryPickerInput = {
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
title?: string
|
title?: string
|
||||||
@@ -19,7 +13,6 @@ type DirectoryPickerInput = {
|
|||||||
|
|
||||||
export function useDirectoryPicker() {
|
export function useDirectoryPicker() {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
return (input: DirectoryPickerInput) => {
|
return (input: DirectoryPickerInput) => {
|
||||||
@@ -28,18 +21,9 @@ export function useDirectoryPicker() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let selected = false
|
dialog.show(
|
||||||
const onSelect = (result: string | string[] | null) => {
|
() => <DialogSelectDirectory {...input} />,
|
||||||
selected = result !== null
|
() => input.onSelect(null),
|
||||||
input.onSelect(result)
|
)
|
||||||
}
|
|
||||||
const cancel = () => {
|
|
||||||
if (!selected) input.onSelect(null)
|
|
||||||
}
|
|
||||||
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
|
||||||
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import { FileTree, type FileTreeDirectoryHandle } from "@pierre/trees"
|
|
||||||
|
|
||||||
test("reports directory expansion changes", () => {
|
|
||||||
const changes: Array<{ path: string; expanded: boolean }> = []
|
|
||||||
const tree = new FileTree({
|
|
||||||
paths: ["src/"],
|
|
||||||
onExpansionChange: (change) => changes.push(change),
|
|
||||||
})
|
|
||||||
|
|
||||||
const src = tree.getItem("src/")
|
|
||||||
if (!src || !src.isDirectory()) throw new Error("Expected src to be a directory")
|
|
||||||
const directory = src as FileTreeDirectoryHandle
|
|
||||||
|
|
||||||
directory.expand()
|
|
||||||
directory.collapse()
|
|
||||||
|
|
||||||
expect(changes).toEqual([
|
|
||||||
{ path: "src/", expanded: true },
|
|
||||||
{ path: "src/", expanded: false },
|
|
||||||
])
|
|
||||||
tree.cleanUp()
|
|
||||||
})
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createPromptState } from "@/context/prompt"
|
|
||||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
|
||||||
|
|
||||||
function PromptInputExample() {
|
|
||||||
const state = createPromptState()
|
|
||||||
const history = createPromptInputHistory()
|
|
||||||
const [controls, setControls] = createStore({
|
|
||||||
agent: "build",
|
|
||||||
variant: undefined as string | undefined,
|
|
||||||
comments: 0,
|
|
||||||
tabs: [] as string[],
|
|
||||||
activeTab: undefined as string | undefined,
|
|
||||||
reviewOpen: false,
|
|
||||||
})
|
|
||||||
const model = {
|
|
||||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
|
||||||
variant: {
|
|
||||||
list: () => ["fast", "thinking"],
|
|
||||||
current: () => controls.variant,
|
|
||||||
set: (variant?: string) => setControls("variant", variant),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const submission = {
|
|
||||||
abort() {},
|
|
||||||
handleSubmit(event: Event) {
|
|
||||||
event.preventDefault()
|
|
||||||
state.reset()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const inputControls = {
|
|
||||||
agents: {
|
|
||||||
available: [{ name: "review", hidden: false, mode: "subagent" }],
|
|
||||||
options: ["build", "review", "plan"],
|
|
||||||
get current() {
|
|
||||||
return controls.agent
|
|
||||||
},
|
|
||||||
loading: false,
|
|
||||||
visible: true,
|
|
||||||
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
|
||||||
},
|
|
||||||
model: {
|
|
||||||
selection: model,
|
|
||||||
paid: true,
|
|
||||||
loading: false,
|
|
||||||
},
|
|
||||||
projects: {
|
|
||||||
available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
|
|
||||||
directory: "/tmp/story",
|
|
||||||
select() {},
|
|
||||||
add() {},
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
id: "story-session",
|
|
||||||
tabs: {
|
|
||||||
active: () => controls.activeTab,
|
|
||||||
all: () => controls.tabs,
|
|
||||||
open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])),
|
|
||||||
setActive: (tab: string) => setControls("activeTab", tab),
|
|
||||||
},
|
|
||||||
reviewPanel: {
|
|
||||||
opened: () => controls.reviewOpen,
|
|
||||||
open: () => setControls("reviewOpen", true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
newLayoutDesigns: true,
|
|
||||||
}
|
|
||||||
const addReviewComment = () => {
|
|
||||||
const comment = controls.comments + 1
|
|
||||||
setControls("comments", comment)
|
|
||||||
state.context.add({
|
|
||||||
type: "file",
|
|
||||||
path: "src/components/prompt-input.tsx",
|
|
||||||
selection: {
|
|
||||||
startLine: 84 + comment,
|
|
||||||
startChar: 0,
|
|
||||||
endLine: 84 + comment,
|
|
||||||
endChar: 0,
|
|
||||||
},
|
|
||||||
comment: `Review comment ${comment}`,
|
|
||||||
commentID: `review-comment-${comment}`,
|
|
||||||
commentOrigin: "review",
|
|
||||||
preview: "export const PromptInput = ...",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<PromptInput controls={inputControls} state={state} history={history} submission={submission} />
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-border-weak-base bg-background-base px-2.5 py-1.5 text-12-medium text-text-base hover:bg-background-stronger"
|
|
||||||
onClick={addReviewComment}
|
|
||||||
>
|
|
||||||
Add review comment
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: "App/PromptInput",
|
|
||||||
id: "app-prompt-input",
|
|
||||||
component: PromptInput,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Basic = {
|
|
||||||
render: () => (
|
|
||||||
<div class="pt-10">
|
|
||||||
<h1 class="mb-4">Prompt Input</h1>
|
|
||||||
<PromptInputExample />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
type JSX,
|
type JSX,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||||
import {
|
import {
|
||||||
ContentPart,
|
ContentPart,
|
||||||
@@ -31,7 +31,10 @@ import {
|
|||||||
FileAttachmentPart,
|
FileAttachmentPart,
|
||||||
} from "@/context/prompt"
|
} from "@/context/prompt"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -43,11 +46,14 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
|||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||||
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||||
@@ -67,104 +73,18 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
|||||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||||
|
import { useDirectoryPicker } from "./directory-picker"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
|
import { useQueries } from "@tanstack/solid-query"
|
||||||
|
import { useQueryOptions } from "@/context/server-sync"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { displayName } from "@/pages/layout/helpers"
|
import { displayName } from "@/pages/layout/helpers"
|
||||||
|
|
||||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
interface PromptInputProps {
|
||||||
|
|
||||||
export type PromptInputHistory = {
|
|
||||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
|
||||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PromptInputSubmission = {
|
|
||||||
abort: () => Promise<void> | void
|
|
||||||
handleSubmit: (event: Event) => Promise<void> | void
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PromptInputControls = {
|
|
||||||
agents: {
|
|
||||||
available: { name: string; hidden?: boolean; mode: string }[]
|
|
||||||
options: string[]
|
|
||||||
current: string
|
|
||||||
loading: boolean
|
|
||||||
visible: boolean
|
|
||||||
select: (name: string | undefined) => void
|
|
||||||
}
|
|
||||||
model: {
|
|
||||||
selection: ReturnType<typeof useLocal>["model"]
|
|
||||||
paid: boolean
|
|
||||||
loading: boolean
|
|
||||||
}
|
|
||||||
projects: {
|
|
||||||
available: { name?: string; worktree: string; sandboxes?: string[] }[]
|
|
||||||
directory: string
|
|
||||||
select: (worktree: string) => void
|
|
||||||
add: (title: string) => void
|
|
||||||
}
|
|
||||||
session: {
|
|
||||||
id?: string
|
|
||||||
tabs: {
|
|
||||||
active: () => string | undefined
|
|
||||||
all: () => string[]
|
|
||||||
open: (tab: string) => void | Promise<void>
|
|
||||||
setActive: (tab: string) => void
|
|
||||||
}
|
|
||||||
reviewPanel: {
|
|
||||||
opened: () => boolean
|
|
||||||
open: () => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newLayoutDesigns: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPromptInputHistory(): PromptInputHistory {
|
|
||||||
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
|
|
||||||
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
|
|
||||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
|
||||||
}
|
|
||||||
|
|
||||||
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
|
||||||
|
|
||||||
function createPromptInputHistoryStore(
|
|
||||||
normal: Store<PromptHistoryState>,
|
|
||||||
setNormal: SetStoreFunction<PromptHistoryState>,
|
|
||||||
shell: Store<PromptHistoryState>,
|
|
||||||
setShell: SetStoreFunction<PromptHistoryState>,
|
|
||||||
): PromptInputHistory {
|
|
||||||
return {
|
|
||||||
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
|
|
||||||
add(prompt, mode, comments) {
|
|
||||||
const current = mode === "shell" ? shell : normal
|
|
||||||
const setCurrent = mode === "shell" ? setShell : setNormal
|
|
||||||
const next = prependHistoryEntry(current.entries, prompt, comments)
|
|
||||||
if (next === current.entries) return
|
|
||||||
setCurrent("entries", next)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPersistedPromptInputHistory() {
|
|
||||||
const [normal, setNormal] = persisted(
|
|
||||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
|
||||||
createStore<PromptHistoryState>({ entries: [] }),
|
|
||||||
)
|
|
||||||
const [shell, setShell] = persisted(
|
|
||||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
|
||||||
createStore<PromptHistoryState>({ entries: [] }),
|
|
||||||
)
|
|
||||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PromptInputProps {
|
|
||||||
class?: string
|
class?: string
|
||||||
variant?: "dock" | "new-session"
|
variant?: "dock" | "new-session"
|
||||||
state?: PromptInputState
|
|
||||||
history?: PromptInputHistory
|
|
||||||
submission?: PromptInputSubmission
|
|
||||||
controls: PromptInputControls
|
|
||||||
ref?: (el: HTMLDivElement) => void
|
ref?: (el: HTMLDivElement) => void
|
||||||
newSessionWorktree?: string
|
newSessionWorktree?: string
|
||||||
onNewSessionWorktreeReset?: () => void
|
onNewSessionWorktreeReset?: () => void
|
||||||
@@ -206,18 +126,27 @@ const EXAMPLES = [
|
|||||||
|
|
||||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryOptions = useQueryOptions()
|
||||||
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const local = useLocal()
|
||||||
const files = useFile()
|
const files = useFile()
|
||||||
const prompt = props.state ?? usePrompt()
|
const prompt = usePrompt()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
const server = useServer()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const providers = useProviders()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const tabs = () => props.controls.session.tabs
|
const pickDirectory = useDirectoryPicker()
|
||||||
|
const settings = useSettings()
|
||||||
|
const tabsStore = useTabs()
|
||||||
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
|
const { params, tabs, view } = useSessionLayout()
|
||||||
let editorRef!: HTMLDivElement
|
let editorRef!: HTMLDivElement
|
||||||
let fileInputRef: HTMLInputElement | undefined
|
let fileInputRef: HTMLInputElement | undefined
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
@@ -275,10 +204,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}).activeFileTab
|
}).activeFileTab
|
||||||
|
|
||||||
const commentInReview = (path: string) => {
|
const commentInReview = (path: string) => {
|
||||||
const sessionID = props.controls.session.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return false
|
if (!sessionID) return false
|
||||||
|
|
||||||
const diffs = sync().data.session_diff[sessionID]
|
const diffs = sync.data.session_diff[sessionID]
|
||||||
if (!diffs) return false
|
if (!diffs) return false
|
||||||
return diffs.some((diff) => diff.file === path)
|
return diffs.some((diff) => diff.file === path)
|
||||||
}
|
}
|
||||||
@@ -308,14 +237,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
||||||
if (wantsReview) {
|
if (wantsReview) {
|
||||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
layout.fileTree.setTab("changes")
|
layout.fileTree.setTab("changes")
|
||||||
tabs().setActive("review")
|
tabs().setActive("review")
|
||||||
queueCommentFocus()
|
queueCommentFocus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
layout.fileTree.setTab("all")
|
layout.fileTree.setTab("all")
|
||||||
const tab = files.tab(item.path)
|
const tab = files.tab(item.path)
|
||||||
void tabs().open(tab)
|
void tabs().open(tab)
|
||||||
@@ -340,8 +269,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
return paths
|
return paths
|
||||||
})
|
})
|
||||||
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
const working = createMemo(() => sync.data.session_working(params.id ?? ""))
|
||||||
const imageAttachments = createMemo(() =>
|
const imageAttachments = createMemo(() =>
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
@@ -418,14 +347,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const hasUserPrompt = createMemo(() => {
|
const hasUserPrompt = createMemo(() => {
|
||||||
const sessionID = props.controls.session.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return false
|
if (!sessionID) return false
|
||||||
const messages = sync().data.message[sessionID]
|
const messages = sync.data.message[sessionID]
|
||||||
if (!messages) return false
|
if (!messages) return false
|
||||||
return messages.some((m) => m.role === "user")
|
return messages.some((m) => m.role === "user")
|
||||||
})
|
})
|
||||||
|
|
||||||
const history = props.history ?? createPersistedPromptInputHistory()
|
const [history, setHistory] = persisted(
|
||||||
|
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||||
|
createStore<{
|
||||||
|
entries: PromptHistoryStoredEntry[]
|
||||||
|
}>({
|
||||||
|
entries: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const [shellHistory, setShellHistory] = persisted(
|
||||||
|
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||||
|
createStore<{
|
||||||
|
entries: PromptHistoryStoredEntry[]
|
||||||
|
}>({
|
||||||
|
entries: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const suggest = createMemo(() => !hasUserPrompt())
|
const suggest = createMemo(() => !hasUserPrompt())
|
||||||
|
|
||||||
@@ -530,7 +474,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const pick = () => {
|
const pick = () => {
|
||||||
pickAttachmentFiles({
|
pickAttachmentFiles({
|
||||||
picker: platform.openAttachmentPickerDialog,
|
picker: platform.openAttachmentPickerDialog,
|
||||||
directory: () => sdk().directory,
|
directory: () => sdk.directory,
|
||||||
fallback: () => fileInputRef?.click(),
|
fallback: () => fileInputRef?.click(),
|
||||||
onFile: addAttachment,
|
onFile: addAttachment,
|
||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
@@ -629,8 +573,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.controls.session.id
|
params.id
|
||||||
if (props.controls.session.id) return
|
if (params.id) return
|
||||||
if (!suggest()) return
|
if (!suggest()) return
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
|
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
|
||||||
@@ -659,10 +603,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agentList = createMemo(() =>
|
const agentList = createMemo(() =>
|
||||||
props.controls.agents.available
|
sync.data.agent
|
||||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
||||||
)
|
)
|
||||||
|
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
|
||||||
|
|
||||||
const handleAtSelect = (option: AtOption | undefined) => {
|
const handleAtSelect = (option: AtOption | undefined) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
@@ -728,7 +673,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
type: "builtin" as const,
|
type: "builtin" as const,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const custom = sync().data.command.map((cmd) => ({
|
const custom = sync.data.command.map((cmd) => ({
|
||||||
id: `custom.${cmd.name}`,
|
id: `custom.${cmd.name}`,
|
||||||
trigger: cmd.name,
|
trigger: cmd.name,
|
||||||
title: cmd.name,
|
title: cmd.name,
|
||||||
@@ -1088,7 +1033,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||||
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
|
const currentHistory = mode === "shell" ? shellHistory : history
|
||||||
|
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
||||||
|
const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
|
||||||
|
if (next === currentHistory.entries) return
|
||||||
|
setCurrentHistory("entries", next)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -1133,7 +1082,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const navigateHistory = (direction: "up" | "down") => {
|
const navigateHistory = (direction: "up" | "down") => {
|
||||||
const result = navigatePromptHistory({
|
const result = navigatePromptHistory({
|
||||||
direction,
|
direction,
|
||||||
entries: history.entries(store.mode),
|
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
|
||||||
historyIndex: store.historyIndex,
|
historyIndex: store.historyIndex,
|
||||||
currentPrompt: prompt.current(),
|
currentPrompt: prompt.current(),
|
||||||
currentComments: historyComments(),
|
currentComments: historyComments(),
|
||||||
@@ -1147,7 +1096,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||||
prompt,
|
|
||||||
editor: () => editorRef,
|
editor: () => editorRef,
|
||||||
isDialogActive: () => !!dialog.active,
|
isDialogActive: () => !!dialog.active,
|
||||||
setDraggingType: (type) => setStore("draggingType", type),
|
setDraggingType: (type) => setStore("draggingType", type),
|
||||||
@@ -1157,7 +1105,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
},
|
},
|
||||||
addPart,
|
addPart,
|
||||||
readClipboardImage: platform.readClipboardImage,
|
readClipboardImage: platform.readClipboardImage,
|
||||||
getPathForFile: platform.getPathForFile,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const fileAttachmentInput = () => (
|
const fileAttachmentInput = () => (
|
||||||
@@ -1175,41 +1122,38 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||||
// Check provider variants directly: `variants` also includes the UI-only default option.
|
// Check provider variants directly: `variants` also includes the UI-only default option.
|
||||||
const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0)
|
const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
|
||||||
const accepting = createMemo(() => {
|
const accepting = createMemo(() => {
|
||||||
const id = props.controls.session.id
|
const id = params.id
|
||||||
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
return permission.isAutoAccepting(id, sdk().directory)
|
return permission.isAutoAccepting(id, sdk.directory)
|
||||||
})
|
})
|
||||||
|
|
||||||
const { abort, handleSubmit } =
|
const { abort, handleSubmit } = createPromptSubmit({
|
||||||
props.submission ??
|
info,
|
||||||
createPromptSubmit({
|
imageAttachments,
|
||||||
prompt,
|
commentCount,
|
||||||
info,
|
autoAccept: () => accepting(),
|
||||||
imageAttachments,
|
mode: () => store.mode,
|
||||||
commentCount,
|
working,
|
||||||
autoAccept: () => accepting(),
|
editor: () => editorRef,
|
||||||
mode: () => store.mode,
|
queueScroll,
|
||||||
working,
|
promptLength,
|
||||||
editor: () => editorRef,
|
addToHistory,
|
||||||
queueScroll,
|
resetHistoryNavigation: () => {
|
||||||
promptLength,
|
resetHistoryNavigation(true)
|
||||||
addToHistory,
|
},
|
||||||
resetHistoryNavigation: () => {
|
setMode: (mode) => setStore("mode", mode),
|
||||||
resetHistoryNavigation(true)
|
setPopover: (popover) => setStore("popover", popover),
|
||||||
},
|
newSessionWorktree: () => props.newSessionWorktree,
|
||||||
setMode: (mode) => setStore("mode", mode),
|
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||||
setPopover: (popover) => setStore("popover", popover),
|
shouldQueue: props.shouldQueue,
|
||||||
newSessionWorktree: () => props.newSessionWorktree,
|
onQueue: props.onQueue,
|
||||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
onAbort: props.onAbort,
|
||||||
shouldQueue: props.shouldQueue,
|
onSubmit: props.onSubmit,
|
||||||
onQueue: props.onQueue,
|
})
|
||||||
onAbort: props.onAbort,
|
|
||||||
onSubmit: props.onSubmit,
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
||||||
@@ -1373,9 +1317,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentsLoading = () => props.controls.agents.loading
|
const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
|
||||||
|
queries: [
|
||||||
|
queryOptions.agents(pathKey(sdk.directory)),
|
||||||
|
queryOptions.providers(null),
|
||||||
|
queryOptions.providers(pathKey(sdk.directory)),
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const agentsLoading = () => agentsQuery.isLoading
|
||||||
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
||||||
const providersLoading = () => props.controls.model.loading
|
const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
|
||||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||||
|
|
||||||
const [promptReady] = createResource(
|
const [promptReady] = createResource(
|
||||||
@@ -1390,23 +1342,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||||
loading: providersLoading(),
|
loading: providersLoading(),
|
||||||
paid: props.controls.model.paid,
|
paid: providers.paid().length > 0,
|
||||||
title: language.t("command.model.choose"),
|
title: language.t("command.model.choose"),
|
||||||
keybind: command.keybind("model.choose"),
|
keybind: command.keybind("model.choose"),
|
||||||
model: props.controls.model.selection,
|
model: local.model,
|
||||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
providerID: local.model.current()?.provider?.id,
|
||||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"),
|
||||||
style: control(),
|
style: control(),
|
||||||
onClose: restoreFocus,
|
onClose: restoreFocus,
|
||||||
onUnpaidClick: () => {
|
onUnpaidClick: () => {
|
||||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const newSession = () => props.variant === "new-session"
|
const newSession = () => props.variant === "new-session"
|
||||||
const projects = createMemo(() => props.controls.projects.available)
|
const projects = createMemo(() => layout.projects.list())
|
||||||
const projectForDirectory = (directory: string | undefined) => {
|
const projectForDirectory = (directory: string | undefined) => {
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
const key = pathKey(directory)
|
const key = pathKey(directory)
|
||||||
@@ -1414,13 +1366,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory))
|
const selectedProject = createMemo(() => projectForDirectory(sdk.directory))
|
||||||
const projectResults = createMemo(() => {
|
const projectResults = createMemo(() => {
|
||||||
const search = picker.projectSearch.trim().toLowerCase()
|
const search = picker.projectSearch.trim().toLowerCase()
|
||||||
if (!search) return projects()
|
if (!search) return projects()
|
||||||
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
|
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
|
||||||
})
|
})
|
||||||
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0)
|
||||||
const selectProject = (worktree: string) => {
|
const selectProject = (worktree: string) => {
|
||||||
setPicker({
|
setPicker({
|
||||||
projectOpen: false,
|
projectOpen: false,
|
||||||
@@ -1430,11 +1382,33 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
restoreFocus()
|
restoreFocus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
props.controls.projects.select(worktree)
|
layout.projects.open(worktree)
|
||||||
restoreFocus()
|
server.projects.touch(worktree)
|
||||||
|
|
||||||
|
// On the draft route, retarget the existing draft in place so we keep the same
|
||||||
|
// draft id (and its tab/prompt) instead of spawning a new draft for the new directory.
|
||||||
|
const draftID = search.draftId
|
||||||
|
if (draftID) {
|
||||||
|
tabsStore.updateDraft(draftID, { server: server.key, directory: worktree })
|
||||||
|
restoreFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(`/${base64Encode(worktree)}/session`)
|
||||||
}
|
}
|
||||||
const addProject = () => {
|
const addProject = () => {
|
||||||
props.controls.projects.add(language.t("command.project.open"))
|
const conn = server.current
|
||||||
|
if (!conn) return
|
||||||
|
const select = (result: string | string[] | null) => {
|
||||||
|
const directory = Array.isArray(result) ? result[0] : result
|
||||||
|
if (!directory) return
|
||||||
|
selectProject(directory)
|
||||||
|
}
|
||||||
|
pickDirectory({
|
||||||
|
server: conn,
|
||||||
|
title: language.t("command.project.open"),
|
||||||
|
onSelect: select,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectPickerState = createMemo<ComposerPickerState>(() => ({
|
const projectPickerState = createMemo<ComposerPickerState>(() => ({
|
||||||
@@ -1475,11 +1449,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||||
title: language.t("command.agent.cycle"),
|
title: language.t("command.agent.cycle"),
|
||||||
keybind: command.keybind("agent.cycle"),
|
keybind: command.keybind("agent.cycle"),
|
||||||
options: props.controls.agents.options,
|
options: agentNames(),
|
||||||
current: props.controls.agents.current,
|
current: local.agent.current()?.name ?? "",
|
||||||
style: control(),
|
style: control(),
|
||||||
onSelect: (value) => {
|
onSelect: (value) => {
|
||||||
props.controls.agents.select(value)
|
local.agent.set(value)
|
||||||
restoreFocus()
|
restoreFocus()
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -1511,7 +1485,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.controls.newLayoutDesigns}>
|
<Match when={settings.general.newLayoutDesigns()}>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<DockShellForm
|
<DockShellForm
|
||||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||||
@@ -1631,7 +1605,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
data-component="prompt-variant-control"
|
data-component="prompt-variant-control"
|
||||||
classList={{
|
classList={{
|
||||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
!local.model.variant.current() && !store.variantOpen,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
@@ -1643,11 +1617,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Select
|
<Select
|
||||||
size="normal"
|
size="normal"
|
||||||
options={variants()}
|
options={variants()}
|
||||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
current={local.model.variant.current() ?? "default"}
|
||||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
onOpenChange={(open) => setStore("variantOpen", open)}
|
onOpenChange={(open) => setStore("variantOpen", open)}
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
local.model.variant.set(value === "default" ? undefined : value)
|
||||||
restoreFocus()
|
restoreFocus()
|
||||||
}}
|
}}
|
||||||
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
||||||
@@ -1887,10 +1861,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
size="normal"
|
size="normal"
|
||||||
options={props.controls.agents.options}
|
options={agentNames()}
|
||||||
current={props.controls.agents.current}
|
current={local.agent.current()?.name ?? ""}
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
props.controls.agents.select(value)
|
local.agent.set(value)
|
||||||
restoreFocus()
|
restoreFocus()
|
||||||
}}
|
}}
|
||||||
class="capitalize max-w-[160px] text-text-base"
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
@@ -1909,7 +1883,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.controls.model.paid}
|
when={providers.paid().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -1926,22 +1900,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
style={control()}
|
style={control()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||||
dialog.show(() => (
|
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||||
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
|
|
||||||
))
|
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
id={local.model.current()?.provider?.id ?? ""}
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<span class="truncate">
|
<span class="truncate">
|
||||||
{props.controls.model.selection.current()?.name ??
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
</span>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1955,7 +1926,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
keybind={command.keybind("model.choose")}
|
keybind={command.keybind("model.choose")}
|
||||||
>
|
>
|
||||||
<ModelSelectorPopover
|
<ModelSelectorPopover
|
||||||
model={props.controls.model.selection}
|
model={local.model}
|
||||||
triggerAs={Button}
|
triggerAs={Button}
|
||||||
triggerProps={{
|
triggerProps={{
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
@@ -1966,16 +1937,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
onClose={restoreFocus}
|
onClose={restoreFocus}
|
||||||
>
|
>
|
||||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
id={local.model.current()?.provider?.id ?? ""}
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<span class="truncate">
|
<span class="truncate">
|
||||||
{props.controls.model.selection.current()?.name ??
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
</span>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
</ModelSelectorPopover>
|
</ModelSelectorPopover>
|
||||||
@@ -1996,10 +1966,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Select
|
<Select
|
||||||
size="normal"
|
size="normal"
|
||||||
options={variants()}
|
options={variants()}
|
||||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
current={local.model.variant.current() ?? "default"}
|
||||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
local.model.variant.set(value === "default" ? undefined : value)
|
||||||
restoreFocus()
|
restoreFocus()
|
||||||
}}
|
}}
|
||||||
class="capitalize max-w-[160px] text-text-base"
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
|
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { uuid } from "@/utils/uuid"
|
import { uuid } from "@/utils/uuid"
|
||||||
import { getCursorPosition } from "./editor-dom"
|
import { getCursorPosition } from "./editor-dom"
|
||||||
@@ -26,18 +26,16 @@ function dataUrl(file: File, mime: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PromptAttachmentsInput = {
|
type PromptAttachmentsInput = {
|
||||||
prompt: ReturnType<typeof usePrompt>
|
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
isDialogActive: () => boolean
|
isDialogActive: () => boolean
|
||||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||||
focusEditor: () => void
|
focusEditor: () => void
|
||||||
addPart: (part: ContentPart) => boolean
|
addPart: (part: ContentPart) => boolean
|
||||||
readClipboardImage?: () => Promise<File | null>
|
readClipboardImage?: () => Promise<File | null>
|
||||||
getPathForFile?: (file: File) => string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
const prompt = input.prompt
|
const prompt = usePrompt()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const warn = () => {
|
const warn = () => {
|
||||||
@@ -64,7 +62,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
type: "image",
|
type: "image",
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
sourcePath: input.getPathForFile?.(file) || undefined,
|
|
||||||
mime,
|
mime,
|
||||||
dataUrl: url,
|
dataUrl: url,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,31 +75,6 @@ describe("buildRequestParts", () => {
|
|||||||
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves an external attachment source path for the model", () => {
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt: [],
|
|
||||||
context: [],
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
type: "image",
|
|
||||||
id: "img_external",
|
|
||||||
filename: "opencode.global.dat",
|
|
||||||
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
|
||||||
mime: "text/plain",
|
|
||||||
dataUrl: "data:text/plain;base64,AAA",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
text: "inspect this",
|
|
||||||
messageID: "msg_external",
|
|
||||||
sessionID: "ses_external",
|
|
||||||
sessionDirectory: "C:\\Repos\\sst\\opencode",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
|
|
||||||
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("deduplicates context files when prompt already includes same path", () => {
|
test("deduplicates context files when prompt already includes same path", () => {
|
||||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||||||
type: "file",
|
type: "file",
|
||||||
mime: attachment.mime,
|
mime: attachment.mime,
|
||||||
url: attachment.dataUrl,
|
url: attachment.dataUrl,
|
||||||
filename: attachment.sourcePath ?? attachment.filename,
|
filename: attachment.filename,
|
||||||
} satisfies PromptRequestPart
|
} satisfies PromptRequestPart
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||||
<div class="flex items-center text-[12px] min-w-0 font-medium leading-5">
|
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||||
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
||||||
<Show when={item.selection}>
|
<Show when={item.selection}>
|
||||||
{(sel) => (
|
{(sel) => (
|
||||||
|
|||||||
@@ -26,22 +26,6 @@ let selected = "/repo/worktree-a"
|
|||||||
let variant: string | undefined
|
let variant: string | undefined
|
||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
const prompt = {
|
|
||||||
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
|
|
||||||
current: () => promptValue,
|
|
||||||
cursor: () => 0,
|
|
||||||
dirty: () => true,
|
|
||||||
reset: () => undefined,
|
|
||||||
set: () => undefined,
|
|
||||||
context: {
|
|
||||||
add: () => undefined,
|
|
||||||
remove: () => undefined,
|
|
||||||
removeComment: () => undefined,
|
|
||||||
updateComment: () => undefined,
|
|
||||||
replaceComments: () => undefined,
|
|
||||||
items: () => [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientFor = (directory: string) => {
|
const clientFor = (directory: string) => {
|
||||||
createdClients.push(directory)
|
createdClients.push(directory)
|
||||||
@@ -89,7 +73,6 @@ beforeAll(async () => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@opencode-ai/ui/toast", () => ({
|
mock.module("@opencode-ai/ui/toast", () => ({
|
||||||
Toast: { Region: () => null },
|
|
||||||
showToast: () => 0,
|
showToast: () => 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -133,7 +116,16 @@ beforeAll(async () => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/prompt", () => ({
|
mock.module("@/context/prompt", () => ({
|
||||||
usePrompt: () => prompt,
|
usePrompt: () => ({
|
||||||
|
current: () => promptValue,
|
||||||
|
reset: () => undefined,
|
||||||
|
set: () => undefined,
|
||||||
|
context: {
|
||||||
|
add: () => undefined,
|
||||||
|
remove: () => undefined,
|
||||||
|
items: () => [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/layout", () => ({
|
mock.module("@/context/layout", () => ({
|
||||||
@@ -155,12 +147,12 @@ beforeAll(async () => {
|
|||||||
return clientFor(opts.directory)
|
return clientFor(opts.directory)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return () => sdk
|
return sdk
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => () => ({
|
useSync: () => ({
|
||||||
data: { command: [] },
|
data: { command: [] },
|
||||||
session: {
|
session: {
|
||||||
optimistic: {
|
optimistic: {
|
||||||
@@ -184,7 +176,7 @@ beforeAll(async () => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/server-sync", () => ({
|
mock.module("@/context/server-sync", () => ({
|
||||||
useServerSync: () => () => ({
|
useServerSync: () => ({
|
||||||
child: (directory: string) => {
|
child: (directory: string) => {
|
||||||
syncedDirectories.push(directory)
|
syncedDirectories.push(directory)
|
||||||
storedSessions[directory] ??= []
|
storedSessions[directory] ??= []
|
||||||
@@ -240,7 +232,6 @@ beforeEach(() => {
|
|||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
test("reads the latest worktree accessor value per submit", async () => {
|
test("reads the latest worktree accessor value per submit", async () => {
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
prompt,
|
|
||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
@@ -278,7 +269,6 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
test("applies auto-accept to newly created sessions", async () => {
|
test("applies auto-accept to newly created sessions", async () => {
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
prompt,
|
|
||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
@@ -309,7 +299,6 @@ describe("prompt submit worktree selection", () => {
|
|||||||
variant = "high"
|
variant = "high"
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
prompt,
|
|
||||||
info: () => ({ id: "session-1" }),
|
info: () => ({ id: "session-1" }),
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
@@ -341,7 +330,6 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
prompt,
|
|
||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { batch, type Accessor } from "solid-js"
|
|||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync, type DirectorySync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
@@ -40,9 +40,9 @@ export type FollowupDraft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
client: DirectorySDK["client"]
|
client: ReturnType<typeof useSDK>["client"]
|
||||||
serverSync: ServerSync
|
serverSync: ReturnType<typeof useServerSync>
|
||||||
sync: DirectorySync
|
sync: ReturnType<typeof useSync>
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
messageID?: string
|
messageID?: string
|
||||||
optimisticBusy?: boolean
|
optimisticBusy?: boolean
|
||||||
@@ -174,7 +174,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PromptSubmitInput = {
|
type PromptSubmitInput = {
|
||||||
prompt: ReturnType<typeof usePrompt>
|
|
||||||
info: Accessor<{ id: string } | undefined>
|
info: Accessor<{ id: string } | undefined>
|
||||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||||
commentCount: Accessor<number>
|
commentCount: Accessor<number>
|
||||||
@@ -212,14 +211,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const prompt = input.prompt
|
const prompt = usePrompt()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
if (err && typeof err === "object" && "data" in err) {
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
@@ -234,8 +233,8 @@ 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.todo.set(sessionID, [])
|
||||||
const [, setStore] = serverSync().child(sdk().directory)
|
const [, setStore] = serverSync.child(sdk.directory)
|
||||||
setStore("todo", sessionID, [])
|
setStore("todo", sessionID, [])
|
||||||
|
|
||||||
input.onAbort?.()
|
input.onAbort?.()
|
||||||
@@ -248,8 +247,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
pending.delete(key)
|
pending.delete(key)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk()
|
return sdk.client.session
|
||||||
.client.session.abort({
|
.abort({
|
||||||
sessionID,
|
sessionID,
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -282,7 +281,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seed = (dir: string, info: Session) => {
|
const seed = (dir: string, info: Session) => {
|
||||||
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)
|
||||||
const next = [...list]
|
const next = [...list]
|
||||||
@@ -322,13 +321,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
input.addToHistory(currentPrompt, mode)
|
input.addToHistory(currentPrompt, mode)
|
||||||
input.resetHistoryNavigation()
|
input.resetHistoryNavigation()
|
||||||
|
|
||||||
const projectDirectory = sdk().directory
|
const projectDirectory = sdk.directory
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
|
|
||||||
let sessionDirectory = projectDirectory
|
let sessionDirectory = projectDirectory
|
||||||
let client = sdk().client
|
let client = sdk.client
|
||||||
|
|
||||||
if (isNewSession) {
|
if (isNewSession) {
|
||||||
if (worktreeSelection === "create") {
|
if (worktreeSelection === "create") {
|
||||||
@@ -350,7 +349,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
WorktreeState.pending(sdk.scope, createdWorktree.directory)
|
||||||
sessionDirectory = createdWorktree.directory
|
sessionDirectory = createdWorktree.directory
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,11 +358,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionDirectory !== projectDirectory) {
|
if (sessionDirectory !== projectDirectory) {
|
||||||
client = sdk().createClient({
|
client = sdk.createClient({
|
||||||
directory: sessionDirectory,
|
directory: sessionDirectory,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
serverSync().child(sessionDirectory)
|
serverSync.child(sessionDirectory)
|
||||||
}
|
}
|
||||||
|
|
||||||
input.onNewSessionWorktreeReset?.()
|
input.onNewSessionWorktreeReset?.()
|
||||||
@@ -471,7 +470,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (text.startsWith("/")) {
|
if (text.startsWith("/")) {
|
||||||
const [cmdName, ...args] = text.split(" ")
|
const [cmdName, ...args] = text.split(" ")
|
||||||
const commandName = cmdName.slice(1)
|
const commandName = cmdName.slice(1)
|
||||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
const customCommand = sync.data.command.find((c) => c.name === commandName)
|
||||||
if (customCommand) {
|
if (customCommand) {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
client.session
|
||||||
@@ -505,7 +504,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
|
|
||||||
const removeOptimisticMessage = () => {
|
const removeOptimisticMessage = () => {
|
||||||
sync().session.optimistic.remove({
|
sync.session.optimistic.remove({
|
||||||
directory: sessionDirectory,
|
directory: sessionDirectory,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
messageID,
|
messageID,
|
||||||
@@ -516,17 +515,17 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
clearInput()
|
clearInput()
|
||||||
|
|
||||||
const waitForWorktree = async () => {
|
const waitForWorktree = async () => {
|
||||||
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
const worktree = WorktreeState.get(sdk.scope, sessionDirectory)
|
||||||
if (!worktree || worktree.status !== "pending") return true
|
if (!worktree || worktree.status !== "pending") return true
|
||||||
|
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync().set("session_status", session.id, { type: "busy" })
|
sync.set("session_status", session.id, { type: "busy" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync().set("session_status", session.id, { type: "idle" })
|
sync.set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
removeOptimisticMessage()
|
removeOptimisticMessage()
|
||||||
restoreCommentItems(commentItems)
|
restoreCommentItems(commentItems)
|
||||||
@@ -560,14 +559,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await Promise.race([
|
const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(
|
||||||
WorktreeState.wait(sdk().scope, sessionDirectory),
|
() => {
|
||||||
abortWait,
|
if (timer.id === undefined) return
|
||||||
timeout,
|
clearTimeout(timer.id)
|
||||||
]).finally(() => {
|
},
|
||||||
if (timer.id === undefined) return
|
)
|
||||||
clearTimeout(timer.id)
|
|
||||||
})
|
|
||||||
pending.delete(pendingKey(session.id))
|
pending.delete(pendingKey(session.id))
|
||||||
if (controller.signal.aborted) return false
|
if (controller.signal.aborted) return false
|
||||||
if (result.status === "failed") throw new Error(result.message)
|
if (result.status === "failed") throw new Error(result.message)
|
||||||
@@ -576,8 +573,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
client,
|
client,
|
||||||
sync: sync(),
|
sync,
|
||||||
serverSync: serverSync(),
|
serverSync,
|
||||||
draft,
|
draft,
|
||||||
messageID,
|
messageID,
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
@@ -585,7 +582,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
pending.delete(pendingKey(session.id))
|
pending.delete(pendingKey(session.id))
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync().set("session_status", session.id, { type: "idle" })
|
sync.set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
})
|
})
|
||||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -96,13 +96,13 @@ export function SessionContextTab() {
|
|||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const { params, view } = useSessionLayout()
|
const { params, view } = useSessionLayout()
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
|
|
||||||
const messages = createMemo(
|
const messages = createMemo(
|
||||||
() => {
|
() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return emptyMessages
|
if (!id) return emptyMessages
|
||||||
return (sync().data.message[id] ?? []) as Message[]
|
return (sync.data.message[id] ?? []) as Message[]
|
||||||
},
|
},
|
||||||
emptyMessages,
|
emptyMessages,
|
||||||
{ equals: same },
|
{ equals: same },
|
||||||
@@ -180,7 +180,7 @@ export function SessionContextTab() {
|
|||||||
if (!c?.input) return []
|
if (!c?.input) return []
|
||||||
return estimateSessionContextBreakdown({
|
return estimateSessionContextBreakdown({
|
||||||
messages: messages(),
|
messages: messages(),
|
||||||
parts: sync().data.part as Record<string, Part[] | undefined>,
|
parts: sync.data.part as Record<string, Part[] | undefined>,
|
||||||
input: c.input,
|
input: c.input,
|
||||||
systemPrompt: systemPrompt(),
|
systemPrompt: systemPrompt(),
|
||||||
})
|
})
|
||||||
@@ -221,7 +221,7 @@ export function SessionContextTab() {
|
|||||||
let scroll: HTMLDivElement | undefined
|
let scroll: HTMLDivElement | undefined
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
let pending: { x: number; y: number } | undefined
|
let pending: { x: number; y: number } | undefined
|
||||||
const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[]
|
const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
|
||||||
|
|
||||||
const restoreScroll = () => {
|
const restoreScroll = () => {
|
||||||
const el = scroll
|
const el = scroll
|
||||||
|
|||||||
@@ -155,9 +155,11 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||||
const os = createMemo(() => detectOS(platform))
|
const os = createMemo(() => detectOS(platform))
|
||||||
const isV2 = settings.general.newLayoutDesigns
|
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
|
||||||
const search = settings.visibility.search
|
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
|
||||||
const status = settings.visibility.status
|
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
|
||||||
|
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
|
||||||
|
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
|
||||||
|
|
||||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||||
finder: true,
|
finder: true,
|
||||||
@@ -229,7 +231,7 @@ export function SessionHeader() {
|
|||||||
)
|
)
|
||||||
const opening = createMemo(() => openRequest.app !== undefined)
|
const opening = createMemo(() => openRequest.app !== undefined)
|
||||||
const tint = createMemo(() =>
|
const tint = createMemo(() =>
|
||||||
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
|
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
|
||||||
)
|
)
|
||||||
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
||||||
statusVisible: status(),
|
statusVisible: status(),
|
||||||
@@ -320,7 +322,7 @@ export function SessionHeader() {
|
|||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount()}>
|
<Portal mount={mount()}>
|
||||||
<Show
|
<Show
|
||||||
when={isV2}
|
when={isDesktopV2}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when={projectDirectory()}>
|
<Show when={projectDirectory()}>
|
||||||
@@ -442,21 +444,23 @@ export function SessionHeader() {
|
|||||||
<StatusPopover />
|
<StatusPopover />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Show>
|
</Show>
|
||||||
<TooltipKeybind
|
<Show when={term()}>
|
||||||
title={language.t("command.terminal.toggle")}
|
<TooltipKeybind
|
||||||
keybind={command.keybind("terminal.toggle")}
|
title={language.t("command.terminal.toggle")}
|
||||||
>
|
keybind={command.keybind("terminal.toggle")}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
|
||||||
onClick={toggleTerminal}
|
|
||||||
aria-label={language.t("command.terminal.toggle")}
|
|
||||||
aria-expanded={view().terminal.opened()}
|
|
||||||
aria-controls="terminal-panel"
|
|
||||||
>
|
>
|
||||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</TooltipKeybind>
|
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||||
|
onClick={toggleTerminal}
|
||||||
|
aria-label={language.t("command.terminal.toggle")}
|
||||||
|
aria-expanded={view().terminal.opened()}
|
||||||
|
aria-controls="terminal-panel"
|
||||||
|
>
|
||||||
|
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
@@ -475,30 +479,32 @@ export function SessionHeader() {
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
|
|
||||||
<TooltipKeybind
|
<Show when={tree()}>
|
||||||
title={language.t("command.fileTree.toggle")}
|
<TooltipKeybind
|
||||||
keybind={command.keybind("fileTree.toggle")}
|
title={language.t("command.fileTree.toggle")}
|
||||||
>
|
keybind={command.keybind("fileTree.toggle")}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
|
||||||
onClick={() => layout.fileTree.toggle()}
|
|
||||||
aria-label={language.t("command.fileTree.toggle")}
|
|
||||||
aria-expanded={layout.fileTree.opened()}
|
|
||||||
aria-controls="file-tree-panel"
|
|
||||||
>
|
>
|
||||||
<div class="relative flex items-center justify-center size-4">
|
<Button
|
||||||
<Icon
|
variant="ghost"
|
||||||
size="small"
|
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
onClick={() => layout.fileTree.toggle()}
|
||||||
classList={{
|
aria-label={language.t("command.fileTree.toggle")}
|
||||||
"text-icon-strong": layout.fileTree.opened(),
|
aria-expanded={layout.fileTree.opened()}
|
||||||
"text-icon-weak": !layout.fileTree.opened(),
|
aria-controls="file-tree-panel"
|
||||||
}}
|
>
|
||||||
/>
|
<div class="relative flex items-center justify-center size-4">
|
||||||
</div>
|
<Icon
|
||||||
</Button>
|
size="small"
|
||||||
</TooltipKeybind>
|
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||||
|
classList={{
|
||||||
|
"text-icon-strong": layout.fileTree.opened(),
|
||||||
|
"text-icon-weak": !layout.fileTree.opened(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,24 +20,24 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
|
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
|
||||||
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
||||||
const current = createMemo(() => {
|
const current = createMemo(() => {
|
||||||
const selection = props.worktree
|
const selection = props.worktree
|
||||||
if (options().includes(selection)) return selection
|
if (options().includes(selection)) return selection
|
||||||
return MAIN_WORKTREE
|
return MAIN_WORKTREE
|
||||||
})
|
})
|
||||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
|
||||||
const isWorktree = createMemo(() => {
|
const isWorktree = createMemo(() => {
|
||||||
const project = sync().project
|
const project = sync.project
|
||||||
if (!project) return false
|
if (!project) return false
|
||||||
return sdk().directory !== project.worktree
|
return sdk.directory !== project.worktree
|
||||||
})
|
})
|
||||||
|
|
||||||
const label = (value: string) => {
|
const label = (value: string) => {
|
||||||
if (value === MAIN_WORKTREE) {
|
if (value === MAIN_WORKTREE) {
|
||||||
if (isWorktree()) return language.t("session.new.worktree.main")
|
if (isWorktree()) return language.t("session.new.worktree.main")
|
||||||
const branch = sync().data.vcs?.branch
|
const branch = sync.data.vcs?.branch
|
||||||
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
|
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
|
||||||
return language.t("session.new.worktree.main")
|
return language.t("session.new.worktree.main")
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
{label(current())}
|
{label(current())}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={sync().project}>
|
<Show when={sync.project}>
|
||||||
{(project) => (
|
{(project) => (
|
||||||
<div class="flex items-start justify-center gap-3 min-h-5">
|
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||||
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
|
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
() =>
|
||||||
serverSdk()
|
serverSdk.client.pty
|
||||||
.client.pty.shells()
|
.shells()
|
||||||
.then((res) => res.data ?? [])
|
.then((res) => res.data ?? [])
|
||||||
.catch(() => [] as ShellOption[]),
|
.catch(() => [] as ShellOption[]),
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
@@ -151,11 +151,11 @@ export const SettingsGeneral: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||||
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
|
||||||
|
|
||||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||||
const list = shells.latest
|
const list = shells.latest
|
||||||
const current = serverSync().data.config.shell
|
const current = serverSync.data.config.shell
|
||||||
|
|
||||||
const nameCounts = new Map<string, number>()
|
const nameCounts = new Map<string, number>()
|
||||||
for (const s of list) {
|
for (const s of list) {
|
||||||
@@ -290,7 +290,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
if (option.value === currentShell()) return
|
if (option.value === currentShell()) return
|
||||||
serverSync().updateConfig({ shell: option.value })
|
serverSync.updateConfig({ shell: option.value })
|
||||||
}}
|
}}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const SettingsProvidersContent: Component = () => {
|
|||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
const isConfigCustom = (providerID: string) => {
|
const isConfigCustom = (providerID: string) => {
|
||||||
const provider = serverSync().data.config.provider?.[providerID]
|
const provider = serverSync.data.config.provider?.[providerID]
|
||||||
if (!provider) return false
|
if (!provider) return false
|
||||||
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
||||||
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
||||||
@@ -91,11 +91,11 @@ const SettingsProvidersContent: Component = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
const before = serverSync.data.config.disabled_providers ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
serverSync().set("config", "disabled_providers", next)
|
serverSync.set("config", "disabled_providers", next)
|
||||||
|
|
||||||
await serverSync()
|
await serverSync
|
||||||
.updateConfig({ disabled_providers: next })
|
.updateConfig({ disabled_providers: next })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -106,7 +106,7 @@ const SettingsProvidersContent: Component = () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
serverSync().set("config", "disabled_providers", before)
|
serverSync.set("config", "disabled_providers", before)
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
})
|
})
|
||||||
@@ -114,16 +114,14 @@ const SettingsProvidersContent: Component = () => {
|
|||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
if (isConfigCustom(providerID)) {
|
if (isConfigCustom(providerID)) {
|
||||||
await serverSDK()
|
await serverSDK.client.auth.remove({ providerID }).catch(() => undefined)
|
||||||
.client.auth.remove({ providerID })
|
|
||||||
.catch(() => undefined)
|
|
||||||
await disableProvider(providerID, name)
|
await disableProvider(providerID, name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await serverSDK()
|
await serverSDK.client.auth
|
||||||
.client.auth.remove({ providerID })
|
.remove({ providerID })
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await serverSDK().client.global.dispose()
|
await serverSDK.client.global.dispose()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
icon: "circle-check",
|
icon: "circle-check",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnecti
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={serverCtx().queryClient}>
|
<QueryClientProvider client={serverCtx().queryClient}>
|
||||||
<ServerSDKProvider server={() => props.server}>
|
<ServerSDKProvider server={props.server}>
|
||||||
<ServerSyncProvider>
|
<ServerSyncProvider>
|
||||||
<ModelsProvider>{props.children}</ModelsProvider>
|
<ModelsProvider>{props.children}</ModelsProvider>
|
||||||
</ServerSyncProvider>
|
</ServerSyncProvider>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useUpdaterAction } from "../updater-action"
|
import { useUpdaterAction } from "../updater-action"
|
||||||
@@ -92,6 +94,7 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
|
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
|
|
||||||
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
const dir = createMemo(() => decode64(params.dir))
|
const dir = createMemo(() => decode64(params.dir))
|
||||||
const accepting = createMemo(() => {
|
const accepting = createMemo(() => {
|
||||||
const value = dir()
|
const value = dir()
|
||||||
@@ -126,13 +129,19 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
() =>
|
||||||
serverSdk()
|
serverSdk.client.pty
|
||||||
.client.pty.shells()
|
.shells()
|
||||||
.then((res) => res.data ?? [])
|
.then((res) => res.data ?? [])
|
||||||
.catch(() => [] as ShellOption[]),
|
.catch(() => [] as ShellOption[]),
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
|
||||||
|
() => (linux() && platform.getDisplayBackend ? true : false),
|
||||||
|
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
|
||||||
|
{ initialValue: null as DisplayBackend | null },
|
||||||
|
)
|
||||||
|
|
||||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||||
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
||||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||||
@@ -144,11 +153,11 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||||
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
|
||||||
|
|
||||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||||
const list = shells.latest
|
const list = shells.latest
|
||||||
const current = serverSync().data.config.shell
|
const current = serverSync.data.config.shell
|
||||||
|
|
||||||
const nameCounts = new Map<string, number>()
|
const nameCounts = new Map<string, number>()
|
||||||
for (const s of list) {
|
for (const s of list) {
|
||||||
@@ -177,6 +186,14 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
return options
|
return options
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const onDisplayBackendChange = (checked: boolean) => {
|
||||||
|
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
|
||||||
|
if (!update) return
|
||||||
|
void update.finally(() => {
|
||||||
|
void refetchDisplayBackend()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const onPinchZoomChange = (checked: boolean) => {
|
const onPinchZoomChange = (checked: boolean) => {
|
||||||
setPinchZoom(checked)
|
setPinchZoom(checked)
|
||||||
const update = platform.setPinchZoomEnabled?.(checked)
|
const update = platform.setPinchZoomEnabled?.(checked)
|
||||||
@@ -275,7 +292,7 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
if (option.value === currentShell()) return
|
if (option.value === currentShell()) return
|
||||||
serverSync().updateConfig({ shell: option.value })
|
serverSync.updateConfig({ shell: option.value })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
@@ -366,6 +383,18 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.showNavigation.title")}
|
||||||
|
description={language.t("settings.general.row.showNavigation.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-navigation">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showNavigation()}
|
||||||
|
onChange={(checked) => settings.general.setShowNavigation(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.showSearch.title")}
|
title={language.t("settings.general.row.showSearch.title")}
|
||||||
description={language.t("settings.general.row.showSearch.description")}
|
description={language.t("settings.general.row.showSearch.description")}
|
||||||
@@ -378,6 +407,18 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.showTerminal.title")}
|
||||||
|
description={language.t("settings.general.row.showTerminal.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-terminal">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showTerminal()}
|
||||||
|
onChange={(checked) => settings.general.setShowTerminal(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.showStatus.title")}
|
title={language.t("settings.general.row.showStatus.title")}
|
||||||
description={language.t("settings.general.row.showStatus.description")}
|
description={language.t("settings.general.row.showStatus.description")}
|
||||||
@@ -668,7 +709,6 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// We can probably remove this, right?
|
|
||||||
const DisplaySection = () => (
|
const DisplaySection = () => (
|
||||||
<Show when={desktop()}>
|
<Show when={desktop()}>
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
@@ -683,6 +723,26 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<Show when={linux()}>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("settings.general.row.wayland.title")}</span>
|
||||||
|
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
||||||
|
<span class="text-text-weak">
|
||||||
|
<Icon name="help" size="small" />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={language.t("settings.general.row.wayland.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-wayland">
|
||||||
|
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</Show>
|
||||||
</SettingsListV2>
|
</SettingsListV2>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -703,13 +763,13 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
|
|
||||||
<SoundsSection />
|
<SoundsSection />
|
||||||
|
|
||||||
<Show when={desktop()}>
|
<UpdatesSection />
|
||||||
<UpdatesSection />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<DisplaySection />
|
<DisplaySection />
|
||||||
|
|
||||||
<AdvancedSection />
|
<Show when={desktop()}>
|
||||||
|
<AdvancedSection />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component = () => {
|
|||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
const isConfigCustom = (providerID: string) => {
|
const isConfigCustom = (providerID: string) => {
|
||||||
const provider = serverSync().data.config.provider?.[providerID]
|
const provider = serverSync.data.config.provider?.[providerID]
|
||||||
if (!provider) return false
|
if (!provider) return false
|
||||||
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
||||||
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
||||||
@@ -85,11 +85,11 @@ export const SettingsProvidersV2: Component = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
const before = serverSync.data.config.disabled_providers ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
serverSync().set("config", "disabled_providers", next)
|
serverSync.set("config", "disabled_providers", next)
|
||||||
|
|
||||||
await serverSync()
|
await serverSync
|
||||||
.updateConfig({ disabled_providers: next })
|
.updateConfig({ disabled_providers: next })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -100,7 +100,7 @@ export const SettingsProvidersV2: Component = () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
serverSync().set("config", "disabled_providers", before)
|
serverSync.set("config", "disabled_providers", before)
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
})
|
})
|
||||||
@@ -108,16 +108,14 @@ export const SettingsProvidersV2: Component = () => {
|
|||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
if (isConfigCustom(providerID)) {
|
if (isConfigCustom(providerID)) {
|
||||||
await serverSdk()
|
await serverSdk.client.auth.remove({ providerID }).catch(() => undefined)
|
||||||
.client.auth.remove({ providerID })
|
|
||||||
.catch(() => undefined)
|
|
||||||
await disableProvider(providerID, name)
|
await disableProvider(providerID, name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await serverSdk()
|
await serverSdk.client.auth
|
||||||
.client.auth.remove({ providerID })
|
.remove({ providerID })
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await serverSdk().client.global.dispose()
|
await serverSdk.client.global.dispose()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
icon: "circle-check",
|
icon: "circle-check",
|
||||||
|
|||||||
@@ -279,13 +279,13 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
|
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
|
||||||
const toggleMcp = useMcpToggle()
|
const toggleMcp = useMcpToggle()
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||||
const lspCount = createMemo(() => lspItems().length)
|
const lspCount = createMemo(() => lspItems().length)
|
||||||
const plugins = createMemo(() =>
|
const plugins = createMemo(() =>
|
||||||
(sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
||||||
)
|
)
|
||||||
const pluginCount = createMemo(() => plugins().length)
|
const pluginCount = createMemo(() => plugins().length)
|
||||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ export function StatusPopover() {
|
|||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
|
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
|
||||||
const mcpIssue = createMemo(() => {
|
const mcpIssue = createMemo(() => {
|
||||||
const mcp = Object.values(sync().data.mcp ?? {})
|
const mcp = Object.values(sync.data.mcp ?? {})
|
||||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
const warn = mcp.some((item) => item.status === "needs_auth")
|
||||||
if (failed) return "critical" as const
|
if (failed) return "critical" as const
|
||||||
@@ -86,9 +86,9 @@ function DirectoryStatusPopover() {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
|
const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
|
||||||
const mcpIssue = createMemo(() => {
|
const mcpIssue = createMemo(() => {
|
||||||
const mcp = Object.values(sync().data.mcp ?? {})
|
const mcp = Object.values(sync.data.mcp ?? {})
|
||||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
const warn = mcp.some((item) => item.status === "needs_auth")
|
||||||
if (failed) return "critical" as const
|
if (failed) return "critical" as const
|
||||||
|
|||||||
@@ -161,9 +161,9 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const directory = sdk().directory
|
const directory = sdk.directory
|
||||||
const client = sdk().client
|
const client = sdk.client
|
||||||
const url = sdk().url
|
const url = sdk.url
|
||||||
const auth = server.current?.http
|
const auth = server.current?.http
|
||||||
const username = auth?.username ?? "opencode"
|
const username = auth?.username ?? "opencode"
|
||||||
const password = auth?.password ?? ""
|
const password = auth?.password ?? ""
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
@keyframes titlebar-tab-fade-left {
|
|
||||||
from {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes titlebar-tab-fade-right {
|
|
||||||
from {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The narrow ranges preserve the binary thresholds of scrollLeft > 0 and
|
|
||||||
remaining scroll distance > 1px. Hidden defaults make an inactive timeline
|
|
||||||
(no overflow) a no-op. Unsupported browsers also degrade to no fades. */
|
|
||||||
[data-slot="titlebar-tabs"] {
|
|
||||||
timeline-scope: --titlebar-tabs-scroll;
|
|
||||||
|
|
||||||
[data-slot^="titlebar-tabs-fade-"] {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@supports (animation-timeline: --titlebar-tabs-scroll) and (timeline-scope: --titlebar-tabs-scroll) {
|
|
||||||
[data-slot="titlebar-tabs-scroll"] {
|
|
||||||
scroll-timeline: --titlebar-tabs-scroll x;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="titlebar-tabs-fade-left"] {
|
|
||||||
animation: titlebar-tab-fade-left linear both;
|
|
||||||
animation-timeline: --titlebar-tabs-scroll;
|
|
||||||
animation-range: 0 0.1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="titlebar-tabs-fade-right"] {
|
|
||||||
animation: titlebar-tab-fade-right linear both;
|
|
||||||
animation-timeline: --titlebar-tabs-scroll;
|
|
||||||
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,11 +7,12 @@ import {
|
|||||||
Match,
|
Match,
|
||||||
onMount,
|
onMount,
|
||||||
Show,
|
Show,
|
||||||
|
startTransition,
|
||||||
Switch,
|
Switch,
|
||||||
untrack,
|
untrack,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -19,8 +20,6 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
|||||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
|
||||||
|
|
||||||
import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
|
import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -35,13 +34,11 @@ import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
|||||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|
||||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { tabHref, useTabs, type Tab } from "@/context/tabs"
|
import { tabHref, useTabs, type Tab } from "@/context/tabs"
|
||||||
import "./titlebar.css"
|
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
startDragging?: () => Promise<void>
|
startDragging?: () => Promise<void>
|
||||||
@@ -254,6 +251,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
{(_) => {
|
{(_) => {
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const homeMatch = useMatch(() => "/")
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
|
||||||
const newSessionHref = () => {
|
const newSessionHref = () => {
|
||||||
@@ -268,6 +266,17 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const tabsStore = tabs.store
|
const tabsStore = tabs.store
|
||||||
const tabsStoreActions = tabs
|
const tabsStoreActions = tabs
|
||||||
|
const navigateTab = (tab: Tab) => {
|
||||||
|
const href = tabHref(tab)
|
||||||
|
if (tab.server === server.key) {
|
||||||
|
navigate(href)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void startTransition(() => {
|
||||||
|
server.setActive(tab.server)
|
||||||
|
navigate(href)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const matchRoute = (route: LayoutRoute) => {
|
const matchRoute = (route: LayoutRoute) => {
|
||||||
if (route.type === "home") return
|
if (route.type === "home") return
|
||||||
@@ -280,7 +289,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
|
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
|
||||||
)
|
)
|
||||||
if (main) return main
|
if (main) return main
|
||||||
const sync = serverSync().createDirSyncContext(route.dir)
|
const sync = serverSync.createDirSyncContext(route.dir)
|
||||||
const session = sync.session.get(route.sessionId)
|
const session = sync.session.get(route.sessionId)
|
||||||
if (session?.parentID) {
|
if (session?.parentID) {
|
||||||
const parentID = session.parentID
|
const parentID = session.parentID
|
||||||
@@ -298,13 +307,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
const route = layout.route()
|
const route = layout.route()
|
||||||
if (!tabs.ready()) return
|
if (!tabs.ready()) return
|
||||||
const tab = currentTab()
|
const tab = currentTab()
|
||||||
if (tab) {
|
if (tab) return
|
||||||
tabs.remember(tab)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route.type === "session") {
|
if (route.type === "session") {
|
||||||
const sync = serverSync().createDirSyncContext(route.dir)
|
const sync = serverSync.createDirSyncContext(route.dir)
|
||||||
const session = sync.session.get(route.sessionId)
|
const session = sync.session.get(route.sessionId)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
const sessionId = session.parentID ?? session.id
|
const sessionId = session.parentID ?? session.id
|
||||||
@@ -324,18 +330,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const openNewTab = () => navigate(newSessionHref())
|
const openNewTab = () => navigate(newSessionHref())
|
||||||
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
|
|
||||||
|
|
||||||
command.register("titlebar-home", () => [
|
|
||||||
{
|
|
||||||
id: "home.toggle",
|
|
||||||
title: language.t("home.title"),
|
|
||||||
category: language.t("command.category.view"),
|
|
||||||
keybind: "mod+b",
|
|
||||||
hidden: true,
|
|
||||||
onSelect: toggleHome,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
command.register("tabs", () => {
|
command.register("tabs", () => {
|
||||||
const current = currentTab()
|
const current = currentTab()
|
||||||
@@ -373,7 +367,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
if (index === -1) index = tabsStore.length - 1
|
if (index === -1) index = tabsStore.length - 1
|
||||||
|
|
||||||
const next = tabsStore[index]
|
const next = tabsStore[index]
|
||||||
if (next) tabs.select(next)
|
if (next) navigateTab(next)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -390,7 +384,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
if (index === tabsStore.length) index = 0
|
if (index === tabsStore.length) index = 0
|
||||||
|
|
||||||
const next = tabsStore[index]
|
const next = tabsStore[index]
|
||||||
if (next) tabs.select(next)
|
if (next) navigateTab(next)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...Array.from({ length: 9 }, (_, i) => {
|
...Array.from({ length: 9 }, (_, i) => {
|
||||||
@@ -405,7 +399,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
const tab = tabsStore[index]
|
const tab = tabsStore[index]
|
||||||
if (tab) tabs.select(tab)
|
if (tab) navigateTab(tab)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -431,130 +425,102 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
<Show when={windows() || linux()}>
|
<Show when={windows() || linux()}>
|
||||||
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
||||||
</Show>
|
</Show>
|
||||||
<TooltipV2
|
<IconButtonV2
|
||||||
placement="bottom"
|
variant="ghost-muted"
|
||||||
value={
|
size="large"
|
||||||
<>
|
as="a"
|
||||||
{language.t("home.title")}
|
href="/"
|
||||||
<KeybindV2 keys={command.keybindParts("home.toggle")} variant="neutral" />
|
class="!w-9 shrink-0"
|
||||||
</>
|
icon={<IconV2 name="grid-plus" />}
|
||||||
}
|
state={!!homeMatch() ? "pressed" : undefined}
|
||||||
class="shrink-0"
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||||
|
ref={tabScrollRef}
|
||||||
>
|
>
|
||||||
<IconButtonV2
|
<div class="flex min-w-0 flex-row items-center gap-1.5">
|
||||||
type="button"
|
<For each={tabsStore}>
|
||||||
variant="ghost-muted"
|
{(tab, i) => {
|
||||||
size="large"
|
let ref!: HTMLDivElement
|
||||||
class="!w-9 shrink-0"
|
|
||||||
icon={<IconV2 name="grid-plus" />}
|
|
||||||
state={layout.route().type === "home" ? "pressed" : undefined}
|
|
||||||
onClick={toggleHome}
|
|
||||||
aria-label={language.t("home.title")}
|
|
||||||
aria-pressed={layout.route().type === "home"}
|
|
||||||
/>
|
|
||||||
</TooltipV2>
|
|
||||||
|
|
||||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
onMount(() => {
|
||||||
<div
|
refreshTabsAreOverflowing()
|
||||||
data-slot="titlebar-tabs-scroll"
|
})
|
||||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
|
||||||
ref={(el) => {
|
|
||||||
tabScrollRef = el
|
|
||||||
createResizeObserver(el, refreshTabsAreOverflowing)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex min-w-0 flex-row items-center gap-1.5"
|
|
||||||
ref={(el) => createResizeObserver(el, refreshTabsAreOverflowing)}
|
|
||||||
>
|
|
||||||
<For each={tabsStore}>
|
|
||||||
{(tab, i) => {
|
|
||||||
let ref!: HTMLDivElement
|
|
||||||
|
|
||||||
const divider = () =>
|
const divider = () =>
|
||||||
i() !== 0 && (
|
i() !== 0 && (
|
||||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||||
)
|
)
|
||||||
|
|
||||||
if (tab.type === "draft") {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{divider()}
|
|
||||||
<DraftTabItem
|
|
||||||
ref={ref}
|
|
||||||
href={tabHref(tab)}
|
|
||||||
title={language.t("command.session.new")}
|
|
||||||
active={currentTab() === tab}
|
|
||||||
onNavigate={() => {
|
|
||||||
tabs.select(tab)
|
|
||||||
ref.scrollIntoView({ behavior: "instant" })
|
|
||||||
}}
|
|
||||||
onClose={() => tabsStoreActions.removeTab(i())}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (tab.type === "draft") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{divider()}
|
{divider()}
|
||||||
<TabNavItem
|
<DraftTabItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
href={tabHref(tab)}
|
href={tabHref(tab)}
|
||||||
server={tab.server}
|
title={language.t("command.session.new")}
|
||||||
directory={decode64(tab.dirBase64)!}
|
active={currentTab() === tab}
|
||||||
sessionId={tab.sessionId}
|
|
||||||
onNavigate={() => {
|
onNavigate={() => {
|
||||||
tabs.select(tab)
|
navigateTab(tab)
|
||||||
|
|
||||||
ref.scrollIntoView({ behavior: "instant" })
|
ref.scrollIntoView({ behavior: "instant" })
|
||||||
}}
|
}}
|
||||||
onClose={() => tabsStoreActions.removeTab(i())}
|
onClose={() => tabsStoreActions.removeTab(i())}
|
||||||
active={currentTab() === tab}
|
|
||||||
activeServer={tab.server === server.key}
|
|
||||||
forceTruncate={tabsAreOverflowing()}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}}
|
}
|
||||||
</For>
|
|
||||||
<Show when={creating() && params.dir}>
|
|
||||||
{(_) => {
|
|
||||||
let ref!: HTMLDivElement
|
|
||||||
|
|
||||||
onMount(() => {
|
return (
|
||||||
ref.scrollIntoView({ behavior: "instant" })
|
<>
|
||||||
})
|
{divider()}
|
||||||
|
<TabNavItem
|
||||||
|
ref={ref}
|
||||||
|
href={tabHref(tab)}
|
||||||
|
server={tab.server}
|
||||||
|
directory={decode64(tab.dirBase64)!}
|
||||||
|
sessionId={tab.sessionId}
|
||||||
|
onNavigate={() => {
|
||||||
|
navigateTab(tab)
|
||||||
|
|
||||||
return (
|
ref.scrollIntoView({ behavior: "instant" })
|
||||||
<>
|
}}
|
||||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
onClose={() => tabsStoreActions.removeTab(i())}
|
||||||
<NewSessionTabItem
|
active={currentTab() === tab}
|
||||||
ref={ref}
|
activeServer={tab.server === server.key}
|
||||||
href={`/${params.dir}/session`}
|
forceTruncate={tabsAreOverflowing()}
|
||||||
title={language.t("command.session.new")}
|
/>
|
||||||
onClose={() => {
|
</>
|
||||||
const tab = tabsStore.at(-1)
|
)
|
||||||
if (tab) tabs.select(tab)
|
}}
|
||||||
else navigate("/")
|
</For>
|
||||||
}}
|
<Show when={creating() && params.dir}>
|
||||||
/>
|
{(_) => {
|
||||||
</>
|
let ref!: HTMLDivElement
|
||||||
)
|
|
||||||
}}
|
onMount(() => {
|
||||||
</Show>
|
ref.scrollIntoView({ behavior: "instant" })
|
||||||
</div>
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||||
|
<NewSessionTabItem
|
||||||
|
ref={ref}
|
||||||
|
href={`/${params.dir}/session`}
|
||||||
|
title={language.t("command.session.new")}
|
||||||
|
onClose={() => {
|
||||||
|
const tab = tabsStore.at(-1)
|
||||||
|
if (tab) navigateTab(tab)
|
||||||
|
else navigate("/")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
data-slot="titlebar-tabs-fade-left"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
data-slot="titlebar-tabs-fade-right"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Show when={!(creating() && params.dir)}>
|
<Show when={!(creating() && params.dir)}>
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
|
|||||||
@@ -170,7 +170,13 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) {
|
export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
|
||||||
|
if (!config || config === "none") return ""
|
||||||
|
|
||||||
|
const keybinds = parseKeybind(config)
|
||||||
|
if (keybinds.length === 0) return ""
|
||||||
|
|
||||||
|
const kb = keybinds[0]
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
|
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
|
||||||
@@ -178,52 +184,40 @@ function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) {
|
|||||||
if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t))
|
if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t))
|
||||||
if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
|
if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
|
||||||
|
|
||||||
if (!kb.key) return parts
|
if (kb.key) {
|
||||||
|
const keys: Record<string, string> = {
|
||||||
const keys: Record<string, string> = {
|
arrowup: "↑",
|
||||||
arrowup: "↑",
|
arrowdown: "↓",
|
||||||
arrowdown: "↓",
|
arrowleft: "←",
|
||||||
arrowleft: "←",
|
arrowright: "→",
|
||||||
arrowright: "→",
|
comma: ",",
|
||||||
comma: ",",
|
plus: "+",
|
||||||
plus: "+",
|
}
|
||||||
|
const named: Record<string, KeyLabel> = {
|
||||||
|
backspace: "common.key.backspace",
|
||||||
|
delete: "common.key.delete",
|
||||||
|
end: "common.key.end",
|
||||||
|
enter: "common.key.enter",
|
||||||
|
esc: "common.key.esc",
|
||||||
|
escape: "common.key.esc",
|
||||||
|
home: "common.key.home",
|
||||||
|
insert: "common.key.insert",
|
||||||
|
pagedown: "common.key.pageDown",
|
||||||
|
pageup: "common.key.pageUp",
|
||||||
|
space: "common.key.space",
|
||||||
|
tab: "common.key.tab",
|
||||||
|
}
|
||||||
|
const key = kb.key.toLowerCase()
|
||||||
|
const displayKey =
|
||||||
|
keys[key] ??
|
||||||
|
(named[key]
|
||||||
|
? keyText(named[key], t)
|
||||||
|
: key.length === 1
|
||||||
|
? key.toUpperCase()
|
||||||
|
: key.charAt(0).toUpperCase() + key.slice(1))
|
||||||
|
parts.push(displayKey)
|
||||||
}
|
}
|
||||||
const named: Record<string, KeyLabel> = {
|
|
||||||
backspace: "common.key.backspace",
|
|
||||||
delete: "common.key.delete",
|
|
||||||
end: "common.key.end",
|
|
||||||
enter: "common.key.enter",
|
|
||||||
esc: "common.key.esc",
|
|
||||||
escape: "common.key.esc",
|
|
||||||
home: "common.key.home",
|
|
||||||
insert: "common.key.insert",
|
|
||||||
pagedown: "common.key.pageDown",
|
|
||||||
pageup: "common.key.pageUp",
|
|
||||||
space: "common.key.space",
|
|
||||||
tab: "common.key.tab",
|
|
||||||
}
|
|
||||||
const key = kb.key.toLowerCase()
|
|
||||||
const displayKey =
|
|
||||||
keys[key] ??
|
|
||||||
(named[key]
|
|
||||||
? keyText(named[key], t)
|
|
||||||
: key.length === 1
|
|
||||||
? key.toUpperCase()
|
|
||||||
: key.charAt(0).toUpperCase() + key.slice(1))
|
|
||||||
parts.push(displayKey)
|
|
||||||
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatKeybindParts(config: string, t?: (key: KeyLabel) => string): string[] {
|
|
||||||
if (!config || config === "none") return []
|
|
||||||
const keybind = parseKeybind(config)[0]
|
|
||||||
return keybind ? displayKeybindParts(keybind, t) : []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
|
|
||||||
const parts = formatKeybindParts(config, t)
|
|
||||||
if (parts.length === 0) return ""
|
|
||||||
return IS_MAC ? parts.join("") : parts.join("+")
|
return IS_MAC ? parts.join("") : parts.join("+")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,26 +402,25 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const keybindConfig = (id: string) => {
|
|
||||||
if (id === PALETTE_ID) return settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
|
|
||||||
const base = actionId(id)
|
|
||||||
return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog[base]?.keybind)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
trigger(id: string, source?: CommandSource) {
|
trigger(id: string, source?: CommandSource) {
|
||||||
run(id, source)
|
run(id, source)
|
||||||
},
|
},
|
||||||
keybind(id: string) {
|
keybind(id: string) {
|
||||||
const config = keybindConfig(id)
|
if (id === PALETTE_ID) {
|
||||||
|
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = actionId(id)
|
||||||
|
const option = options().find((x) => actionId(x.id) === base)
|
||||||
|
if (option?.keybind) return formatKeybind(option.keybind, language.t)
|
||||||
|
|
||||||
|
const meta = catalog[base]
|
||||||
|
const config = bind(base, meta?.keybind)
|
||||||
if (!config) return ""
|
if (!config) return ""
|
||||||
return formatKeybind(config, language.t)
|
return formatKeybind(config, language.t)
|
||||||
},
|
},
|
||||||
keybindParts(id: string) {
|
|
||||||
const config = keybindConfig(id)
|
|
||||||
return config ? formatKeybindParts(config, language.t) : []
|
|
||||||
},
|
|
||||||
show: showPalette,
|
show: showPalette,
|
||||||
keybinds(enabled: boolean) {
|
keybinds(enabled: boolean) {
|
||||||
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
|
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
|||||||
const decoded = decodeSessionKey(key)
|
const decoded = decodeSessionKey(key)
|
||||||
return createRoot((dispose) => ({
|
return createRoot((dispose) => ({
|
||||||
value: createCommentSession(
|
value: createCommentSession(
|
||||||
serverSDK().scope,
|
serverSDK.scope,
|
||||||
decoded.dir,
|
decoded.dir,
|
||||||
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
|
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
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 { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||||
import { type createServerSdkContext } from "./server-sdk"
|
import { createServerSdkContext, useServerSDK } from "./server-sdk"
|
||||||
import { type createServerSyncContextInner } from "./server-sync"
|
import { type createServerSyncContextInner } from "./server-sync"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
@@ -174,7 +174,7 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
|
|||||||
export const createDirSyncContext = (
|
export const createDirSyncContext = (
|
||||||
directory: string,
|
directory: string,
|
||||||
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
||||||
serverSDK: ReturnType<typeof createServerSdkContext>,
|
serverSDK: ReturnType<typeof createServerSdkContext> = useServerSDK(),
|
||||||
) => {
|
) => {
|
||||||
const client = serverSDK.createClient({ directory, throwOnError: true })
|
const client = serverSDK.createClient({ directory, throwOnError: true })
|
||||||
|
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
|
||||||
const scope = createMemo(() => sdk().directory)
|
const scope = createMemo(() => sdk.directory)
|
||||||
const path = createPathHelpers(scope)
|
const path = createPathHelpers(scope)
|
||||||
const tabs = layout.tabs(() =>
|
const tabs = layout.tabs(() =>
|
||||||
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)),
|
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
@@ -78,10 +78,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
const tree = createFileTreeStore({
|
const tree = createFileTreeStore({
|
||||||
scope,
|
scope,
|
||||||
normalizeDir: path.normalizeDir,
|
normalizeDir: path.normalizeDir,
|
||||||
list: (dir) =>
|
list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
|
||||||
sdk()
|
|
||||||
.client.file.list({ path: dir })
|
|
||||||
.then((x) => x.data ?? []),
|
|
||||||
onError: (message) => {
|
onError: (message) => {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -115,7 +112,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewCache = createFileViewCache(serverSDK().scope)
|
const viewCache = createFileViewCache(serverSDK.scope)
|
||||||
const view = createMemo(() => viewCache.load(scope(), params.id))
|
const view = createMemo(() => viewCache.load(scope(), params.id))
|
||||||
|
|
||||||
const ensure = (file: string) => {
|
const ensure = (file: string) => {
|
||||||
@@ -179,8 +176,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
|
|
||||||
setLoading(file)
|
setLoading(file)
|
||||||
|
|
||||||
const promise = sdk()
|
const promise = sdk.client.file
|
||||||
.client.file.read({ path: file })
|
.read({ path: file })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (scope() !== directory) return
|
if (scope() !== directory) return
|
||||||
const content = x.data
|
const content = x.data
|
||||||
@@ -203,14 +200,12 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const search = (query: string, dirs: "true" | "false") =>
|
const search = (query: string, dirs: "true" | "false") =>
|
||||||
sdk()
|
sdk.client.find.files({ query, dirs }).then(
|
||||||
.client.find.files({ query, dirs })
|
(x) => (x.data ?? []).map(path.normalize),
|
||||||
.then(
|
() => [],
|
||||||
(x) => (x.data ?? []).map(path.normalize),
|
)
|
||||||
() => [],
|
|
||||||
)
|
|
||||||
|
|
||||||
const stop = sdk().event.listen((e) => {
|
const stop = sdk.event.listen((e) => {
|
||||||
invalidateFromWatcher(e.details, {
|
invalidateFromWatcher(e.details, {
|
||||||
normalize: path.normalize,
|
normalize: path.normalize,
|
||||||
hasFile: (file) => Boolean(store.file[file]),
|
hasFile: (file) => Boolean(store.file[file]),
|
||||||
|
|||||||
@@ -134,26 +134,6 @@ describe("applyGlobalEvent", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("applyDirectoryEvent", () => {
|
describe("applyDirectoryEvent", () => {
|
||||||
test("initializes text delta accumulation from the current part text", () => {
|
|
||||||
const part = { ...textPart("part", "session", "message"), text: "existing" }
|
|
||||||
const [store, setStore] = createStore(baseState({ part: { message: [part] } }))
|
|
||||||
|
|
||||||
applyDirectoryEvent({
|
|
||||||
event: {
|
|
||||||
type: "message.part.delta",
|
|
||||||
properties: { messageID: "message", partID: "part", field: "text", delta: " appended" },
|
|
||||||
},
|
|
||||||
store,
|
|
||||||
setStore,
|
|
||||||
push() {},
|
|
||||||
directory: "/tmp",
|
|
||||||
loadLsp() {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(store.part_text_accum_delta.part).toBe("existing appended")
|
|
||||||
expect((store.part.message?.[0] as { text: string }).text).toBe("existing appended")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves a Home-specific retained session limit", () => {
|
test("preserves a Home-specific retained session limit", () => {
|
||||||
const [store, setStore] = createStore(
|
const [store, setStore] = createStore(
|
||||||
baseState({
|
baseState({
|
||||||
|
|||||||
@@ -282,13 +282,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
if (!parts) break
|
if (!parts) break
|
||||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||||
if (!result.found) break
|
if (!result.found) break
|
||||||
const field = props.field as keyof (typeof parts)[number]
|
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
|
||||||
const current = parts[result.index]?.[field]
|
|
||||||
input.setStore(
|
|
||||||
"part_text_accum_delta",
|
|
||||||
props.partID,
|
|
||||||
(existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta,
|
|
||||||
)
|
|
||||||
input.setStore(
|
input.setStore(
|
||||||
"part",
|
"part",
|
||||||
props.messageID,
|
props.messageID,
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"])
|
const target = Persist.serverGlobal(serverSdk.scope, "layout", ["layout.v6"])
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{ ...target, migrate },
|
{ ...target, migrate },
|
||||||
createStore({
|
createStore({
|
||||||
@@ -409,11 +409,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||||
const [childStore] = serverSync().child(project.worktree, { bootstrap: false })
|
const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
|
||||||
const projectID = childStore.project
|
const projectID = childStore.project
|
||||||
const metadata = projectID
|
const metadata = projectID
|
||||||
? serverSync().data.project.find((x) => x.id === projectID)
|
? serverSync.data.project.find((x) => x.id === projectID)
|
||||||
: serverSync().data.project.find((x) => x.worktree === project.worktree)
|
: serverSync.data.project.find((x) => x.worktree === project.worktree)
|
||||||
|
|
||||||
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
||||||
// Without this, different subdirectories of the same git repo would share the same
|
// Without this, different subdirectories of the same git repo would share the same
|
||||||
@@ -427,7 +427,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const roots = createMemo(() => {
|
const roots = createMemo(() => {
|
||||||
const map = new Map<string, string>()
|
const map = new Map<string, string>()
|
||||||
for (const project of serverSync().data.project) {
|
for (const project of serverSync.data.project) {
|
||||||
const sandboxes = project.sandboxes ?? []
|
const sandboxes = project.sandboxes ?? []
|
||||||
for (const sandbox of sandboxes) {
|
for (const sandbox of sandboxes) {
|
||||||
map.set(sandbox, project.worktree)
|
map.set(sandbox, project.worktree)
|
||||||
@@ -493,12 +493,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const projects = enriched()
|
const projects = enriched()
|
||||||
if (projects.length === 0) return
|
if (projects.length === 0) return
|
||||||
if (!serverSync().ready) return
|
if (!serverSync.ready) return
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
if (!project.id) continue
|
if (!project.id) continue
|
||||||
if (project.id === "global") continue
|
if (project.id === "global") continue
|
||||||
serverSync().project.icon(project.worktree, project.icon?.override)
|
serverSync.project.icon(project.worktree, project.icon?.override)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -532,12 +532,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
colorRequested.set(worktree, color)
|
colorRequested.set(worktree, color)
|
||||||
|
|
||||||
if (project.id === "global") {
|
if (project.id === "global") {
|
||||||
serverSync().project.meta(worktree, { icon: { color } })
|
serverSync.project.meta(worktree, { icon: { color } })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
void serverSdk()
|
void serverSdk.client.project
|
||||||
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
|
.update({ projectID: project.id, directory: worktree, icon: { color } })
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||||
})
|
})
|
||||||
@@ -554,7 +554,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
sessionTimer = undefined
|
sessionTimer = undefined
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
server.projects.list().map((project) => {
|
server.projects.list().map((project) => {
|
||||||
return serverSync().project.loadSessions(project.worktree)
|
return serverSync.project.loadSessions(project.worktree)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}, 0)
|
}, 0)
|
||||||
@@ -584,7 +584,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
open(directory: string) {
|
open(directory: string) {
|
||||||
const root = rootFor(directory)
|
const root = rootFor(directory)
|
||||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||||
void serverSync().project.loadSessions(root)
|
void serverSync.project.loadSessions(root)
|
||||||
server.projects.open(root)
|
server.projects.open(root)
|
||||||
},
|
},
|
||||||
close(directory: string) {
|
close(directory: string) {
|
||||||
|
|||||||
@@ -64,12 +64,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
|
||||||
const id = createMemo(() => params.id || undefined)
|
const id = createMemo(() => params.id || undefined)
|
||||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||||
|
|
||||||
const [saved, setSaved] = persisted(
|
const [saved, setSaved] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
|
...Persist.serverWorkspace(serverSDK.scope, sdk.directory, "model-selection", ["model-selection.v1"]),
|
||||||
migrate,
|
migrate,
|
||||||
},
|
},
|
||||||
createStore<Saved>({
|
createStore<Saved>({
|
||||||
@@ -124,14 +124,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const scope = createMemo<State | undefined>(() => {
|
const scope = createMemo<State | undefined>(() => {
|
||||||
const session = id()
|
const session = id()
|
||||||
if (!session) return store.draft
|
if (!session) return store.draft
|
||||||
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
|
return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk.directory, session))
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const session = id()
|
const session = id()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
const key = handoffKey(serverSDK().scope, sdk().directory, session)
|
const key = handoffKey(serverSDK.scope, sdk.directory, session)
|
||||||
const next = handoff.get(key)
|
const next = handoff.get(key)
|
||||||
if (!next) return
|
if (!next) return
|
||||||
if (saved.session[session] !== undefined) {
|
if (saved.session[session] !== undefined) {
|
||||||
@@ -144,9 +144,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const configuredModel = () => {
|
const configuredModel = () => {
|
||||||
const configured = sync().data.config.model
|
if (!sync.data.config.model) return
|
||||||
if (!configured) return
|
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||||
const [providerID, modelID] = configured.split("/")
|
|
||||||
const model = { providerID, modelID }
|
const model = { providerID, modelID }
|
||||||
if (validModel(model)) return model
|
if (validModel(model)) return model
|
||||||
}
|
}
|
||||||
@@ -364,7 +363,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
slug: createMemo(() => base64Encode(sdk().directory)),
|
slug: createMemo(() => base64Encode(sdk.directory)),
|
||||||
model,
|
model,
|
||||||
agent,
|
agent,
|
||||||
session: {
|
session: {
|
||||||
@@ -375,13 +374,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const next = clone(snapshot())
|
const next = clone(snapshot())
|
||||||
if (!next) return
|
if (!next) return
|
||||||
|
|
||||||
if (dir === sdk().directory) {
|
if (dir === sdk.directory) {
|
||||||
setSaved("session", session, next)
|
setSaved("session", session, next)
|
||||||
setStore("draft", undefined)
|
setStore("draft", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
handoff.set(handoffKey(serverSDK().scope, dir, session), next)
|
handoff.set(handoffKey(serverSDK.scope, dir, session), next)
|
||||||
setStore("draft", undefined)
|
setStore("draft", undefined)
|
||||||
},
|
},
|
||||||
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
||||||
@@ -389,7 +388,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
if (msg.sessionID !== session) return
|
if (msg.sessionID !== session) return
|
||||||
if (saved.session[session] !== undefined) return
|
if (saved.session[session] !== undefined) return
|
||||||
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
|
if (handoff.has(handoffKey(serverSDK.scope, sdk.directory, session))) return
|
||||||
|
|
||||||
setSaved("session", session, {
|
setSaved("session", session, {
|
||||||
agent: msg.agent,
|
agent: msg.agent,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function useMcpToggle() {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
return useMutation(() => ({
|
return useMutation(() => ({
|
||||||
mutationFn: sync().mcp.toggle,
|
mutationFn: sync.mcp.toggle,
|
||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
const currentSession = createMemo(() => params.id)
|
const currentSession = createMemo(() => params.id)
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
Persist.serverGlobal(serverSDK.scope, "notification", ["notification.v1"]),
|
||||||
createStore({
|
createStore({
|
||||||
list: [] as Notification[],
|
list: [] as Notification[],
|
||||||
}),
|
}),
|
||||||
@@ -208,11 +208,11 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
|
|
||||||
const lookup = async (directory: string, sessionID?: string) => {
|
const lookup = async (directory: string, sessionID?: string) => {
|
||||||
if (!sessionID) return undefined
|
if (!sessionID) return undefined
|
||||||
const [syncStore] = serverSync().child(directory, { bootstrap: false })
|
const [syncStore] = serverSync.child(directory, { bootstrap: false })
|
||||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||||
if (match.found) return syncStore.session[match.index]
|
if (match.found) return syncStore.session[match.index]
|
||||||
return serverSDK()
|
return serverSDK.client.session
|
||||||
.client.session.get({ directory, sessionID })
|
.get({ directory, sessionID })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
@@ -286,7 +286,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = serverSDK().event.listen((e) => {
|
const unsub = serverSDK.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||||
|
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
const permissionsEnabled = createMemo(() => {
|
const permissionsEnabled = createMemo(() => {
|
||||||
const directory = decode64(params.dir)
|
const directory = decode64(params.dir)
|
||||||
if (!directory) return false
|
if (!directory) return false
|
||||||
const [store] = serverSync().child(directory)
|
const [store] = serverSync.child(directory)
|
||||||
return hasPermissionPromptRules(store.config.permission)
|
return hasPermissionPromptRules(store.config.permission)
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
|
...Persist.serverGlobal(serverSDK.scope, "permission", ["permission.v3"]),
|
||||||
migrate(value) {
|
migrate(value) {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
if (!ready()) return
|
if (!ready()) return
|
||||||
const directory = decode64(params.dir)
|
const directory = decode64(params.dir)
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
const [childStore] = serverSync().child(directory)
|
const [childStore] = serverSync.child(directory)
|
||||||
const perm = childStore.config.permission
|
const perm = childStore.config.permission
|
||||||
if (typeof perm === "string" && perm === "allow") {
|
if (typeof perm === "string" && perm === "allow") {
|
||||||
const key = directoryAcceptKey(directory)
|
const key = directoryAcceptKey(directory)
|
||||||
@@ -119,11 +119,9 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}
|
}
|
||||||
|
|
||||||
const respond: PermissionRespondFn = (input) => {
|
const respond: PermissionRespondFn = (input) => {
|
||||||
serverSDK()
|
serverSDK.client.permission.respond(input).catch(() => {
|
||||||
.client.permission.respond(input)
|
responded.delete(input.permissionID)
|
||||||
.catch(() => {
|
})
|
||||||
responded.delete(input.permissionID)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||||
@@ -142,7 +140,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
|
||||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +149,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
|
||||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +160,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = serverSDK().event.listen((e) => {
|
const unsubscribe = serverSDK.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event?.type !== "permission.asked") return
|
if (event?.type !== "permission.asked") return
|
||||||
|
|
||||||
@@ -181,8 +179,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
serverSDK()
|
serverSDK.client.permission
|
||||||
.client.permission.list({ directory })
|
.list({ directory })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (!isAutoAcceptingDirectory(directory)) return
|
if (!isAutoAcceptingDirectory(directory)) return
|
||||||
for (const perm of x.data ?? []) {
|
for (const perm of x.data ?? []) {
|
||||||
@@ -213,8 +211,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
serverSDK()
|
serverSDK.client.permission
|
||||||
.client.permission.list({ directory })
|
.list({ directory })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (enableVersion.get(key) !== version) return
|
if (enableVersion.get(key) !== version) return
|
||||||
if (!isAutoAccepting(sessionID, directory)) return
|
if (!isAutoAccepting(sessionID, directory)) return
|
||||||
@@ -271,7 +269,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
},
|
},
|
||||||
permissionsEnabled,
|
permissionsEnabled,
|
||||||
isPermissionAllowAll(directory: string) {
|
isPermissionAllowAll(directory: string) {
|
||||||
const [childStore] = serverSync().child(directory)
|
const [childStore] = serverSync.child(directory)
|
||||||
const perm = childStore.config.permission
|
const perm = childStore.config.permission
|
||||||
return typeof perm === "string" && perm === "allow"
|
return typeof perm === "string" && perm === "allow"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -55,9 +55,6 @@ type PlatformBase = {
|
|||||||
onFile: (file: File) => Promise<unknown>,
|
onFile: (file: File) => Promise<unknown>,
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
|
|
||||||
/** Resolve the native source path for a desktop File. */
|
|
||||||
getPathForFile?(file: File): string
|
|
||||||
|
|
||||||
/** Open a native save file picker dialog (desktop only) */
|
/** Open a native save file picker dialog (desktop only) */
|
||||||
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export interface ImageAttachmentPart {
|
|||||||
type: "image"
|
type: "image"
|
||||||
id: string
|
id: string
|
||||||
filename: string
|
filename: string
|
||||||
sourcePath?: string
|
|
||||||
mime: string
|
mime: string
|
||||||
dataUrl: string
|
dataUrl: string
|
||||||
}
|
}
|
||||||
@@ -154,14 +153,6 @@ const MAX_PROMPT_SESSIONS = 20
|
|||||||
|
|
||||||
type PromptSession = ReturnType<typeof createPromptSession>
|
type PromptSession = ReturnType<typeof createPromptSession>
|
||||||
|
|
||||||
type PromptStore = {
|
|
||||||
prompt: Prompt
|
|
||||||
cursor?: number
|
|
||||||
context: {
|
|
||||||
items: (ContextItem & { key: string })[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Scope = { draftID: string } | { dir: string; id?: string }
|
type Scope = { draftID: string } | { dir: string; id?: string }
|
||||||
|
|
||||||
function scopeKey(scope: Scope) {
|
function scopeKey(scope: Scope) {
|
||||||
@@ -183,26 +174,25 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
|
|||||||
function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
promptTarget(serverScope, scope),
|
promptTarget(serverScope, scope),
|
||||||
createStore<PromptStore>(promptStore()),
|
createStore<{
|
||||||
|
prompt: Prompt
|
||||||
|
cursor?: number
|
||||||
|
context: {
|
||||||
|
items: (ContextItem & { key: string })[]
|
||||||
|
}
|
||||||
|
}>({
|
||||||
|
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||||
|
cursor: undefined,
|
||||||
|
context: {
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return { ready, ...createPromptStateValue(store, setStore) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptStore(): PromptStore {
|
|
||||||
return {
|
|
||||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
|
||||||
cursor: undefined,
|
|
||||||
context: {
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
|
||||||
const actions = createPromptActions(setStore)
|
const actions = createPromptActions(setStore)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
ready,
|
||||||
current: () => store.prompt,
|
current: () => store.prompt,
|
||||||
cursor: createMemo(() => store.cursor),
|
cursor: createMemo(() => store.cursor),
|
||||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||||
@@ -242,15 +232,6 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptState() {
|
|
||||||
const [store, setStore] = createStore<PromptStore>(promptStore())
|
|
||||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
|
||||||
return {
|
|
||||||
ready: () => ready,
|
|
||||||
...createPromptStateValue(store, setStore),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||||
name: "Prompt",
|
name: "Prompt",
|
||||||
gate: false,
|
gate: false,
|
||||||
@@ -291,7 +272,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const entry = createRoot(
|
const entry = createRoot(
|
||||||
(dispose) => ({
|
(dispose) => ({
|
||||||
value: createPromptSession(serverSDK().scope, scope),
|
value: createPromptSession(serverSDK.scope, scope),
|
||||||
dispose,
|
dispose,
|
||||||
}),
|
}),
|
||||||
owner,
|
owner,
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { type Accessor, createMemo } from "solid-js"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import { type ServerSDK, useServerSDK } from "./server-sdk"
|
|
||||||
|
|
||||||
export type DirectorySDK = ReturnType<ServerSDK["createDirSdkContext"]>
|
|
||||||
|
|
||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
init: (props: { directory: string }) => {
|
||||||
init: (props: { directory: string | Accessor<string> }) => {
|
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
return createMemo(() => {
|
|
||||||
const directory = typeof props.directory === "function" ? props.directory() : props.directory
|
return serverSDK.createDirSdkContext(props.directory)
|
||||||
return serverSDK().createDirSdkContext(directory)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
import { resumeStreamAfterPageShow } from "./server-sdk"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
test("restarts a stream only after a back-forward cache restore", () => {
|
test("restarts a stream only after a back-forward cache restore", () => {
|
||||||
@@ -13,41 +12,3 @@ describe("resumeStreamAfterPageShow", () => {
|
|||||||
expect(starts).toBe(1)
|
expect(starts).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("coalesceServerEvents", () => {
|
|
||||||
const delta = (value: string, field = "text") => ({
|
|
||||||
directory: "/repo",
|
|
||||||
payload: {
|
|
||||||
type: "message.part.delta",
|
|
||||||
properties: { messageID: "msg", partID: "part", field, delta: value },
|
|
||||||
} as Event,
|
|
||||||
})
|
|
||||||
|
|
||||||
test("merges adjacent deltas for the same field", () => {
|
|
||||||
const result = coalesceServerEvents([delta("hello "), delta("world")])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves event boundaries and distinct fields", () => {
|
|
||||||
const status = {
|
|
||||||
directory: "/repo",
|
|
||||||
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
|
|
||||||
}
|
|
||||||
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
|
|
||||||
|
|
||||||
expect(result.map((event) => event.payload.type)).toEqual([
|
|
||||||
"message.part.delta",
|
|
||||||
"message.part.delta",
|
|
||||||
"session.status",
|
|
||||||
"message.part.delta",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("drops stale deltas", () => {
|
|
||||||
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
|
|
||||||
|
|
||||||
expect(result).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Event } from "@opencode-ai/sdk/v2/client"
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
import { createSdkForServer } from "@/utils/server"
|
import { createSdkForServer } from "@/utils/server"
|
||||||
import { useLanguage } from "./language"
|
import { useLanguage } from "./language"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
@@ -15,46 +15,13 @@ const isAbortError = (error: unknown) =>
|
|||||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||||
|
|
||||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||||
type QueuedServerEvent = { directory: string; payload: Event }
|
|
||||||
|
|
||||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
|
||||||
|
|
||||||
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
|
|
||||||
const output: QueuedServerEvent[] = []
|
|
||||||
const deltas = new Map<string, number>()
|
|
||||||
events.forEach((event) => {
|
|
||||||
if (stale && event.payload.type === "message.part.delta") {
|
|
||||||
const props = event.payload.properties
|
|
||||||
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
|
|
||||||
}
|
|
||||||
if (event.payload.type !== "message.part.delta") {
|
|
||||||
deltas.clear()
|
|
||||||
output.push(event)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const props = event.payload.properties
|
|
||||||
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
|
|
||||||
const index = deltas.get(id)
|
|
||||||
const existing = index === undefined ? undefined : output[index]
|
|
||||||
if (!existing || existing.payload.type !== "message.part.delta") {
|
|
||||||
deltas.set(id, output.length)
|
|
||||||
output.push({
|
|
||||||
directory: event.directory,
|
|
||||||
payload: { ...event.payload, properties: { ...props } },
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
existing.payload.properties.delta += props.delta
|
|
||||||
})
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
||||||
if (!event.persisted) return
|
if (!event.persisted) return
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
|
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
|
|
||||||
@@ -78,7 +45,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
[key: string]: Event
|
[key: string]: Event
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
type Queued = QueuedServerEvent
|
type Queued = { directory: string; payload: Event }
|
||||||
const FLUSH_FRAME_MS = 16
|
const FLUSH_FRAME_MS = 16
|
||||||
const STREAM_YIELD_MS = 8
|
const STREAM_YIELD_MS = 8
|
||||||
const RECONNECT_DELAY_MS = 250
|
const RECONNECT_DELAY_MS = 250
|
||||||
@@ -90,6 +57,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
let last = 0
|
let last = 0
|
||||||
|
|
||||||
|
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
||||||
|
|
||||||
const key = (directory: string, payload: Event) => {
|
const key = (directory: string, payload: Event) => {
|
||||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||||
@@ -114,9 +83,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
staleDeltas.clear()
|
staleDeltas.clear()
|
||||||
|
|
||||||
last = Date.now()
|
last = Date.now()
|
||||||
const output = coalesceServerEvents(events, skip)
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
for (const event of events) {
|
||||||
|
if (skip && event.payload.type === "message.part.delta") {
|
||||||
|
const props = event.payload.properties
|
||||||
|
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
|
||||||
|
}
|
||||||
|
emitter.emit(event.directory, event.payload)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
buffer.length = 0
|
buffer.length = 0
|
||||||
@@ -287,31 +261,21 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
export type ServerSDK = ReturnType<typeof createServerSdkContext>
|
||||||
export type ServerSDK = ServerSDKBase & {
|
|
||||||
createDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
|
|
||||||
const sdk = createServerSdkContextBase(server, scope)
|
|
||||||
return Object.assign(sdk, {
|
|
||||||
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
|
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
|
||||||
name: "ServerSDK",
|
name: "ServerSDK",
|
||||||
// Returns an accessor so the resolved server can change reactively (e.g. a
|
init: (props: { server?: ServerConnection.Any }) => {
|
||||||
// /new-session draft retargeting its server) without re-instantiating the subtree.
|
|
||||||
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
|
||||||
return createMemo<ServerSDK>(() => {
|
const conn = props.server ?? server.current
|
||||||
const conn = props.server?.() ?? server.current
|
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
|
||||||
return global.createServerCtx(conn).sdk
|
const ctx = global.createServerCtx(conn)
|
||||||
|
return Object.assign(ctx.sdk, {
|
||||||
|
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, ctx.sdk)),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -320,7 +284,7 @@ type SDKEventMap = {
|
|||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
function createDirSdkContext(directory: string, serverSDK: ServerSDK) {
|
||||||
const client = serverSDK.createClient({
|
const client = serverSDK.createClient({
|
||||||
directory,
|
directory,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
|
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } 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 { batch, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { InitError } from "../pages/error"
|
import type { InitError } from "../pages/error"
|
||||||
import { ServerSDK } from "./server-sdk"
|
import { ServerSDK, useServerSDK } from "./server-sdk"
|
||||||
import {
|
import {
|
||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
bootstrapGlobal,
|
bootstrapGlobal,
|
||||||
@@ -84,7 +84,8 @@ function makeQueryOptionsApi(
|
|||||||
}
|
}
|
||||||
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
||||||
|
|
||||||
export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
||||||
|
const serverSDK: ServerSDK = _serverSDK ?? useServerSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||||
@@ -506,37 +507,33 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSyncContext(serverSDK: ServerSDK) {
|
export function createServerSyncContext(_serverSDK?: ServerSDK) {
|
||||||
const inner = createServerSyncContextInner(serverSDK)
|
const inner = createServerSyncContextInner(_serverSDK)
|
||||||
return Object.assign(inner, {
|
return Object.assign(inner, {
|
||||||
createDirSyncContext: createRefCountMap(
|
createDirSyncContext: createRefCountMap(
|
||||||
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
(dir) => createDirSyncContext(dir, inner, _serverSDK),
|
||||||
(dir) => inner.disableMcp(dir),
|
(dir) => inner.disableMcp(dir),
|
||||||
directoryKey,
|
directoryKey,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerSync = ReturnType<typeof createServerSyncContext>
|
|
||||||
|
|
||||||
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
|
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
|
||||||
name: "ServerSync",
|
name: "ServerSync",
|
||||||
// Returns an accessor so the resolved server can change reactively without
|
gate: false,
|
||||||
// re-instantiating the subtree (mirrors useServerSDK).
|
init: (props: { server?: ServerConnection.Any }) => {
|
||||||
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
|
||||||
return createMemo<ServerSync>(() => {
|
const conn = props.server ?? server.current
|
||||||
const conn = props.server?.() ?? server.current
|
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
const ctx = global.createServerCtx(conn)
|
||||||
return global.createServerCtx(conn).sync
|
|
||||||
})
|
return ctx.sync
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export function useQueryOptions() {
|
export function useQueryOptions() {
|
||||||
const sync = useServerSync()
|
return useServerSync().queryOptions
|
||||||
return createMemo(() => sync().queryOptions)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,15 +153,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
|
||||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
|
||||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
|
||||||
const showCustomAgents = withFallback(
|
|
||||||
() => store.general?.showCustomAgents,
|
|
||||||
defaultSettings.general.showCustomAgents,
|
|
||||||
)
|
|
||||||
const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault)
|
|
||||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
@@ -196,7 +187,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setFollowup(value: "queue" | "steer") {
|
setFollowup(value: "queue" | "steer") {
|
||||||
setStore("general", "followup", value === "queue" ? "steer" : value)
|
setStore("general", "followup", value === "queue" ? "steer" : value)
|
||||||
},
|
},
|
||||||
showFileTree,
|
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
|
||||||
setShowFileTree(value: boolean) {
|
setShowFileTree(value: boolean) {
|
||||||
setStore("general", "showFileTree", value)
|
setStore("general", "showFileTree", value)
|
||||||
},
|
},
|
||||||
@@ -204,11 +195,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setShowNavigation(value: boolean) {
|
setShowNavigation(value: boolean) {
|
||||||
setStore("general", "showNavigation", value)
|
setStore("general", "showNavigation", value)
|
||||||
},
|
},
|
||||||
showSearch,
|
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
|
||||||
setShowSearch(value: boolean) {
|
setShowSearch(value: boolean) {
|
||||||
setStore("general", "showSearch", value)
|
setStore("general", "showSearch", value)
|
||||||
},
|
},
|
||||||
showStatus,
|
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
|
||||||
setShowStatus(value: boolean) {
|
setShowStatus(value: boolean) {
|
||||||
setStore("general", "showStatus", value)
|
setStore("general", "showStatus", value)
|
||||||
},
|
},
|
||||||
@@ -244,21 +235,15 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setShowSessionProgressBar(value: boolean) {
|
setShowSessionProgressBar(value: boolean) {
|
||||||
setStore("general", "showSessionProgressBar", value)
|
setStore("general", "showSessionProgressBar", value)
|
||||||
},
|
},
|
||||||
showCustomAgents,
|
showCustomAgents: withFallback(() => store.general?.showCustomAgents, defaultSettings.general.showCustomAgents),
|
||||||
setShowCustomAgents(value: boolean) {
|
setShowCustomAgents(value: boolean) {
|
||||||
setStore("general", "showCustomAgents", value)
|
setStore("general", "showCustomAgents", value)
|
||||||
},
|
},
|
||||||
newLayoutDesigns,
|
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
|
||||||
setNewLayoutDesigns(value: boolean) {
|
setNewLayoutDesigns(value: boolean) {
|
||||||
setStore("general", "newLayoutDesigns", value)
|
setStore("general", "newLayoutDesigns", value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
visibility: {
|
|
||||||
fileTree: visible(showFileTree),
|
|
||||||
search: visible(showSearch),
|
|
||||||
status: visible(showStatus),
|
|
||||||
customAgents: visible(showCustomAgents),
|
|
||||||
},
|
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||||
setFontSize(value: number) {
|
setFontSize(value: number) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { createMemo } from "solid-js"
|
|
||||||
import { useServerSync } from "./server-sync"
|
import { useServerSync } from "./server-sync"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
@@ -113,7 +112,5 @@ export const useSync = () => {
|
|||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
return createMemo(() => serverSync().createDirSyncContext(sdk().directory))
|
return serverSync.createDirSyncContext(sdk.directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
|
|
||||||
|
|||||||
@@ -27,10 +27,6 @@ export type DraftTab = {
|
|||||||
|
|
||||||
export type Tab = SessionTab | DraftTab
|
export type Tab = SessionTab | DraftTab
|
||||||
|
|
||||||
type RecentTab = {
|
|
||||||
key?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
|
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
|
||||||
|
|
||||||
export const tabHref = (tab: Tab) =>
|
export const tabHref = (tab: Tab) =>
|
||||||
@@ -66,45 +62,26 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
createStore<Tab[]>([]),
|
createStore<Tab[]>([]),
|
||||||
)
|
)
|
||||||
const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
|
|
||||||
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
const closing = new Set<string>()
|
const closing = new Set<string>()
|
||||||
let recentWrite = 0
|
|
||||||
let recentValue: string | undefined
|
|
||||||
|
|
||||||
const recentKey = () => (recentWrite ? recentValue : recent.key)
|
|
||||||
|
|
||||||
const setRecentKey = (key: string | undefined) => {
|
|
||||||
const write = ++recentWrite
|
|
||||||
recentValue = key
|
|
||||||
if (recentReady()) {
|
|
||||||
setRecent("key", key)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void recentReady.promise?.then(() => {
|
|
||||||
if (write === recentWrite) setRecent("key", key)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeDraftPersisted = (draftID: string) => {
|
const removeDraftPersisted = (draftID: string) => {
|
||||||
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready() || !recentReady()) return
|
if (!ready()) return
|
||||||
const servers = new Set(server.list.map(ServerConnection.key))
|
const servers = new Set(server.list.map(ServerConnection.key))
|
||||||
const next = store.filter((tab) => servers.has(tab.server))
|
if (store.every((tab) => servers.has(tab.server))) return
|
||||||
if (next.length !== store.length) setStore(() => next)
|
setStore((tabs) => tabs.filter((tab) => servers.has(tab.server)))
|
||||||
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const navigateTab = (tab: Tab) => {
|
const navigateTab = (tab: Tab) => {
|
||||||
const href = tabHref(tab)
|
const href = tabHref(tab)
|
||||||
setRecentKey(tabKey(tab))
|
|
||||||
if (tab.server === server.key) {
|
if (tab.server === server.key) {
|
||||||
navigate(href)
|
navigate(href)
|
||||||
return
|
return
|
||||||
@@ -152,16 +129,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
// and fall back home. Navigate to the new session first so we leave /new-session
|
// and fall back home. Navigate to the new session first so we leave /new-session
|
||||||
// before the draft is removed from the store.
|
// before the draft is removed from the store.
|
||||||
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
||||||
const next = { type: "session" as const, ...session }
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((tabs) => {
|
produce((tabs) => {
|
||||||
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||||
if (index !== -1) tabs[index] = next
|
if (index !== -1) tabs[index] = { type: "session", ...session }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
|
if (active) navigateTab({ type: "session", ...session })
|
||||||
if (active) navigateTab(next)
|
|
||||||
})
|
})
|
||||||
removeDraftPersisted(draftID)
|
removeDraftPersisted(draftID)
|
||||||
},
|
},
|
||||||
@@ -178,7 +153,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
tabs.splice(index, 1)
|
tabs.splice(index, 1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
|
|
||||||
if (nextTab) navigateTab(nextTab)
|
if (nextTab) navigateTab(nextTab)
|
||||||
else navigate("/")
|
else navigate("/")
|
||||||
}).finally(() => closing.delete(key))
|
}).finally(() => closing.delete(key))
|
||||||
@@ -186,22 +160,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
removeServer(key: ServerConnection.Key) {
|
removeServer(key: ServerConnection.Key) {
|
||||||
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||||
const removed = store.filter((tab) => tab.server === key).map(tabKey)
|
|
||||||
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
||||||
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
|
||||||
for (const draftID of drafts) removeDraftPersisted(draftID)
|
for (const draftID of drafts) removeDraftPersisted(draftID)
|
||||||
if (server.key === key) navigate("/")
|
if (server.key === key) navigate("/")
|
||||||
},
|
},
|
||||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||||
const removed = store
|
|
||||||
.filter(
|
|
||||||
(tab) =>
|
|
||||||
tab.type === "session" &&
|
|
||||||
tab.server === server.key &&
|
|
||||||
atob(tab.dirBase64) === input.directory &&
|
|
||||||
input.sessionIDs.includes(tab.sessionId),
|
|
||||||
)
|
|
||||||
.map(tabKey)
|
|
||||||
void startTransition(() => {
|
void startTransition(() => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((tabs) => {
|
produce((tabs) => {
|
||||||
@@ -244,29 +207,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
else navigate("/")
|
else navigate("/")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
select: navigateTab,
|
|
||||||
remember(tab: Tab) {
|
|
||||||
const key = tabKey(tab)
|
|
||||||
if (recentKey() !== key) setRecentKey(key)
|
|
||||||
},
|
|
||||||
toggleHome(input: { home: boolean; current?: Tab }) {
|
|
||||||
if (input.home) {
|
|
||||||
const tab = store.find((tab) => tabKey(tab) === recentKey())
|
|
||||||
if (tab) navigateTab(tab)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (input.current) {
|
|
||||||
setRecentKey(tabKey(input.current))
|
|
||||||
navigate("/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
navigate("/")
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...actions, store, ready, recentReady }
|
return { ...actions, store, ready }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createStore, produce } from "solid-js/store"
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
|
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useSDK, type DirectorySDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Platform } from "./platform"
|
import type { Platform } from "./platform"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
import { defaultTitle, titleNumber } from "./terminal-title"
|
import { defaultTitle, titleNumber } from "./terminal-title"
|
||||||
@@ -143,7 +143,7 @@ export function clearWorkspaceTerminals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createWorkspaceTerminalSession(
|
function createWorkspaceTerminalSession(
|
||||||
sdk: DirectorySDK,
|
sdk: ReturnType<typeof useSDK>,
|
||||||
dir: string,
|
dir: string,
|
||||||
scope: ServerScopeValue,
|
scope: ServerScopeValue,
|
||||||
legacySessionID?: string,
|
legacySessionID?: string,
|
||||||
@@ -202,7 +202,7 @@ function createWorkspaceTerminalSession(
|
|||||||
})
|
})
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|
||||||
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
||||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
const index = store.all.findIndex((x) => x.id === pty.id)
|
||||||
const previous = index >= 0 ? store.all[index] : undefined
|
const previous = index >= 0 ? store.all[index] : undefined
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
@@ -223,7 +223,7 @@ function createWorkspaceTerminalSession(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const clone = async (client: DirectorySDK["client"], id: string) => {
|
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
const pty = store.all[index]
|
const pty = store.all[index]
|
||||||
if (!pty) return
|
if (!pty) return
|
||||||
@@ -412,7 +412,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entry = createRoot((dispose) => ({
|
const entry = createRoot((dispose) => ({
|
||||||
value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID),
|
value: createWorkspaceTerminalSession(sdk, dir, serverScope, legacySessionID),
|
||||||
dispose,
|
dispose,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
|||||||
id: "view",
|
id: "view",
|
||||||
label: "View",
|
label: "View",
|
||||||
items: [
|
items: [
|
||||||
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" },
|
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } },
|
||||||
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
|
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
|
||||||
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
|
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ export function useProviders() {
|
|||||||
const dir = createMemo(() => decode64(params.dir) ?? "")
|
const dir = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const providers = () => {
|
const providers = () => {
|
||||||
if (dir()) {
|
if (dir()) {
|
||||||
const [projectStore] = serverSync().child(dir())
|
const [projectStore] = serverSync.child(dir())
|
||||||
if (projectStore.provider_ready) return projectStore.provider
|
if (projectStore.provider_ready) return projectStore.provider
|
||||||
}
|
}
|
||||||
return serverSync().data.provider
|
return serverSync.data.provider
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
all: () => providers().all,
|
all: () => providers().all,
|
||||||
|
|||||||
@@ -281,11 +281,6 @@ export const dict = {
|
|||||||
"dialog.fork.empty": "لا توجد رسائل للتفرع منها",
|
"dialog.fork.empty": "لا توجد رسائل للتفرع منها",
|
||||||
"dialog.directory.search.placeholder": "البحث في المجلدات",
|
"dialog.directory.search.placeholder": "البحث في المجلدات",
|
||||||
"dialog.directory.empty": "لم يتم العثور على مجلدات",
|
"dialog.directory.empty": "لم يتم العثور على مجلدات",
|
||||||
"dialog.directory.action.selectFile": "اختيار ملف",
|
|
||||||
"dialog.directory.action.selectFolder": "اختيار مجلد",
|
|
||||||
"dialog.directory.root": "الجذر",
|
|
||||||
"dialog.directory.parent": "المجلد الأصل",
|
|
||||||
"dialog.directory.readError": "تعذرت قراءة هذا المجلد",
|
|
||||||
"dialog.server.title": "الخوادم",
|
"dialog.server.title": "الخوادم",
|
||||||
"dialog.server.description": "تبديل خادم OpenCode الذي يتصل به هذا التطبيق.",
|
"dialog.server.description": "تبديل خادم OpenCode الذي يتصل به هذا التطبيق.",
|
||||||
"dialog.server.search.placeholder": "البحث في الخوادم",
|
"dialog.server.search.placeholder": "البحث في الخوادم",
|
||||||
|
|||||||
@@ -281,11 +281,6 @@ export const dict = {
|
|||||||
"dialog.fork.empty": "Nenhuma mensagem para bifurcar",
|
"dialog.fork.empty": "Nenhuma mensagem para bifurcar",
|
||||||
"dialog.directory.search.placeholder": "Buscar pastas",
|
"dialog.directory.search.placeholder": "Buscar pastas",
|
||||||
"dialog.directory.empty": "Nenhuma pasta encontrada",
|
"dialog.directory.empty": "Nenhuma pasta encontrada",
|
||||||
"dialog.directory.action.selectFile": "Selecionar arquivo",
|
|
||||||
"dialog.directory.action.selectFolder": "Selecionar pasta",
|
|
||||||
"dialog.directory.root": "Raiz",
|
|
||||||
"dialog.directory.parent": "Pasta superior",
|
|
||||||
"dialog.directory.readError": "Não foi possível ler esta pasta",
|
|
||||||
"dialog.server.title": "Servidores",
|
"dialog.server.title": "Servidores",
|
||||||
"dialog.server.description": "Trocar para qual servidor OpenCode este aplicativo se conecta.",
|
"dialog.server.description": "Trocar para qual servidor OpenCode este aplicativo se conecta.",
|
||||||
"dialog.server.search.placeholder": "Buscar servidores",
|
"dialog.server.search.placeholder": "Buscar servidores",
|
||||||
|
|||||||
@@ -307,11 +307,6 @@ export const dict = {
|
|||||||
|
|
||||||
"dialog.directory.search.placeholder": "Pretraži foldere",
|
"dialog.directory.search.placeholder": "Pretraži foldere",
|
||||||
"dialog.directory.empty": "Nema pronađenih foldera",
|
"dialog.directory.empty": "Nema pronađenih foldera",
|
||||||
"dialog.directory.action.selectFile": "Odaberi datoteku",
|
|
||||||
"dialog.directory.action.selectFolder": "Odaberi folder",
|
|
||||||
"dialog.directory.root": "Korijen",
|
|
||||||
"dialog.directory.parent": "Nadređeni folder",
|
|
||||||
"dialog.directory.readError": "Nije moguće pročitati ovaj folder",
|
|
||||||
|
|
||||||
"dialog.server.title": "Serveri",
|
"dialog.server.title": "Serveri",
|
||||||
"dialog.server.description": "Promijeni na koji se OpenCode server ova aplikacija povezuje.",
|
"dialog.server.description": "Promijeni na koji se OpenCode server ova aplikacija povezuje.",
|
||||||
|
|||||||
@@ -305,11 +305,6 @@ export const dict = {
|
|||||||
|
|
||||||
"dialog.directory.search.placeholder": "Søg mapper",
|
"dialog.directory.search.placeholder": "Søg mapper",
|
||||||
"dialog.directory.empty": "Ingen mapper fundet",
|
"dialog.directory.empty": "Ingen mapper fundet",
|
||||||
"dialog.directory.action.selectFile": "Vælg fil",
|
|
||||||
"dialog.directory.action.selectFolder": "Vælg mappe",
|
|
||||||
"dialog.directory.root": "Rod",
|
|
||||||
"dialog.directory.parent": "Overordnet",
|
|
||||||
"dialog.directory.readError": "Denne mappe kan ikke læses",
|
|
||||||
|
|
||||||
"dialog.server.title": "Servere",
|
"dialog.server.title": "Servere",
|
||||||
"dialog.server.description": "Skift hvilken OpenCode-server denne app forbinder til.",
|
"dialog.server.description": "Skift hvilken OpenCode-server denne app forbinder til.",
|
||||||
|
|||||||
@@ -287,11 +287,6 @@ export const dict = {
|
|||||||
"dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden",
|
"dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden",
|
||||||
"dialog.directory.search.placeholder": "Ordner durchsuchen",
|
"dialog.directory.search.placeholder": "Ordner durchsuchen",
|
||||||
"dialog.directory.empty": "Keine Ordner gefunden",
|
"dialog.directory.empty": "Keine Ordner gefunden",
|
||||||
"dialog.directory.action.selectFile": "Datei auswählen",
|
|
||||||
"dialog.directory.action.selectFolder": "Ordner auswählen",
|
|
||||||
"dialog.directory.root": "Stammverzeichnis",
|
|
||||||
"dialog.directory.parent": "Übergeordnet",
|
|
||||||
"dialog.directory.readError": "Dieser Ordner kann nicht gelesen werden",
|
|
||||||
"dialog.server.title": "Server",
|
"dialog.server.title": "Server",
|
||||||
"dialog.server.description": "Wechseln Sie den OpenCode-Server, mit dem sich diese App verbindet.",
|
"dialog.server.description": "Wechseln Sie den OpenCode-Server, mit dem sich diese App verbindet.",
|
||||||
"dialog.server.search.placeholder": "Server durchsuchen",
|
"dialog.server.search.placeholder": "Server durchsuchen",
|
||||||
|
|||||||
@@ -313,11 +313,6 @@ export const dict = {
|
|||||||
|
|
||||||
"dialog.directory.search.placeholder": "Search folders",
|
"dialog.directory.search.placeholder": "Search folders",
|
||||||
"dialog.directory.empty": "No folders found",
|
"dialog.directory.empty": "No folders found",
|
||||||
"dialog.directory.action.selectFile": "Select file",
|
|
||||||
"dialog.directory.action.selectFolder": "Select folder",
|
|
||||||
"dialog.directory.root": "Root",
|
|
||||||
"dialog.directory.parent": "Parent",
|
|
||||||
"dialog.directory.readError": "Unable to read this folder",
|
|
||||||
|
|
||||||
"app.server.unreachable": "Could not reach {{server}}",
|
"app.server.unreachable": "Could not reach {{server}}",
|
||||||
"app.server.retrying": "Retrying automatically...",
|
"app.server.retrying": "Retrying automatically...",
|
||||||
@@ -830,17 +825,17 @@ export const dict = {
|
|||||||
"settings.general.row.followup.option.queue": "Queue",
|
"settings.general.row.followup.option.queue": "Queue",
|
||||||
"settings.general.row.followup.option.steer": "Steer",
|
"settings.general.row.followup.option.steer": "Steer",
|
||||||
"settings.general.row.showFileTree.title": "File tree",
|
"settings.general.row.showFileTree.title": "File tree",
|
||||||
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
|
"settings.general.row.showFileTree.description": "Show the file tree panel in desktop sessions",
|
||||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||||
"settings.general.row.showSearch.title": "Command palette",
|
"settings.general.row.showSearch.title": "Command palette",
|
||||||
"settings.general.row.showSearch.description": "Show the search and command palette button in the title bar",
|
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
|
||||||
"settings.general.row.showTerminal.title": "Terminal",
|
"settings.general.row.showTerminal.title": "Terminal",
|
||||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||||
"settings.general.row.showStatus.title": "Server status",
|
"settings.general.row.showStatus.title": "Server status",
|
||||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
|
||||||
"settings.general.row.showCustomAgents.title": "Custom agents",
|
"settings.general.row.showCustomAgents.title": "Custom agents",
|
||||||
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
|
"settings.general.row.showCustomAgents.description": "Show the agent picker in the v2 desktop composer",
|
||||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||||
|
|||||||
@@ -306,11 +306,6 @@ export const dict = {
|
|||||||
|
|
||||||
"dialog.directory.search.placeholder": "Buscar carpetas",
|
"dialog.directory.search.placeholder": "Buscar carpetas",
|
||||||
"dialog.directory.empty": "No se encontraron carpetas",
|
"dialog.directory.empty": "No se encontraron carpetas",
|
||||||
"dialog.directory.action.selectFile": "Seleccionar archivo",
|
|
||||||
"dialog.directory.action.selectFolder": "Seleccionar carpeta",
|
|
||||||
"dialog.directory.root": "Raíz",
|
|
||||||
"dialog.directory.parent": "Superior",
|
|
||||||
"dialog.directory.readError": "No se puede leer esta carpeta",
|
|
||||||
|
|
||||||
"dialog.server.title": "Servidores",
|
"dialog.server.title": "Servidores",
|
||||||
"dialog.server.description": "Cambiar a qué servidor de OpenCode se conecta esta app.",
|
"dialog.server.description": "Cambiar a qué servidor de OpenCode se conecta esta app.",
|
||||||
|
|||||||
@@ -282,11 +282,6 @@ export const dict = {
|
|||||||
"dialog.fork.empty": "Aucun message à partir duquel bifurquer",
|
"dialog.fork.empty": "Aucun message à partir duquel bifurquer",
|
||||||
"dialog.directory.search.placeholder": "Rechercher des dossiers",
|
"dialog.directory.search.placeholder": "Rechercher des dossiers",
|
||||||
"dialog.directory.empty": "Aucun dossier trouvé",
|
"dialog.directory.empty": "Aucun dossier trouvé",
|
||||||
"dialog.directory.action.selectFile": "Sélectionner le fichier",
|
|
||||||
"dialog.directory.action.selectFolder": "Sélectionner le dossier",
|
|
||||||
"dialog.directory.root": "Racine",
|
|
||||||
"dialog.directory.parent": "Parent",
|
|
||||||
"dialog.directory.readError": "Impossible de lire ce dossier",
|
|
||||||
"dialog.server.title": "Serveurs",
|
"dialog.server.title": "Serveurs",
|
||||||
"dialog.server.description": "Changez le serveur OpenCode auquel cette application se connecte.",
|
"dialog.server.description": "Changez le serveur OpenCode auquel cette application se connecte.",
|
||||||
"dialog.server.search.placeholder": "Rechercher des serveurs",
|
"dialog.server.search.placeholder": "Rechercher des serveurs",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user