Compare commits
206 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 537ce02685 | |||
| d3bbfff826 | |||
| 468f425e76 | |||
| d59619fffd | |||
| 5606d2bab9 | |||
| e84d94d998 | |||
| 4f1ae1604e | |||
| d99f86b28d | |||
| 22cc758b1a | |||
| 0b7ec51d0a | |||
| 4f1a9d7aef | |||
| 503309d244 | |||
| 1c76587ce2 | |||
| 24c70ec974 | |||
| 2d993cd0d5 | |||
| babe5070e2 | |||
| 009f3799cd | |||
| 95237a90a7 | |||
| e6cdc543f3 | |||
| f092bafe88 | |||
| 3f1fffeb6d | |||
| c6083a474c | |||
| 10ec856ff2 | |||
| 0f6c9b3874 | |||
| 355a0bcf5b | |||
| 2892e97c57 | |||
| 62c746f2e8 | |||
| ec50db334b | |||
| 8716c4309a | |||
| 10b6672be1 | |||
| 85a79292e6 | |||
| 5c9e4ff21b | |||
| 417ad240c7 | |||
| 1e63e76192 | |||
| 213ff3f2d7 | |||
| 3ab3d04ec7 | |||
| 8fd5753f76 | |||
| 273efdeee7 | |||
| 3b811bd019 | |||
| e772664389 | |||
| 1c2c2d6b52 | |||
| 88f5b9a90e | |||
| 25cb2be619 | |||
| bd84c32860 | |||
| 94652cfe4a | |||
| 3a2ff11be8 | |||
| a98d5732c0 | |||
| 5d0f86606a | |||
| 98d66e9a8b | |||
| 0dbfb6bc0b | |||
| 9258e8ca86 | |||
| 9fdfa23f01 | |||
| dfb616f067 | |||
| 1338d7b47a | |||
| a774c62eac | |||
| a9a4b2f00f | |||
| e4ccb505bf | |||
| 85e278b728 | |||
| 87c33b3d85 | |||
| 3f81402663 | |||
| d37ddc501c | |||
| 3e523d506c | |||
| e4d4b07e7d | |||
| 51461429f4 | |||
| 3ab19bfd7d | |||
| 0cf3ee4406 | |||
| 4810df0a71 | |||
| 010b456dfd | |||
| c81cd3202c | |||
| 7ad68f8150 | |||
| 8cc2276dba | |||
| f2cf607376 | |||
| 7efade2d53 | |||
| 231f4944c7 | |||
| f55a931f59 | |||
| 42f339c681 | |||
| 409ba229ab | |||
| 252223a4a7 | |||
| 11fd6f8255 | |||
| 7143bf8ff0 | |||
| 414c037b21 | |||
| 632f94fa68 | |||
| a6e3afe048 | |||
| 3f174531b9 | |||
| fcca731a96 | |||
| c7dee9c609 | |||
| 45e4606fa4 | |||
| 9ae4a5139f | |||
| d73348719e | |||
| 2630f457b9 | |||
| dbbe67f066 | |||
| fff0ec294c | |||
| cf2d1dd3e9 | |||
| 27ca0f8825 | |||
| 73dbd8a312 | |||
| 443f103ee3 | |||
| 6c36b585c3 | |||
| c2e6b18076 | |||
| 8d97c8d412 | |||
| f8b357b267 | |||
| 621796d8ce | |||
| ba2455ecc7 | |||
| 44308dfd7e | |||
| b000256f57 | |||
| 30b2544fe1 | |||
| 1b096b4148 | |||
| be227503af | |||
| 4ddfa7c6fa | |||
| 2415434ad9 | |||
| bf4c647653 | |||
| f35bb5184c | |||
| 2c5335d84c | |||
| 7793db3ac8 | |||
| 5f77482a29 | |||
| 30aec297d8 | |||
| a9c810cbbc | |||
| fe2e4e21d0 | |||
| 03a2504d3d | |||
| bace18ccd3 | |||
| cd2e6f06ee | |||
| 7e7ad37736 | |||
| ca8db315a9 | |||
| 9eb07ab55a | |||
| e07e420637 | |||
| 84f94e6143 | |||
| a150424c16 | |||
| 2e71292f2f | |||
| e2527db3c7 | |||
| a1dee8b273 | |||
| 04e5ca9d1b | |||
| 6dd4d1473c | |||
| 31c5454ee6 | |||
| 31b233e9db | |||
| 567d6ed1a3 | |||
| 2bde20c2ba | |||
| 92c70c9c3d | |||
| 318dbe93ba | |||
| 8bd249d8cf | |||
| 710e408709 | |||
| dac0dd5309 | |||
| bf05e8a122 | |||
| 51b10b128e | |||
| 47a45601fd | |||
| 69623c2b79 | |||
| 38536cf733 | |||
| 8bf0675997 | |||
| ff967e582c | |||
| 20bf18ffb4 | |||
| 51891d56e7 | |||
| cc22646467 | |||
| eb70b6137b | |||
| 6e2bcafd34 | |||
| 07b983e82f | |||
| c51a1588c7 | |||
| f43b0d3afd | |||
| 722f4dd416 | |||
| bb82aab5c8 | |||
| 936363e7a8 | |||
| 8688ed7e3e | |||
| 5b54203168 | |||
| bed780fac9 | |||
| 14ec7ed5b3 | |||
| 2c65273204 | |||
| 2e0f88d0e3 | |||
| e1073e5d18 | |||
| 649618c50a | |||
| 02608a4e97 | |||
| 3ad6923c61 | |||
| e4300e9b74 | |||
| 538cfaff0d | |||
| 1dad38d1b5 | |||
| c9e2a38bf4 | |||
| 5863e1254c | |||
| 4c9abff445 | |||
| 2cf68f32b5 | |||
| 90fb32be30 | |||
| 97e713e8aa | |||
| 826419127a | |||
| e0449c0b96 | |||
| 5e342f7114 | |||
| 174ab58343 | |||
| 954d618790 | |||
| 91073360c6 | |||
| 8a2cfc00c9 | |||
| 0fc33e2a06 | |||
| 6c6ed68b5a | |||
| e9106efbdc | |||
| e9e2612706 | |||
| 4ae468ff06 | |||
| 785918262a | |||
| be8fee5c2b | |||
| 4597c687fa | |||
| c4aa049042 | |||
| 1d46b5c33c | |||
| 6ae6f0fe8f | |||
| a0409e64d8 | |||
| ce4e658e3f | |||
| 215fb39fc5 | |||
| 1cc94bb23e | |||
| bc42187c58 | |||
| 80c0b06980 | |||
| 381eabb970 | |||
| c939aa04fe | |||
| 9b8e356fca | |||
| 07e5ea9367 | |||
| 7a54a2c49c |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
mode: primary
|
||||
hidden: true
|
||||
model: opencode/gpt-5.4-nano
|
||||
model: opencode/gpt-5.4-mini
|
||||
color: "#44BA81"
|
||||
tools:
|
||||
"*": false
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {},
|
||||
"permission": {},
|
||||
"reference": {
|
||||
"effect": "github.com/Effect-TS/effect-smol",
|
||||
"references": {
|
||||
"effect": {
|
||||
"repository": "github.com/Effect-TS/effect-smol",
|
||||
"description": "Use for Effect v4 and effect-smol implementation details",
|
||||
},
|
||||
"opencode-local": {
|
||||
"path": "~/.local/share/opencode",
|
||||
"description": "Contains opencode logs and data",
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
"tools": {
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
## Branch Names
|
||||
|
||||
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
||||
|
||||
Examples: `session-recovery`, `fix-scroll-state`, `regenerate-sdk`.
|
||||
|
||||
## Commits and PR Titles
|
||||
|
||||
Use conventional commit-style messages and PR titles: `type(scope): summary`.
|
||||
|
||||
@@ -52,6 +52,9 @@ _Avoid_: Request body, wire options
|
||||
**Generation Controls**:
|
||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||
|
||||
**PTY Environment**:
|
||||
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||
@@ -99,6 +102,8 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
|
||||
- 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.
|
||||
- **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.
|
||||
- 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.
|
||||
|
||||
@@ -29,12 +29,13 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
@@ -52,6 +53,7 @@
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -65,7 +67,6 @@
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"virtua": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
@@ -85,7 +86,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -110,7 +111,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -146,7 +147,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -173,9 +174,9 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
"@ai-sdk/openai-compatible": "2.0.37",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
@@ -195,7 +196,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -219,7 +220,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -239,14 +240,14 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
@@ -268,7 +269,7 @@
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.9.3",
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
@@ -288,7 +289,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.8.0",
|
||||
"gitlab-ai-provider": "6.9.3",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -330,14 +331,14 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "^10",
|
||||
"electron-updater": "^6",
|
||||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"marked": "^15",
|
||||
},
|
||||
@@ -356,8 +357,8 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@valibot/to-json-schema": "1.6.0",
|
||||
"electron": "41.2.1",
|
||||
"electron-builder": "^26",
|
||||
"electron": "42.3.3",
|
||||
"electron-builder": "26.15.2",
|
||||
"electron-vite": "^5",
|
||||
"solid-js": "catalog:",
|
||||
"sury": "11.0.0-alpha.4",
|
||||
@@ -384,7 +385,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -398,7 +399,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -410,7 +411,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -441,7 +442,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -457,10 +458,10 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "0.0.0",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -471,12 +472,12 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.74",
|
||||
"effect": "4.0.0-beta.83",
|
||||
},
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -494,7 +495,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -504,7 +505,7 @@
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
@@ -525,6 +526,7 @@
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
@@ -563,7 +565,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.8.0",
|
||||
"gitlab-ai-provider": "6.9.3",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -575,7 +577,7 @@
|
||||
"minimatch": "10.0.3",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.1",
|
||||
"opencode-gitlab-auth": "2.1.0",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
"opentui-spinner": "catalog:",
|
||||
"partial-json": "0.1.7",
|
||||
@@ -621,7 +623,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -659,7 +661,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -674,7 +676,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -688,7 +690,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -701,7 +703,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
@@ -734,7 +736,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -753,7 +755,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -793,7 +795,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "0.0.0",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -802,7 +804,6 @@
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -821,12 +822,13 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/stream": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
@@ -852,7 +854,6 @@
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"virtua": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
@@ -870,7 +871,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -912,15 +913,18 @@
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.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",
|
||||
"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/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.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",
|
||||
"@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",
|
||||
"@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",
|
||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -931,9 +935,9 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -944,15 +948,17 @@
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
"@sentry/vite-plugin": "4.6.0",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.13",
|
||||
@@ -967,25 +973,24 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.74",
|
||||
"effect": "4.0.0-beta.83",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "17.0.1",
|
||||
"marked-shiki": "1.2.1",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"opentui-spinner": "0.0.7",
|
||||
"remeda": "2.26.0",
|
||||
"remend": "1.3.0",
|
||||
"semver": "7.7.4",
|
||||
"shiki": "3.20.0",
|
||||
"shiki": "4.2.0",
|
||||
"solid-js": "1.9.10",
|
||||
"solid-list": "0.3.0",
|
||||
"sst": "4.13.1",
|
||||
"tailwindcss": "4.1.11",
|
||||
"typescript": "5.8.2",
|
||||
"ulid": "3.0.1",
|
||||
"virtua": "0.49.1",
|
||||
"vite": "7.1.4",
|
||||
"vite-plugin-solid": "2.11.10",
|
||||
"zod": "4.1.8",
|
||||
@@ -1013,7 +1018,7 @@
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
|
||||
|
||||
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
|
||||
|
||||
@@ -1281,6 +1286,8 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
|
||||
|
||||
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="],
|
||||
|
||||
"@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.12.0", "", { "dependencies": { "@bufbuild/protobuf": "2.12.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow=="],
|
||||
@@ -1321,19 +1328,19 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.74", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.74" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-flpyqLPyr+THSe6ZCGRZl6hi+FqxbIXNSkslKGiRJAjbPabam9mSp7R3aC8biIMt6xE4Fd0LNfo4p2GplUkm2Q=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74", "ioredis": "^5.7.0" } }, "sha512-/W16mKqxvhWINLjufzc0log1sl57exXQfwd+em398/zKCbmU3S7snXTDMN6w0ju2TtgK35qrsoGBXEochij6Sg=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.74", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-C6C2hXixNcZXLaFF2u7B/FtOsqpdY7luaPuiGFBJza0P7EnYDkwaT3kB6lv7l/qctmkADc24qOsSCWIKRbC4jg=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
|
||||
|
||||
"@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
|
||||
"@electron/get": ["@electron/get@5.0.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA=="],
|
||||
|
||||
"@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="],
|
||||
|
||||
@@ -1445,23 +1452,23 @@
|
||||
|
||||
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
|
||||
|
||||
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-isGuuEbAo7D6psAllm4+TRONxmDfhlmm548IjsG5hEH4I/pwTTTtrRg4lpMDwQ/cD5I3kEL2KVEYdlwuyFod8w=="],
|
||||
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw=="],
|
||||
|
||||
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vJMyCHtE5/CqCmvH7kEDSkUK9/YImoGZuIrRd6yLBjpSTtwyr0QIYjXDsFSj8a4eyxP3ieZWBw9z+uekPZ4YHw=="],
|
||||
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bapVTzIJZ40WmGYpAN+X3hIOqeynNTH1WPTp6S2pDMj6WQIG0lO4zWboNRAhVxIdsBq7vJwiBm4BKN+8Wp4wzg=="],
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-opdtJbCmDB/SjHx+IaM6DF6UpYUZ8saXbwiAHamqg8ywhBWQoGzTo66BBwbaf6kd7sv7hJsYrUVBqLJhZGfL4A=="],
|
||||
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-74kucsnuCsp0daZQGtg0YYJL8h8ypt/efzSQjEuja2GPLdZrW9zVO1p+EWP9FZIt0bAf1o71W3PWjSEa3dTLUQ=="],
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q=="],
|
||||
|
||||
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zgbzi24qWaE1l8bFweApM8Zd1ymxfP5tf9yX9k+PqmOGdGQhGWwbWTxB6UCUu+BiLPd+78Lxzp4oIBoSsZzejA=="],
|
||||
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w=="],
|
||||
|
||||
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2ZB3LgEXWY0BJVpN6zr2JeuGYQbOZhNVZYYkKGY9g48L/nUuuB2X1HzQTLQ0zPipmFoPG7dUFlTjl+qmQhJPRw=="],
|
||||
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ=="],
|
||||
|
||||
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.3", "", { "os": "win32", "cpu": "x64" }, "sha512-K6PycT3FluRUEtOqsySbq8oHxP8XeyvdWtnxMlnaSSLc5LKlWg3CKvc+kxfq7UkpySA9LlPk+Qp/C1IvJ890QA=="],
|
||||
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw=="],
|
||||
|
||||
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.3", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.3", "@ff-labs/fff-bin-darwin-x64": "0.9.3", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.3", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-x64-musl": "0.9.3", "@ff-labs/fff-bin-win32-arm64": "0.9.3", "@ff-labs/fff-bin-win32-x64": "0.9.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-PPSsmSf1+xD/8eLelBDYFcmlmQUPRCm+GO4K/PgtuLtLu0CWsoxyStykgjw+0GP3bTUVNdHK1FYwESk0hmY6lg=="],
|
||||
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", "@ff-labs/fff-bin-darwin-x64": "0.9.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", "@ff-labs/fff-bin-win32-arm64": "0.9.4", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-7HUraaK/g5dStAnuKAuzsXVOQvqqX0ylo5G+DxYwsCjCDc42bjoEAAHqz/3Sn3raUNw97KMoz87XR9QyrLEfVw=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
@@ -1675,6 +1682,8 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
@@ -2109,9 +2118,21 @@
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
|
||||
|
||||
"@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=="],
|
||||
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.7.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg=="],
|
||||
|
||||
"@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="],
|
||||
"@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="],
|
||||
|
||||
"@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="],
|
||||
|
||||
"@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/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -2315,13 +2336,17 @@
|
||||
|
||||
"@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@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-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-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
|
||||
"@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/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
|
||||
"@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
|
||||
"@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/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=="],
|
||||
|
||||
@@ -2343,7 +2368,7 @@
|
||||
|
||||
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
|
||||
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
|
||||
|
||||
@@ -2565,6 +2590,10 @@
|
||||
|
||||
"@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/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=="],
|
||||
@@ -2769,6 +2798,8 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="],
|
||||
@@ -2853,7 +2884,7 @@
|
||||
|
||||
"app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="],
|
||||
|
||||
"app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="],
|
||||
"app-builder-lib": ["app-builder-lib@26.15.2", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.2", "electron-builder-squirrel-windows": "26.15.2" } }, "sha512-3mYfKOjr/ZY7gFESOcq8kylBMgGPpmlQYnpBVit4p6zIg0t/8bkWBILdMMtnjFyN2jllyBf225T8dLlz3D6oBQ=="],
|
||||
|
||||
"archiver": ["archiver@7.0.1", "", { "dependencies": { "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", "tar-stream": "^3.0.0", "zip-stream": "^6.0.1" } }, "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ=="],
|
||||
|
||||
@@ -2881,12 +2912,16 @@
|
||||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
|
||||
|
||||
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
||||
|
||||
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="],
|
||||
|
||||
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
|
||||
|
||||
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
|
||||
@@ -2917,6 +2952,8 @@
|
||||
|
||||
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
|
||||
|
||||
"aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="],
|
||||
|
||||
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
|
||||
"axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="],
|
||||
@@ -2975,6 +3012,8 @@
|
||||
|
||||
"blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="],
|
||||
|
||||
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
||||
|
||||
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
|
||||
|
||||
"bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
|
||||
@@ -3007,9 +3046,9 @@
|
||||
|
||||
"buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
|
||||
|
||||
"builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
|
||||
"builder-util": ["builder-util@26.15.0", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA=="],
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
|
||||
|
||||
@@ -3021,6 +3060,8 @@
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="],
|
||||
|
||||
"c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
@@ -3133,7 +3174,7 @@
|
||||
|
||||
"condense-newlines": ["condense-newlines@0.2.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", "kind-of": "^3.0.2" } }, "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg=="],
|
||||
|
||||
"conf": ["conf@14.0.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^9.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.4.0" } }, "sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw=="],
|
||||
"conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="],
|
||||
|
||||
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
|
||||
|
||||
@@ -3153,7 +3194,7 @@
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
@@ -3281,7 +3322,7 @@
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="],
|
||||
"dmg-builder": ["dmg-builder@26.15.2", "", { "dependencies": { "app-builder-lib": "26.15.2", "builder-util": "26.15.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA=="],
|
||||
|
||||
"dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
|
||||
|
||||
@@ -3315,6 +3356,8 @@
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
@@ -3323,13 +3366,13 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
||||
"effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"electron": ["electron@41.2.1", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-teeRThiYGTPKf/2yOW7zZA1bhb91KEQ4yLBPOg7GxpmnkLFLugKgQaAKOrCgdzwsXh/5mFIfmkm+4+wACJKwaA=="],
|
||||
"electron": ["electron@42.3.3", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ=="],
|
||||
|
||||
"electron-builder": ["electron-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="],
|
||||
"electron-builder": ["electron-builder@26.15.2", "", { "dependencies": { "app-builder-lib": "26.15.2", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-veKM9+dCljaC5A74Pwc0ZWQ9arOHREXWh9hUIf8NGg49ch7x+IB4QhbMzIrV5ONZIXM2OEkaxW11cAPjPtoi4A=="],
|
||||
|
||||
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="],
|
||||
|
||||
@@ -3341,13 +3384,13 @@
|
||||
|
||||
"electron-log": ["electron-log@5.4.4", "", {}, "sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA=="],
|
||||
|
||||
"electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
|
||||
"electron-publish": ["electron-publish@26.15.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg=="],
|
||||
|
||||
"electron-store": ["electron-store@10.1.0", "", { "dependencies": { "conf": "^14.0.0", "type-fest": "^4.41.0" } }, "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w=="],
|
||||
"electron-store": ["electron-store@11.0.2", "", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="],
|
||||
|
||||
"electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="],
|
||||
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
|
||||
|
||||
"electron-vite": ["electron-vite@5.0.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-arrow-functions": "^7.27.1", "cac": "^6.7.14", "esbuild": "^0.25.11", "magic-string": "^0.30.19", "picocolors": "^1.1.1" }, "peerDependencies": { "@swc/core": "^1.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "electron-vite": "bin/electron-vite.js" } }, "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ=="],
|
||||
|
||||
@@ -3363,6 +3406,8 @@
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="],
|
||||
@@ -3373,7 +3418,7 @@
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
|
||||
|
||||
@@ -3603,7 +3648,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.8.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-KwHASXkHtDcgrzTXZVp9Dyx6t8m9nK0R2fCm47MWcxxQ1kOBt3f2LZugtu1kOby8i4Sbd+kvBSYM66PGkDclng=="],
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.9.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-lWo6b6es5+k9iXaDIvE9ECzyK4zfEza4+dQ5FN8SJpEuVRi3ZBCpHIOTa32QoYEDCBaiPh+tcyca86PfNodmlg=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
@@ -3883,6 +3928,12 @@
|
||||
|
||||
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
|
||||
|
||||
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
||||
|
||||
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
|
||||
|
||||
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
|
||||
|
||||
"iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="],
|
||||
|
||||
"iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="],
|
||||
@@ -4051,6 +4102,8 @@
|
||||
|
||||
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
"make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="],
|
||||
|
||||
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
|
||||
@@ -4295,6 +4348,8 @@
|
||||
|
||||
"node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
|
||||
|
||||
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
|
||||
|
||||
"node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
|
||||
@@ -4363,13 +4418,13 @@
|
||||
|
||||
"opencode": ["opencode@workspace:packages/opencode"],
|
||||
|
||||
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.1", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-1EMZHdbADLMVaTVLQ6C/V8uVMDr6MP++osj2lmOecowtn46AafP/w6ADkV4AN/ddjA1rob5cWpMuf/iME6DI6A=="],
|
||||
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.1.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-ZCDYaY0V8Se6hOH2tqZqqcskrd0xLTgfiGhU0J1igkUP52oFtN9eSwxOPLT0ctvNXUq8b+zOmJ4sskAQoC/IUA=="],
|
||||
|
||||
"opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="],
|
||||
|
||||
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
|
||||
|
||||
"opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="],
|
||||
"opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
|
||||
@@ -4489,6 +4544,8 @@
|
||||
|
||||
"pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
|
||||
|
||||
"pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="],
|
||||
|
||||
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||
@@ -4521,6 +4578,10 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@@ -4571,6 +4632,10 @@
|
||||
|
||||
"pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="],
|
||||
|
||||
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
|
||||
|
||||
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
|
||||
|
||||
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
||||
|
||||
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
@@ -4799,7 +4864,7 @@
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"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=="],
|
||||
"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=="],
|
||||
|
||||
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
|
||||
|
||||
@@ -4977,6 +5042,8 @@
|
||||
|
||||
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
@@ -5171,6 +5238,8 @@
|
||||
|
||||
"unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="],
|
||||
|
||||
"unzipper": ["unzipper@0.12.3", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "^11.2.0", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
@@ -5205,8 +5274,6 @@
|
||||
|
||||
"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-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=="],
|
||||
@@ -5261,6 +5328,8 @@
|
||||
|
||||
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
|
||||
|
||||
"webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="],
|
||||
@@ -5385,7 +5454,9 @@
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
@@ -5451,6 +5522,8 @@
|
||||
|
||||
"@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/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
@@ -5627,9 +5700,7 @@
|
||||
|
||||
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
"@electron/get/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
|
||||
|
||||
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
@@ -5743,8 +5814,6 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
@@ -5759,8 +5828,6 @@
|
||||
|
||||
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
|
||||
"@opencode-ai/tui/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
|
||||
|
||||
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
@@ -5779,8 +5846,6 @@
|
||||
|
||||
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
|
||||
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="],
|
||||
@@ -5797,13 +5862,17 @@
|
||||
|
||||
"@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@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
"@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-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/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/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/langs/@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/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -5829,6 +5898,10 @@
|
||||
|
||||
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||
|
||||
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
||||
|
||||
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
|
||||
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -5857,6 +5930,10 @@
|
||||
|
||||
"@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
"@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
|
||||
|
||||
"@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
|
||||
|
||||
"@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
|
||||
"@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
|
||||
@@ -5899,6 +5976,8 @@
|
||||
|
||||
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
||||
|
||||
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
|
||||
|
||||
"astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
|
||||
@@ -5907,6 +5986,8 @@
|
||||
|
||||
"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/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=="],
|
||||
@@ -5937,9 +6018,7 @@
|
||||
|
||||
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
|
||||
|
||||
"conf/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="],
|
||||
|
||||
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
"conf/dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
|
||||
|
||||
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
@@ -5951,8 +6030,6 @@
|
||||
|
||||
"dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"dmg-builder/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"dmg-license/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
@@ -5961,6 +6038,8 @@
|
||||
|
||||
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
|
||||
|
||||
"duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"editorconfig/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
@@ -5971,16 +6050,24 @@
|
||||
|
||||
"electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="],
|
||||
|
||||
"electron-builder-squirrel-windows/builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
|
||||
|
||||
"electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
||||
|
||||
"electron-store/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="],
|
||||
|
||||
"electron-updater/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"electron-updater/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
|
||||
|
||||
"encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
|
||||
|
||||
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
@@ -6017,6 +6104,8 @@
|
||||
|
||||
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"got/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||
|
||||
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
@@ -6027,6 +6116,8 @@
|
||||
|
||||
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
||||
|
||||
"istanbul-reports/html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||
|
||||
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
|
||||
@@ -6063,6 +6154,8 @@
|
||||
|
||||
"nitro/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
|
||||
|
||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
|
||||
|
||||
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -6073,8 +6166,6 @@
|
||||
|
||||
"nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
|
||||
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
@@ -6083,6 +6174,8 @@
|
||||
|
||||
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
|
||||
|
||||
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
@@ -6099,6 +6192,8 @@
|
||||
|
||||
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
|
||||
|
||||
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
@@ -6137,9 +6232,9 @@
|
||||
|
||||
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"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/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/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
"shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
@@ -6185,12 +6280,16 @@
|
||||
|
||||
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||
|
||||
"unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||
|
||||
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
"vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="],
|
||||
@@ -6277,6 +6376,18 @@
|
||||
|
||||
"@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/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
|
||||
@@ -6347,8 +6458,6 @@
|
||||
|
||||
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
@@ -6575,6 +6684,8 @@
|
||||
|
||||
"@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/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
@@ -6593,10 +6704,16 @@
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
||||
|
||||
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="],
|
||||
|
||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
@@ -6619,6 +6736,8 @@
|
||||
|
||||
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -6637,6 +6756,18 @@
|
||||
|
||||
"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/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=="],
|
||||
@@ -6657,6 +6788,8 @@
|
||||
|
||||
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"conf/dot-prop/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
|
||||
@@ -6667,8 +6800,38 @@
|
||||
|
||||
"dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"duplexer2/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
"electron-builder-squirrel-windows/builder-util/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
|
||||
|
||||
"electron-builder-squirrel-windows/builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"electron-builder-squirrel-windows/builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"electron-builder/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"electron-builder/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
@@ -6699,8 +6862,6 @@
|
||||
|
||||
"js-beautify/nopt/abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
|
||||
|
||||
"lazystream/readable-stream/core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
@@ -6711,6 +6872,8 @@
|
||||
|
||||
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||
|
||||
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
@@ -6741,6 +6904,8 @@
|
||||
|
||||
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
@@ -6995,6 +7160,26 @@
|
||||
|
||||
"editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"electron-builder-squirrel-windows/builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"electron-builder/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"electron-builder/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
@@ -7081,6 +7266,8 @@
|
||||
|
||||
"babel-plugin-module-resolver/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"electron-builder/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@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", "gitlab-ai-provider", "@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"]
|
||||
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"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-2MWaL8yxBR7Lw7XH8TY2V11fqro3Ncn0+q/iEpOiX6g=",
|
||||
"aarch64-linux": "sha256-IoiotWekTroxCFOjQPMteWkY26O+hAZnQtxHY/svJxQ=",
|
||||
"aarch64-darwin": "sha256-hky+dzAQBENArDz7Hr1kfj+bbWjTySiHGK2LrRBKt2k=",
|
||||
"x86_64-darwin": "sha256-tIO18GIjboqv0rWlsbhQ0JNSmzTFswVNUHuE7ZHFxzo="
|
||||
"x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=",
|
||||
"aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=",
|
||||
"aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=",
|
||||
"x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y="
|
||||
}
|
||||
}
|
||||
|
||||
+15
-10
@@ -30,9 +30,9 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -42,6 +42,8 @@
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
@@ -51,15 +53,15 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"opentui-spinner": "0.0.7",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.74",
|
||||
"effect": "4.0.0-beta.83",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -76,10 +78,9 @@
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"sst": "4.13.1",
|
||||
"shiki": "3.20.0",
|
||||
"shiki": "4.2.0",
|
||||
"solid-list": "0.3.0",
|
||||
"tailwindcss": "4.1.11",
|
||||
"virtua": "0.49.1",
|
||||
"vite": "7.1.4",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.4",
|
||||
@@ -140,14 +141,18 @@
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.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",
|
||||
"@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",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.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",
|
||||
"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,3 +1,8 @@
|
||||
## 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
|
||||
|
||||
- NEVER try to restart the app, or the server process, EVER.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
- 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.
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,144 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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 })])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
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" },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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}`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
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 })
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
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" })
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
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,7 +147,8 @@ test.describe("regression: session timeline local row state", () => {
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||
await expectAppVisible(wrapper)
|
||||
await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first())
|
||||
const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first()
|
||||
await expectAppVisible(file)
|
||||
await markDiffProbe(page)
|
||||
|
||||
events.push({
|
||||
@@ -159,7 +160,15 @@ test.describe("regression: session timeline local row state", () => {
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
|
||||
const siblingProbe = await readDiffProbe(page)
|
||||
expect(siblingProbe).toEqual({
|
||||
fileMarker: "before",
|
||||
frameMarker: "before",
|
||||
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
||||
rowMarker: "before",
|
||||
shadowRoots: 0,
|
||||
toolMarker: "before",
|
||||
})
|
||||
|
||||
await markDiffProbe(page)
|
||||
events.push({
|
||||
@@ -173,7 +182,73 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
|
||||
expect(await readDiffProbe(page)).toEqual({
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -247,10 +322,16 @@ async function markDiffProbe(page: Page) {
|
||||
.evaluate((element) => {
|
||||
const tool = element as HTMLElement
|
||||
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 (!row) throw new Error("missing virtual timeline row")
|
||||
if (!frame) throw new Error("missing timeline row frame")
|
||||
|
||||
tool.dataset.timelineProbe = "before"
|
||||
file.dataset.timelineProbe = "before"
|
||||
row.dataset.timelineProbe = "before"
|
||||
frame.dataset.timelineProbe = "before"
|
||||
window.__timelineDiffProbe.reset()
|
||||
})
|
||||
}
|
||||
@@ -262,10 +343,15 @@ async function readDiffProbe(page: Page) {
|
||||
.evaluate((element) => {
|
||||
const tool = element as HTMLElement
|
||||
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 {
|
||||
fileMarker: file?.dataset.timelineProbe,
|
||||
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
|
||||
toolMarker: tool.dataset.timelineProbe,
|
||||
rowMarker: row?.dataset.timelineProbe,
|
||||
rowKey: row?.dataset.timelineKey,
|
||||
frameMarker: frame?.dataset.timelineProbe,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -300,14 +386,15 @@ function readExpanded(element: Element) {
|
||||
return !!content && content.getBoundingClientRect().height > 0
|
||||
}
|
||||
|
||||
async function mockServer(page: Page, events: EventPayload[]) {
|
||||
async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions: [session()],
|
||||
pageMessages: () => ({ items: [userMessage, assistantMessage] }),
|
||||
events: () => events.splice(0),
|
||||
pageMessages: () => ({ items: messages }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,6 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
const samples = await sampleExpansion(page)
|
||||
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(visibleOverlap).toEqual([])
|
||||
expect(samples.at(-1)?.expanded).toBe("true")
|
||||
@@ -115,13 +113,15 @@ async function sampleExpansion(page: Page) {
|
||||
|
||||
let frame = 1
|
||||
const tick = () => {
|
||||
capture(frame, "raf")
|
||||
frame += 1
|
||||
if (frame > 8) {
|
||||
resolve(samples)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
setTimeout(() => {
|
||||
capture(frame, "painted")
|
||||
frame += 1
|
||||
if (frame > 8) {
|
||||
resolve(samples)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}, 0)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}),
|
||||
|
||||
@@ -30,6 +30,284 @@ type SmokeWindow = Window & {
|
||||
test.describe("smoke: session timeline", () => {
|
||||
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 }) => {
|
||||
const errors = trackPageErrors(page)
|
||||
await mockOpenCodeServer(page, {
|
||||
@@ -427,6 +705,14 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
|
||||
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) {
|
||||
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ export interface MockServerConfig {
|
||||
project: unknown
|
||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||
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[]
|
||||
eventRetry?: number
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
@@ -41,10 +44,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
if (url.port !== targetPort) return route.fallback()
|
||||
const appPort = new URL(
|
||||
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
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.())
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
@@ -60,13 +66,17 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||
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 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, {})
|
||||
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
|
||||
return route.fallback()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,10 +93,10 @@ function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route, events?: unknown[]) {
|
||||
function sse(route: Route, events?: unknown[], retry?: number) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n",
|
||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.9",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -17,13 +17,15 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "bun run test:unit",
|
||||
"test": "bun run test:unit && bun run test:virtualizer",
|
||||
"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:e2e": "playwright test",
|
||||
"test:e2e:local": "playwright test",
|
||||
"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",
|
||||
"devDependencies": {
|
||||
@@ -46,6 +48,7 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
@@ -63,6 +66,7 @@
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -75,7 +79,6 @@
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"virtua": "catalog:"
|
||||
"tailwindcss": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const reuse = !process.env.CI
|
||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
|
||||
outputDir: "./e2e/test-results",
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
|
||||
+161
-42
@@ -9,7 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
@@ -43,25 +43,123 @@ import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { TabsProvider } from "@/context/tabs"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
|
||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
const SessionRoute = Object.assign(
|
||||
() => (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
),
|
||||
() => {
|
||||
const settings = useSettings()
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||
// is replaced by a draft at /new-session?draftId=…
|
||||
createEffect(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return
|
||||
if (params.id || search.draftId) return
|
||||
if (!tabs.ready() || !sdk().directory) return
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
)
|
||||
},
|
||||
{ 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() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
|
||||
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedDraftRoute(props: { draftID: string }) {
|
||||
const tabs = useTabs()
|
||||
const draft = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
|
||||
)
|
||||
|
||||
// Key on the directory so retargeting the draft's project re-instantiates the
|
||||
// directory-scoped providers while keeping the same draft id. The draft's target
|
||||
// server is provided by DraftServerLayout, so changing only the server updates the
|
||||
// SDK/sync hooks without remounting the composer.
|
||||
const directory = () => draft()?.directory
|
||||
|
||||
return (
|
||||
<Show when={directory()} keyed>
|
||||
{(dir) => (
|
||||
<SDKProvider directory={dir}>
|
||||
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function UiI18nBridge(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||
@@ -108,27 +206,36 @@ function BodyDesignClass() {
|
||||
return null
|
||||
}
|
||||
|
||||
function AppShellProviders(props: ParentProps) {
|
||||
// Server-agnostic providers shared across every route. These live in the shared
|
||||
// shell (router root) so they stay mounted regardless of the active server/route.
|
||||
function SharedProviders(props: ParentProps) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<BodyDesignClass />
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<ModelsProvider>
|
||||
<CommandProvider>
|
||||
<HighlightsProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
<CommandProvider>
|
||||
<HighlightsProvider>{props.children}</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</SettingsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
|
||||
// each per-route server layout so they resolve to that route's server (selected vs
|
||||
// draft). The Layout remounts when crossing between those groups.
|
||||
function ServerScopedShell(props: ParentProps) {
|
||||
return (
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<ModelsProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
@@ -141,14 +248,15 @@ function SessionProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||
function DraftProviders(props: ParentProps) {
|
||||
return (
|
||||
<AppShellProviders>
|
||||
{/*<Suspense fallback={<Loading />}>*/}
|
||||
{props.appChildren}
|
||||
{props.children}
|
||||
{/*</Suspense>*/}
|
||||
</AppShellProviders>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<CommentsProvider>{props.children}</CommentsProvider>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -310,6 +418,20 @@ export function AppInterface(props: {
|
||||
router?: Component<BaseRouterProps>
|
||||
disableHealthCheck?: boolean
|
||||
}) {
|
||||
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
|
||||
// Command/Highlights) and stays mounted across every route. The server-scoped
|
||||
// providers and the visual Layout live in the per-route layouts below, so they
|
||||
// resolve to that route's server (selected for most routes, the draft's server for
|
||||
// /new-session). appChildren is server-agnostic, so it renders here once.
|
||||
const ServerShell = (shellProps: ParentProps) => (
|
||||
<QueryProvider>
|
||||
<SharedProviders>
|
||||
{props.children}
|
||||
{shellProps.children}
|
||||
</SharedProviders>
|
||||
</QueryProvider>
|
||||
)
|
||||
|
||||
return (
|
||||
<ServerProvider
|
||||
defaultServer={props.defaultServer}
|
||||
@@ -322,22 +444,19 @@ export function AppInterface(props: {
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerKey>
|
||||
<QueryProvider>
|
||||
<ServerSDKProvider>
|
||||
<ServerSyncProvider>
|
||||
<RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</QueryProvider>
|
||||
</ServerKey>
|
||||
<ServerShell>{routerProps.children}</ServerShell>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
<Route component={SelectedServerLayout}>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route component={DraftServerLayout}>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</ConnectionGate>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
})
|
||||
|
||||
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[]>(() => [
|
||||
{
|
||||
@@ -52,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
const [auth] = createResource(
|
||||
() => props.provider,
|
||||
async () => {
|
||||
const cached = serverSync.data.provider_auth[props.provider]
|
||||
const cached = serverSync().data.provider_auth[props.provider]
|
||||
if (cached) return cached
|
||||
const res = await serverSDK.client.provider.auth()
|
||||
const res = await serverSDK().client.provider.auth()
|
||||
if (!alive.value) return fallback()
|
||||
serverSync.set("provider_auth", res.data ?? {})
|
||||
serverSync().set("provider_auth", res.data ?? {})
|
||||
return res.data?.[props.provider] ?? fallback()
|
||||
},
|
||||
)
|
||||
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
|
||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
@@ -158,8 +158,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
const start = Date.now()
|
||||
await serverSDK.client.provider.oauth
|
||||
.authorize(
|
||||
await serverSDK()
|
||||
.client.provider.oauth.authorize(
|
||||
{
|
||||
providerID: props.provider,
|
||||
method: index,
|
||||
@@ -331,7 +331,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
await serverSDK.client.global.dispose()
|
||||
await serverSDK().client.global.dispose()
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -409,7 +409,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await serverSDK.client.auth.set({
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: props.provider,
|
||||
auth: {
|
||||
type: "api",
|
||||
@@ -480,8 +480,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const result = await serverSDK.client.provider.oauth
|
||||
.callback({
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
code,
|
||||
@@ -533,8 +533,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const result = await serverSDK.client.provider.oauth
|
||||
.callback({
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
})
|
||||
|
||||
@@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
|
||||
const output = validateCustomProvider({
|
||||
form,
|
||||
t: language.t,
|
||||
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
||||
disabledProviders: serverSync().data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
|
||||
})
|
||||
batch(() => {
|
||||
setForm("err", output.err)
|
||||
@@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
|
||||
|
||||
const saveMutation = useMutation(() => ({
|
||||
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)
|
||||
|
||||
if (result.key) {
|
||||
await serverSDK.client.auth.set({
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: result.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
})
|
||||
}
|
||||
|
||||
await serverSync.updateConfig({
|
||||
await serverSync().updateConfig({
|
||||
provider: { [result.providerID]: result.config },
|
||||
disabled_providers: nextDisabled,
|
||||
})
|
||||
|
||||
@@ -35,13 +35,13 @@ export const DialogFork: Component = () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return []
|
||||
|
||||
const msgs = sync.data.message[sessionID] ?? []
|
||||
const msgs = sync().data.message[sessionID] ?? []
|
||||
const result: ForkableMessage[] = []
|
||||
|
||||
for (const message of msgs) {
|
||||
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)
|
||||
if (!textPart) continue
|
||||
|
||||
@@ -61,15 +61,15 @@ export const DialogFork: Component = () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const parts = sync.data.part[item.id] ?? []
|
||||
const parts = sync().data.part[item.id] ?? []
|
||||
const restored = extractPromptFromParts(parts, {
|
||||
directory: sdk.directory,
|
||||
directory: sdk().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
const dir = base64Encode(sdk.directory)
|
||||
const dir = base64Encode(sdk().directory)
|
||||
|
||||
sdk.client.session
|
||||
.fork({ sessionID, messageID: item.id })
|
||||
sdk()
|
||||
.client.session.fork({ sessionID, messageID: item.id })
|
||||
.then((forked) => {
|
||||
if (!forked.data) {
|
||||
showToast({ title: language.t("common.requestFailed") })
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
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,12 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import type { ListRef } from "@opencode-ai/ui/list"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import { ServerSDK } from "@/context/server-sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||
|
||||
interface DialogSelectDirectoryProps {
|
||||
title?: string
|
||||
@@ -24,89 +23,9 @@ type Row = {
|
||||
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 {
|
||||
const full = trimTrailing(absolute)
|
||||
const tilde = tildeOf(full, home)
|
||||
const full = displayPickerPath(absolute, "", "")
|
||||
const tilde = displayPickerPath(full, "~", home)
|
||||
const withSlash = (value: string) => {
|
||||
if (!value) return ""
|
||||
if (value.endsWith("/")) return value
|
||||
@@ -128,120 +47,6 @@ 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) {
|
||||
const global = useGlobal()
|
||||
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
|
||||
@@ -268,10 +73,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||
)
|
||||
|
||||
const directories = useDirectorySearch({
|
||||
const directories = createDirectorySearch({
|
||||
sdk,
|
||||
home,
|
||||
start,
|
||||
base: start,
|
||||
})
|
||||
|
||||
const recentProjects = createMemo(() => {
|
||||
@@ -336,7 +141,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
|
||||
}
|
||||
ref={(r) => (list = r)}
|
||||
onFilter={(value) => setFilter(cleanInput(value))}
|
||||
onFilter={(value) => setFilter(cleanPickerInput(value))}
|
||||
onKeyEvent={(e, item) => {
|
||||
if (e.key !== "Tab") return
|
||||
if (e.shiftKey) return
|
||||
@@ -345,7 +150,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const value = displayPath(item.absolute, filter(), home())
|
||||
const value = displayPickerPath(item.absolute, filter(), home())
|
||||
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
||||
}}
|
||||
onSelect={(path) => {
|
||||
@@ -354,7 +159,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
}}
|
||||
>
|
||||
{(item) => {
|
||||
const path = displayPath(item.absolute, filter(), home())
|
||||
const path = displayPickerPath(item.absolute, filter(), home())
|
||||
if (path === "~") {
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between rounded-md">
|
||||
|
||||
@@ -7,18 +7,25 @@ import { List } from "@opencode-ai/ui/list"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useFile } from "@/context/file"
|
||||
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 { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
|
||||
const DialogSelectFileV2 = lazy(() =>
|
||||
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
||||
)
|
||||
|
||||
type EntryType = "command" | "file" | "session"
|
||||
|
||||
type Entry = {
|
||||
@@ -175,7 +182,7 @@ function createFileEntries(props: {
|
||||
function createSessionEntries(props: {
|
||||
workspaces: () => string[]
|
||||
label: (directory: string) => string
|
||||
serverSDK: ReturnType<typeof useServerSDK>
|
||||
serverSDK: ServerSDK
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const state: {
|
||||
@@ -261,13 +268,12 @@ function createSessionEntries(props: {
|
||||
return { sessions }
|
||||
}
|
||||
|
||||
export function DialogSelectFile(props: {
|
||||
mode?: DialogSelectFileMode
|
||||
onOpenFile?: (path: string) => void
|
||||
onSelectFile?: (path: string) => void
|
||||
}) {
|
||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const server = useServer()
|
||||
const settings = useSettings()
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
@@ -296,21 +302,21 @@ export function DialogSelectFile(props: {
|
||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||
return dirs
|
||||
})
|
||||
const homedir = createMemo(() => serverSync.data.path.home)
|
||||
const homedir = createMemo(() => serverSync().data.path.home)
|
||||
const label = (directory: string) => {
|
||||
const current = project()
|
||||
const kind =
|
||||
current && directory === current.worktree
|
||||
? language.t("workspace.type.local")
|
||||
: language.t("workspace.type.sandbox")
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
const home = homedir()
|
||||
const path = home ? directory.replace(home, "~") : directory
|
||||
const name = store.vcs?.branch ?? getFilename(directory)
|
||||
return `${kind} : ${name || path}`
|
||||
}
|
||||
|
||||
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
|
||||
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
|
||||
|
||||
const items = async (text: string) => {
|
||||
const query = text.trim()
|
||||
@@ -379,10 +385,6 @@ export function DialogSelectFile(props: {
|
||||
}
|
||||
|
||||
if (!item.path) return
|
||||
if (props.onSelectFile) {
|
||||
props.onSelectFile(item.path)
|
||||
return
|
||||
}
|
||||
open(item.path)
|
||||
}
|
||||
|
||||
@@ -391,6 +393,21 @@ export function DialogSelectFile(props: {
|
||||
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 (
|
||||
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
|
||||
<List
|
||||
|
||||
@@ -19,7 +19,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
const language = useLanguage()
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
Object.entries(sync().data.mcp ?? {})
|
||||
.map(([name, status]) => ({ name, status: status.status }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
@@ -48,7 +48,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
}}
|
||||
>
|
||||
{(i) => {
|
||||
const mcpStatus = () => sync.data.mcp[i.name]
|
||||
const mcpStatus = () => sync().data.mcp[i.name]
|
||||
const status = () => mcpStatus()?.status
|
||||
const statusLabel = () => {
|
||||
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,331 @@
|
||||
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,9 +1,15 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { lazy } from "solid-js"
|
||||
import { DialogSelectDirectory } from "./dialog-select-directory"
|
||||
import { directoryPickerKind } from "./directory-picker-policy"
|
||||
|
||||
const DialogSelectDirectoryV2 = lazy(() =>
|
||||
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
||||
)
|
||||
|
||||
type DirectoryPickerInput = {
|
||||
server: ServerConnection.Any
|
||||
title?: string
|
||||
@@ -13,6 +19,7 @@ type DirectoryPickerInput = {
|
||||
|
||||
export function useDirectoryPicker() {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const dialog = useDialog()
|
||||
|
||||
return (input: DirectoryPickerInput) => {
|
||||
@@ -21,9 +28,18 @@ export function useDirectoryPicker() {
|
||||
return
|
||||
}
|
||||
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory {...input} />,
|
||||
() => input.onSelect(null),
|
||||
)
|
||||
let selected = false
|
||||
const onSelect = (result: string | string[] | null) => {
|
||||
selected = result !== 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
mock.module("@/context/file", () => ({
|
||||
useFile: () => ({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
// @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,
|
||||
} from "solid-js"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
import {
|
||||
ContentPart,
|
||||
@@ -31,9 +31,7 @@ import {
|
||||
FileAttachmentPart,
|
||||
} from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -45,15 +43,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { serverAttachmentFile } from "./prompt-input/server-attachment"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||
@@ -73,18 +67,104 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { useDirectoryPicker } from "./directory-picker"
|
||||
import { showToast } from "@/utils/toast"
|
||||
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 { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
|
||||
interface PromptInputProps {
|
||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
|
||||
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
|
||||
variant?: "dock" | "new-session"
|
||||
state?: PromptInputState
|
||||
history?: PromptInputHistory
|
||||
submission?: PromptInputSubmission
|
||||
controls: PromptInputControls
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
newSessionWorktree?: string
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
@@ -126,25 +206,18 @@ const EXAMPLES = [
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const navigate = useNavigate()
|
||||
const queryOptions = useQueryOptions()
|
||||
|
||||
const sync = useSync()
|
||||
const local = useLocal()
|
||||
const files = useFile()
|
||||
const prompt = usePrompt()
|
||||
const prompt = props.state ?? usePrompt()
|
||||
const layout = useLayout()
|
||||
const server = useServer()
|
||||
const comments = useComments()
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
const command = useCommand()
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const settings = useSettings()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const tabs = () => props.controls.session.tabs
|
||||
let editorRef!: HTMLDivElement
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
let scrollRef!: HTMLDivElement
|
||||
@@ -202,10 +275,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}).activeFileTab
|
||||
|
||||
const commentInReview = (path: string) => {
|
||||
const sessionID = params.id
|
||||
const sessionID = props.controls.session.id
|
||||
if (!sessionID) return false
|
||||
|
||||
const diffs = sync.data.session_diff[sessionID]
|
||||
const diffs = sync().data.session_diff[sessionID]
|
||||
if (!diffs) return false
|
||||
return diffs.some((diff) => diff.file === path)
|
||||
}
|
||||
@@ -235,14 +308,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
||||
if (wantsReview) {
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||
layout.fileTree.setTab("changes")
|
||||
tabs().setActive("review")
|
||||
queueCommentFocus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||
layout.fileTree.setTab("all")
|
||||
const tab = files.tab(item.path)
|
||||
void tabs().open(tab)
|
||||
@@ -267,8 +340,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
return paths
|
||||
})
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const working = createMemo(() => sync.data.session_working(params.id ?? ""))
|
||||
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
||||
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
||||
const imageAttachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
@@ -345,29 +418,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
|
||||
const hasUserPrompt = createMemo(() => {
|
||||
const sessionID = params.id
|
||||
const sessionID = props.controls.session.id
|
||||
if (!sessionID) return false
|
||||
const messages = sync.data.message[sessionID]
|
||||
const messages = sync().data.message[sessionID]
|
||||
if (!messages) return false
|
||||
return messages.some((m) => m.role === "user")
|
||||
})
|
||||
|
||||
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 history = props.history ?? createPersistedPromptInputHistory()
|
||||
|
||||
const suggest = createMemo(() => !hasUserPrompt())
|
||||
|
||||
@@ -470,34 +528,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
||||
|
||||
const pick = () => {
|
||||
if (server.isLocal()) {
|
||||
pickAttachmentFiles({
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk.directory,
|
||||
fallback: () => fileInputRef?.click(),
|
||||
onFile: addAttachment,
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
void import("@/components/dialog-select-file").then((module) =>
|
||||
dialog.show(() => (
|
||||
<module.DialogSelectFile
|
||||
mode="files"
|
||||
onSelectFile={(path) => {
|
||||
void sdk.client.v2.fs
|
||||
.read({ path })
|
||||
.then((response) => response.data?.data)
|
||||
.then((data) => data && addAttachments([serverAttachmentFile(path, data)]))
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
)
|
||||
pickAttachmentFiles({
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk().directory,
|
||||
fallback: () => fileInputRef?.click(),
|
||||
onFile: addAttachment,
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const setMode = (mode: "normal" | "shell") => {
|
||||
@@ -587,8 +629,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
params.id
|
||||
if (params.id) return
|
||||
props.controls.session.id
|
||||
if (props.controls.session.id) return
|
||||
if (!suggest()) return
|
||||
const interval = setInterval(() => {
|
||||
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
|
||||
@@ -617,11 +659,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const agentList = createMemo(() =>
|
||||
sync.data.agent
|
||||
props.controls.agents.available
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
.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) => {
|
||||
if (!option) return
|
||||
@@ -687,7 +728,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type: "builtin" as const,
|
||||
}))
|
||||
|
||||
const custom = sync.data.command.map((cmd) => ({
|
||||
const custom = sync().data.command.map((cmd) => ({
|
||||
id: `custom.${cmd.name}`,
|
||||
trigger: cmd.name,
|
||||
title: cmd.name,
|
||||
@@ -1047,11 +1088,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||
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)
|
||||
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
|
||||
}
|
||||
|
||||
createEffect(
|
||||
@@ -1096,7 +1133,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const navigateHistory = (direction: "up" | "down") => {
|
||||
const result = navigatePromptHistory({
|
||||
direction,
|
||||
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
|
||||
entries: history.entries(store.mode),
|
||||
historyIndex: store.historyIndex,
|
||||
currentPrompt: prompt.current(),
|
||||
currentComments: historyComments(),
|
||||
@@ -1110,6 +1147,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
prompt,
|
||||
editor: () => editorRef,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
@@ -1119,6 +1157,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
addPart,
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
getPathForFile: platform.getPathForFile,
|
||||
})
|
||||
|
||||
const fileAttachmentInput = () => (
|
||||
@@ -1136,38 +1175,41 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
/>
|
||||
)
|
||||
|
||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
||||
// Check provider variants directly: `variants` also includes the UI-only default option.
|
||||
const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
|
||||
const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0)
|
||||
const accepting = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
return permission.isAutoAccepting(id, sdk.directory)
|
||||
const id = props.controls.session.id
|
||||
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
||||
return permission.isAutoAccepting(id, sdk().directory)
|
||||
})
|
||||
|
||||
const { abort, handleSubmit } = createPromptSubmit({
|
||||
info,
|
||||
imageAttachments,
|
||||
commentCount,
|
||||
autoAccept: () => accepting(),
|
||||
mode: () => store.mode,
|
||||
working,
|
||||
editor: () => editorRef,
|
||||
queueScroll,
|
||||
promptLength,
|
||||
addToHistory,
|
||||
resetHistoryNavigation: () => {
|
||||
resetHistoryNavigation(true)
|
||||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
})
|
||||
const { abort, handleSubmit } =
|
||||
props.submission ??
|
||||
createPromptSubmit({
|
||||
prompt,
|
||||
info,
|
||||
imageAttachments,
|
||||
commentCount,
|
||||
autoAccept: () => accepting(),
|
||||
mode: () => store.mode,
|
||||
working,
|
||||
editor: () => editorRef,
|
||||
queueScroll,
|
||||
promptLength,
|
||||
addToHistory,
|
||||
resetHistoryNavigation: () => {
|
||||
resetHistoryNavigation(true)
|
||||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
})
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
||||
@@ -1331,17 +1373,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
|
||||
queries: [
|
||||
queryOptions.agents(pathKey(sdk.directory)),
|
||||
queryOptions.providers(null),
|
||||
queryOptions.providers(pathKey(sdk.directory)),
|
||||
],
|
||||
}))
|
||||
|
||||
const agentsLoading = () => agentsQuery.isLoading
|
||||
const agentsLoading = () => props.controls.agents.loading
|
||||
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
||||
const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
|
||||
const providersLoading = () => props.controls.model.loading
|
||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||
|
||||
const [promptReady] = createResource(
|
||||
@@ -1356,23 +1390,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||
loading: providersLoading(),
|
||||
paid: providers.paid().length > 0,
|
||||
paid: props.controls.model.paid,
|
||||
title: language.t("command.model.choose"),
|
||||
keybind: command.keybind("model.choose"),
|
||||
model: local.model,
|
||||
providerID: local.model.current()?.provider?.id,
|
||||
modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
model: props.controls.model.selection,
|
||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const newSession = () => props.variant === "new-session"
|
||||
const projects = createMemo(() => layout.projects.list())
|
||||
const projects = createMemo(() => props.controls.projects.available)
|
||||
const projectForDirectory = (directory: string | undefined) => {
|
||||
if (!directory) return
|
||||
const key = pathKey(directory)
|
||||
@@ -1380,13 +1414,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
)
|
||||
}
|
||||
const selectedProject = createMemo(() => projectForDirectory(sdk.directory))
|
||||
const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory))
|
||||
const projectResults = createMemo(() => {
|
||||
const search = picker.projectSearch.trim().toLowerCase()
|
||||
if (!search) return projects()
|
||||
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
|
||||
})
|
||||
const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0)
|
||||
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
||||
const selectProject = (worktree: string) => {
|
||||
setPicker({
|
||||
projectOpen: false,
|
||||
@@ -1396,23 +1430,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
restoreFocus()
|
||||
return
|
||||
}
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
props.controls.projects.select(worktree)
|
||||
restoreFocus()
|
||||
}
|
||||
const addProject = () => {
|
||||
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,
|
||||
})
|
||||
props.controls.projects.add(language.t("command.project.open"))
|
||||
}
|
||||
|
||||
const projectPickerState = createMemo<ComposerPickerState>(() => ({
|
||||
@@ -1453,11 +1475,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||
title: language.t("command.agent.cycle"),
|
||||
keybind: command.keybind("agent.cycle"),
|
||||
options: agentNames(),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
options: props.controls.agents.options,
|
||||
current: props.controls.agents.current,
|
||||
style: control(),
|
||||
onSelect: (value) => {
|
||||
local.agent.set(value)
|
||||
props.controls.agents.select(value)
|
||||
restoreFocus()
|
||||
},
|
||||
}))
|
||||
@@ -1489,7 +1511,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={settings.general.newLayoutDesigns()}>
|
||||
<Match when={props.controls.newLayoutDesigns}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<DockShellForm
|
||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||
@@ -1609,7 +1631,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
data-component="prompt-variant-control"
|
||||
classList={{
|
||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||
!local.model.variant.current() && !store.variantOpen,
|
||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
@@ -1621,11 +1643,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onOpenChange={(open) => setStore("variantOpen", open)}
|
||||
onSelect={(value) => {
|
||||
local.model.variant.set(value === "default" ? undefined : value)
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
||||
@@ -1865,10 +1887,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
options={props.controls.agents.options}
|
||||
current={props.controls.agents.current}
|
||||
onSelect={(value) => {
|
||||
local.agent.set(value)
|
||||
props.controls.agents.select(value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
@@ -1887,7 +1909,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
>
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
when={props.controls.model.paid}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1904,19 +1926,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||
dialog.show(() => (
|
||||
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
|
||||
))
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
{props.controls.model.selection.current()?.name ??
|
||||
language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
@@ -1930,7 +1955,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={local.model}
|
||||
model={props.controls.model.selection}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
@@ -1941,15 +1966,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}}
|
||||
onClose={restoreFocus}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
{props.controls.model.selection.current()?.name ??
|
||||
language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
@@ -1970,10 +1996,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(value) => {
|
||||
local.model.variant.set(value === "default" ? undefined : value)
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { onMount } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { getCursorPosition } from "./editor-dom"
|
||||
@@ -26,16 +26,18 @@ function dataUrl(file: File, mime: string) {
|
||||
}
|
||||
|
||||
type PromptAttachmentsInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
isDialogActive: () => boolean
|
||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||
focusEditor: () => void
|
||||
addPart: (part: ContentPart) => boolean
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
}
|
||||
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const prompt = usePrompt()
|
||||
const prompt = input.prompt
|
||||
const language = useLanguage()
|
||||
|
||||
const warn = () => {
|
||||
@@ -62,6 +64,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
type: "image",
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
sourcePath: input.getPathForFile?.(file) || undefined,
|
||||
mime,
|
||||
dataUrl: url,
|
||||
}
|
||||
|
||||
@@ -75,6 +75,31 @@ describe("buildRequestParts", () => {
|
||||
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", () => {
|
||||
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",
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
filename: attachment.sourcePath ?? attachment.filename,
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<div class="flex items-center text-[12px] min-w-0 font-medium leading-5">
|
||||
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
||||
<Show when={item.selection}>
|
||||
{(sel) => (
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { serverAttachmentFile } from "./server-attachment"
|
||||
|
||||
describe("serverAttachmentFile", () => {
|
||||
test("creates a file from server text content", async () => {
|
||||
const file = serverAttachmentFile("docs/readme.txt", {
|
||||
uri: "file:///docs/readme.txt",
|
||||
name: "readme.txt",
|
||||
content: "hello",
|
||||
encoding: "utf8",
|
||||
mime: "text/plain",
|
||||
})
|
||||
|
||||
expect(file.name).toBe("readme.txt")
|
||||
expect(file.type).toBe("text/plain")
|
||||
expect(await file.text()).toBe("hello")
|
||||
})
|
||||
|
||||
test("creates a file from server base64 content", async () => {
|
||||
const file = serverAttachmentFile("images/pixel.png", {
|
||||
uri: "file:///images/pixel.png",
|
||||
name: "pixel.png",
|
||||
content: "aGVsbG8=",
|
||||
encoding: "base64",
|
||||
mime: "image/png",
|
||||
})
|
||||
|
||||
expect(file.name).toBe("pixel.png")
|
||||
expect(file.type).toBe("image/png")
|
||||
expect(await file.text()).toBe("hello")
|
||||
})
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { FileSystemContent } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export function serverAttachmentFile(path: string, data: FileSystemContent) {
|
||||
const content =
|
||||
data.encoding === "utf8" ? data.content : Uint8Array.from(atob(data.content), (char) => char.charCodeAt(0))
|
||||
return new File([content], getFilename(path), { type: data.mime })
|
||||
}
|
||||
@@ -26,6 +26,22 @@ let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const prompt = {
|
||||
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||
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) => {
|
||||
createdClients.push(directory)
|
||||
@@ -61,6 +77,8 @@ beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => params,
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
@@ -71,6 +89,7 @@ beforeAll(async () => {
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
Toast: { Region: () => null },
|
||||
showToast: () => 0,
|
||||
}))
|
||||
|
||||
@@ -103,19 +122,20 @@ beforeAll(async () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/prompt", () => ({
|
||||
usePrompt: () => ({
|
||||
current: () => promptValue,
|
||||
reset: () => undefined,
|
||||
set: () => undefined,
|
||||
context: {
|
||||
add: () => undefined,
|
||||
remove: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
mock.module("@/context/server", () => ({
|
||||
useServer: () => ({ key: "server-key" }),
|
||||
}))
|
||||
|
||||
mock.module("@/context/tabs", () => ({
|
||||
useTabs: () => ({
|
||||
promoteDraft: () => undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/prompt", () => ({
|
||||
usePrompt: () => prompt,
|
||||
}))
|
||||
|
||||
mock.module("@/context/layout", () => ({
|
||||
useLayout: () => ({
|
||||
handoff: {
|
||||
@@ -135,12 +155,12 @@ beforeAll(async () => {
|
||||
return clientFor(opts.directory)
|
||||
},
|
||||
}
|
||||
return sdk
|
||||
return () => sdk
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => ({
|
||||
useSync: () => () => ({
|
||||
data: { command: [] },
|
||||
session: {
|
||||
optimistic: {
|
||||
@@ -164,7 +184,7 @@ beforeAll(async () => {
|
||||
}))
|
||||
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => ({
|
||||
useServerSync: () => () => ({
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
storedSessions[directory] ??= []
|
||||
@@ -220,6 +240,7 @@ beforeEach(() => {
|
||||
describe("prompt submit worktree selection", () => {
|
||||
test("reads the latest worktree accessor value per submit", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
@@ -257,6 +278,7 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
test("applies auto-accept to newly created sessions", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
@@ -287,6 +309,7 @@ describe("prompt submit worktree selection", () => {
|
||||
variant = "high"
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
@@ -318,6 +341,7 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
|
||||
@@ -2,17 +2,19 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
@@ -38,9 +40,9 @@ export type FollowupDraft = {
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
client: ReturnType<typeof useSDK>["client"]
|
||||
serverSync: ReturnType<typeof useServerSync>
|
||||
sync: ReturnType<typeof useSync>
|
||||
client: DirectorySDK["client"]
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
draft: FollowupDraft
|
||||
messageID?: string
|
||||
optimisticBusy?: boolean
|
||||
@@ -172,6 +174,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
}
|
||||
|
||||
type PromptSubmitInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
info: Accessor<{ id: string } | undefined>
|
||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||
commentCount: Accessor<number>
|
||||
@@ -209,11 +212,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const serverSync = useServerSync()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = usePrompt()
|
||||
const prompt = input.prompt
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
@@ -228,8 +234,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
serverSync.todo.set(sessionID, [])
|
||||
const [, setStore] = serverSync.child(sdk.directory)
|
||||
serverSync().todo.set(sessionID, [])
|
||||
const [, setStore] = serverSync().child(sdk().directory)
|
||||
setStore("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
@@ -242,8 +248,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
pending.delete(key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk.client.session
|
||||
.abort({
|
||||
return sdk()
|
||||
.client.session.abort({
|
||||
sessionID,
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -276,7 +282,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const seed = (dir: string, info: Session) => {
|
||||
const [, setStore] = serverSync.child(dir)
|
||||
const [, setStore] = serverSync().child(dir)
|
||||
setStore("session", (list: Session[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
@@ -316,13 +322,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk.directory
|
||||
const projectDirectory = sdk().directory
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
let client = sdk.client
|
||||
let client = sdk().client
|
||||
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
@@ -344,7 +350,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
})
|
||||
return
|
||||
}
|
||||
WorktreeState.pending(sdk.scope, createdWorktree.directory)
|
||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
@@ -353,11 +359,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = sdk.createClient({
|
||||
client = sdk().createClient({
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
serverSync.child(sessionDirectory)
|
||||
serverSync().child(sessionDirectory)
|
||||
}
|
||||
|
||||
input.onNewSessionWorktreeReset?.()
|
||||
@@ -381,7 +387,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
const draftID = search.draftId
|
||||
if (draftID)
|
||||
tabs.promoteDraft(draftID, {
|
||||
server: server.key,
|
||||
dirBase64: base64Encode(sessionDirectory),
|
||||
sessionId: session.id,
|
||||
})
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
@@ -458,7 +471,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
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) {
|
||||
clearInput()
|
||||
client.session
|
||||
@@ -492,7 +505,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const messageID = Identifier.ascending("message")
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
sync.session.optimistic.remove({
|
||||
sync().session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
@@ -503,17 +516,17 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
clearInput()
|
||||
|
||||
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 (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "busy" })
|
||||
sync().set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const cleanup = () => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
restoreCommentItems(commentItems)
|
||||
@@ -547,12 +560,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(
|
||||
() => {
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
},
|
||||
)
|
||||
const result = await Promise.race([
|
||||
WorktreeState.wait(sdk().scope, sessionDirectory),
|
||||
abortWait,
|
||||
timeout,
|
||||
]).finally(() => {
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") throw new Error(result.message)
|
||||
@@ -561,8 +576,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
void sendFollowupDraft({
|
||||
client,
|
||||
sync,
|
||||
serverSync,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
@@ -570,7 +585,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
pathFromTab: file.pathFromTab,
|
||||
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(
|
||||
() =>
|
||||
|
||||
@@ -96,13 +96,13 @@ export function SessionContextTab() {
|
||||
const providers = useProviders()
|
||||
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 id = params.id
|
||||
if (!id) return emptyMessages
|
||||
return (sync.data.message[id] ?? []) as Message[]
|
||||
return (sync().data.message[id] ?? []) as Message[]
|
||||
},
|
||||
emptyMessages,
|
||||
{ equals: same },
|
||||
@@ -180,7 +180,7 @@ export function SessionContextTab() {
|
||||
if (!c?.input) return []
|
||||
return estimateSessionContextBreakdown({
|
||||
messages: messages(),
|
||||
parts: sync.data.part as Record<string, Part[] | undefined>,
|
||||
parts: sync().data.part as Record<string, Part[] | undefined>,
|
||||
input: c.input,
|
||||
systemPrompt: systemPrompt(),
|
||||
})
|
||||
@@ -221,7 +221,7 @@ export function SessionContextTab() {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let frame: 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 el = scroll
|
||||
|
||||
@@ -155,11 +155,9 @@ export function SessionHeader() {
|
||||
})
|
||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
|
||||
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
|
||||
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 isV2 = settings.general.newLayoutDesigns
|
||||
const search = settings.visibility.search
|
||||
const status = settings.visibility.status
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
@@ -231,7 +229,7 @@ export function SessionHeader() {
|
||||
)
|
||||
const opening = createMemo(() => openRequest.app !== undefined)
|
||||
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>(() => ({
|
||||
statusVisible: status(),
|
||||
@@ -322,7 +320,7 @@ export function SessionHeader() {
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Show
|
||||
when={isDesktopV2}
|
||||
when={isV2}
|
||||
fallback={
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={projectDirectory()}>
|
||||
@@ -444,23 +442,21 @@ export function SessionHeader() {
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={term()}>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
<TooltipKeybind
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||
<TooltipKeybind
|
||||
@@ -479,32 +475,30 @@ export function SessionHeader() {
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<Show when={tree()}>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.fileTree.toggle")}
|
||||
keybind={command.keybind("fileTree.toggle")}
|
||||
<TooltipKeybind
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<Icon
|
||||
size="small"
|
||||
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 class="relative flex items-center justify-center size-4">
|
||||
<Icon
|
||||
size="small"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,24 +20,24 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
|
||||
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
|
||||
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
||||
const current = createMemo(() => {
|
||||
const selection = props.worktree
|
||||
if (options().includes(selection)) return selection
|
||||
return MAIN_WORKTREE
|
||||
})
|
||||
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const isWorktree = createMemo(() => {
|
||||
const project = sync.project
|
||||
const project = sync().project
|
||||
if (!project) return false
|
||||
return sdk.directory !== project.worktree
|
||||
return sdk().directory !== project.worktree
|
||||
})
|
||||
|
||||
const label = (value: string) => {
|
||||
if (value === MAIN_WORKTREE) {
|
||||
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 })
|
||||
return language.t("session.new.worktree.main")
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
{label(current())}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sync.project}>
|
||||
<Show when={sync().project}>
|
||||
{(project) => (
|
||||
<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">
|
||||
|
||||
@@ -127,8 +127,8 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
serverSdk.client.pty
|
||||
.shells()
|
||||
serverSdk()
|
||||
.client.pty.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] 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 currentShell = createMemo(() => serverSync.data.config.shell ?? "")
|
||||
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
||||
|
||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||
const list = shells.latest
|
||||
const current = serverSync.data.config.shell
|
||||
const current = serverSync().data.config.shell
|
||||
|
||||
const nameCounts = new Map<string, number>()
|
||||
for (const s of list) {
|
||||
@@ -290,7 +290,7 @@ export const SettingsGeneral: Component = () => {
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
serverSync.updateConfig({ shell: option.value })
|
||||
serverSync().updateConfig({ shell: option.value })
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
|
||||
@@ -83,7 +83,7 @@ const SettingsProvidersContent: Component = () => {
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const isConfigCustom = (providerID: string) => {
|
||||
const provider = serverSync.data.config.provider?.[providerID]
|
||||
const provider = serverSync().data.config.provider?.[providerID]
|
||||
if (!provider) return false
|
||||
if (provider.npm !== "@ai-sdk/openai-compatible") 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 before = serverSync.data.config.disabled_providers ?? []
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
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 })
|
||||
.then(() => {
|
||||
showToast({
|
||||
@@ -106,7 +106,7 @@ const SettingsProvidersContent: Component = () => {
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
serverSync.set("config", "disabled_providers", before)
|
||||
serverSync().set("config", "disabled_providers", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
@@ -114,14 +114,16 @@ const SettingsProvidersContent: Component = () => {
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSDK.client.auth.remove({ providerID }).catch(() => undefined)
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
await serverSDK.client.auth
|
||||
.remove({ providerID })
|
||||
await serverSDK()
|
||||
.client.auth.remove({ providerID })
|
||||
.then(async () => {
|
||||
await serverSDK.client.global.dispose()
|
||||
await serverSDK().client.global.dispose()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
|
||||
@@ -30,7 +30,7 @@ function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnecti
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={serverCtx().queryClient}>
|
||||
<ServerSDKProvider server={props.server}>
|
||||
<ServerSDKProvider server={() => props.server}>
|
||||
<ServerSyncProvider>
|
||||
<ModelsProvider>{props.children}</ModelsProvider>
|
||||
</ServerSyncProvider>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||
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 { Switch } from "@opencode-ai/ui/v2/switch-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 { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
@@ -94,7 +92,6 @@ export const SettingsGeneralV2: Component = () => {
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const dir = createMemo(() => decode64(params.dir))
|
||||
const accepting = createMemo(() => {
|
||||
const value = dir()
|
||||
@@ -129,19 +126,13 @@ export const SettingsGeneralV2: Component = () => {
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
serverSdk.client.pty
|
||||
.shells()
|
||||
serverSdk()
|
||||
.client.pty.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] 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(
|
||||
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||
@@ -153,11 +144,11 @@ export const SettingsGeneralV2: Component = () => {
|
||||
})
|
||||
|
||||
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 list = shells.latest
|
||||
const current = serverSync.data.config.shell
|
||||
const current = serverSync().data.config.shell
|
||||
|
||||
const nameCounts = new Map<string, number>()
|
||||
for (const s of list) {
|
||||
@@ -186,14 +177,6 @@ export const SettingsGeneralV2: Component = () => {
|
||||
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) => {
|
||||
setPinchZoom(checked)
|
||||
const update = platform.setPinchZoomEnabled?.(checked)
|
||||
@@ -292,7 +275,7 @@ export const SettingsGeneralV2: Component = () => {
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
serverSync.updateConfig({ shell: option.value })
|
||||
serverSync().updateConfig({ shell: option.value })
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
@@ -383,18 +366,6 @@ export const SettingsGeneralV2: Component = () => {
|
||||
</div>
|
||||
</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
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
@@ -407,18 +378,6 @@ export const SettingsGeneralV2: Component = () => {
|
||||
</div>
|
||||
</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
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
@@ -709,6 +668,7 @@ export const SettingsGeneralV2: Component = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
// We can probably remove this, right?
|
||||
const DisplaySection = () => (
|
||||
<Show when={desktop()}>
|
||||
<div class="settings-v2-section">
|
||||
@@ -723,26 +683,6 @@ export const SettingsGeneralV2: Component = () => {
|
||||
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -763,13 +703,13 @@ export const SettingsGeneralV2: Component = () => {
|
||||
|
||||
<SoundsSection />
|
||||
|
||||
<UpdatesSection />
|
||||
<Show when={desktop()}>
|
||||
<UpdatesSection />
|
||||
</Show>
|
||||
|
||||
<DisplaySection />
|
||||
|
||||
<Show when={desktop()}>
|
||||
<AdvancedSection />
|
||||
</Show>
|
||||
<AdvancedSection />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component = () => {
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const isConfigCustom = (providerID: string) => {
|
||||
const provider = serverSync.data.config.provider?.[providerID]
|
||||
const provider = serverSync().data.config.provider?.[providerID]
|
||||
if (!provider) return false
|
||||
if (provider.npm !== "@ai-sdk/openai-compatible") 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 before = serverSync.data.config.disabled_providers ?? []
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
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 })
|
||||
.then(() => {
|
||||
showToast({
|
||||
@@ -100,7 +100,7 @@ export const SettingsProvidersV2: Component = () => {
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
serverSync.set("config", "disabled_providers", before)
|
||||
serverSync().set("config", "disabled_providers", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
@@ -108,14 +108,16 @@ export const SettingsProvidersV2: Component = () => {
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await serverSdk.client.auth.remove({ providerID }).catch(() => undefined)
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
.catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
await serverSdk.client.auth
|
||||
.remove({ providerID })
|
||||
await serverSdk()
|
||||
.client.auth.remove({ providerID })
|
||||
.then(async () => {
|
||||
await serverSdk.client.global.dispose()
|
||||
await serverSdk().client.global.dispose()
|
||||
showToast({
|
||||
variant: "success",
|
||||
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 toggleMcp = useMcpToggle()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
||||
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 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 pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
@@ -18,9 +18,9 @@ export function StatusPopover() {
|
||||
const global = useGlobal()
|
||||
const sync = useSync()
|
||||
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 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 warn = mcp.some((item) => item.status === "needs_auth")
|
||||
if (failed) return "critical" as const
|
||||
@@ -86,9 +86,9 @@ function DirectoryStatusPopover() {
|
||||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
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 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 warn = mcp.some((item) => item.status === "needs_auth")
|
||||
if (failed) return "critical" as const
|
||||
|
||||
@@ -161,9 +161,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const url = sdk.url
|
||||
const directory = sdk().directory
|
||||
const client = sdk().client
|
||||
const url = sdk().url
|
||||
const auth = server.current?.http
|
||||
const username = auth?.username ?? "opencode"
|
||||
const password = auth?.password ?? ""
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
@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,12 +7,11 @@ import {
|
||||
Match,
|
||||
onMount,
|
||||
Show,
|
||||
startTransition,
|
||||
Switch,
|
||||
untrack,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -20,6 +19,8 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
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 { usePlatform } from "@/context/platform"
|
||||
@@ -34,11 +35,13 @@ import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { 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 { useGlobal } from "@/context/global"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabHref, useTabs, type Tab } from "@/context/tabs"
|
||||
import "./titlebar.css"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
@@ -251,7 +254,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
{(_) => {
|
||||
const serverSync = useServerSync()
|
||||
const navigate = useNavigate()
|
||||
const homeMatch = useMatch(() => "/")
|
||||
const layout = useLayout()
|
||||
|
||||
const newSessionHref = () => {
|
||||
@@ -266,21 +268,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const tabs = useTabs()
|
||||
const tabsStore = tabs.store
|
||||
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) => {
|
||||
if (route.type === "home") return
|
||||
if (route.type === "dir-new-sesssion") {
|
||||
if (route.type === "draft") {
|
||||
return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
}
|
||||
if (route.type === "session") {
|
||||
const main = tabsStore.find(
|
||||
@@ -288,7 +280,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
|
||||
)
|
||||
if (main) return main
|
||||
const sync = serverSync.createDirSyncContext(route.dir)
|
||||
const sync = serverSync().createDirSyncContext(route.dir)
|
||||
const session = sync.session.get(route.sessionId)
|
||||
if (session?.parentID) {
|
||||
const parentID = session.parentID
|
||||
@@ -306,10 +298,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const route = layout.route()
|
||||
if (!tabs.ready()) return
|
||||
const tab = currentTab()
|
||||
if (tab) return
|
||||
if (tab) {
|
||||
tabs.remember(tab)
|
||||
return
|
||||
}
|
||||
|
||||
if (route.type === "session") {
|
||||
const sync = serverSync.createDirSyncContext(route.dir)
|
||||
const sync = serverSync().createDirSyncContext(route.dir)
|
||||
const session = sync.session.get(route.sessionId)
|
||||
if (!session) return
|
||||
const sessionId = session.parentID ?? session.id
|
||||
@@ -329,6 +324,18 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
})
|
||||
|
||||
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", () => {
|
||||
const current = currentTab()
|
||||
@@ -366,7 +373,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
if (index === -1) index = tabsStore.length - 1
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) navigateTab(next)
|
||||
if (next) tabs.select(next)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -383,7 +390,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
if (index === tabsStore.length) index = 0
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) navigateTab(next)
|
||||
if (next) tabs.select(next)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => {
|
||||
@@ -398,7 +405,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
const tab = tabsStore[index]
|
||||
if (tab) navigateTab(tab)
|
||||
if (tab) tabs.select(tab)
|
||||
},
|
||||
}
|
||||
}),
|
||||
@@ -424,82 +431,130 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
||||
</Show>
|
||||
<IconButtonV2
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
as="a"
|
||||
href="/"
|
||||
class="!w-9 shrink-0"
|
||||
icon={<IconV2 name="grid-plus" />}
|
||||
state={!!homeMatch() ? "pressed" : undefined}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||
ref={tabScrollRef}
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("home.title")}
|
||||
<KeybindV2 keys={command.keybindParts("home.toggle")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
class="shrink-0"
|
||||
>
|
||||
<div class="flex min-w-0 flex-row items-center gap-1.5">
|
||||
<For each={tabsStore}>
|
||||
{(tab, i) => {
|
||||
let ref!: HTMLDivElement
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
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>
|
||||
|
||||
onMount(() => {
|
||||
refreshTabsAreOverflowing()
|
||||
})
|
||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||
<div
|
||||
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
|
||||
|
||||
if (tab.type !== "session") return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{i() !== 0 && (
|
||||
const divider = () =>
|
||||
i() !== 0 && (
|
||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||
)}
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
server={tab.server}
|
||||
directory={decode64(tab.dirBase64)!}
|
||||
sessionId={tab.sessionId}
|
||||
onNavigate={() => {
|
||||
navigateTab(tab)
|
||||
)
|
||||
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
active={currentTab() === tab}
|
||||
activeServer={tab.server === server.key}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={creating() && params.dir}>
|
||||
{(_) => {
|
||||
let ref!: HTMLDivElement
|
||||
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())}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
})
|
||||
return (
|
||||
<>
|
||||
{divider()}
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
server={tab.server}
|
||||
directory={decode64(tab.dirBase64)!}
|
||||
sessionId={tab.sessionId}
|
||||
onNavigate={() => {
|
||||
tabs.select(tab)
|
||||
|
||||
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>
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
active={currentTab() === tab}
|
||||
activeServer={tab.server === server.key}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={creating() && params.dir}>
|
||||
{(_) => {
|
||||
let ref!: HTMLDivElement
|
||||
|
||||
onMount(() => {
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
})
|
||||
|
||||
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) tabs.select(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>
|
||||
<Show when={!(creating() && params.dir)}>
|
||||
<IconButtonV2
|
||||
@@ -784,7 +839,6 @@ function TabNavItem(props: {
|
||||
>
|
||||
<Show when={session.latest}>
|
||||
{(session) => {
|
||||
console.log({ session: session() })
|
||||
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
|
||||
|
||||
return (
|
||||
@@ -853,6 +907,59 @@ function ProjectTabAvatar(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function DraftTabItem(props: {
|
||||
ref?: HTMLDivElement
|
||||
href: string
|
||||
title: string
|
||||
active?: boolean
|
||||
onNavigate: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
data-active={props.active}
|
||||
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span class="truncate leading-5">{props.title}</span>
|
||||
</a>
|
||||
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -170,13 +170,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean
|
||||
return false
|
||||
}
|
||||
|
||||
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]
|
||||
function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) {
|
||||
const parts: string[] = []
|
||||
|
||||
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
|
||||
@@ -184,40 +178,52 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
|
||||
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.key) {
|
||||
const keys: Record<string, string> = {
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
comma: ",",
|
||||
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)
|
||||
}
|
||||
if (!kb.key) return parts
|
||||
|
||||
const keys: Record<string, string> = {
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
comma: ",",
|
||||
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)
|
||||
|
||||
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("+")
|
||||
}
|
||||
|
||||
@@ -402,25 +408,26 @@ 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 {
|
||||
register,
|
||||
trigger(id: string, source?: CommandSource) {
|
||||
run(id, source)
|
||||
},
|
||||
keybind(id: string) {
|
||||
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)
|
||||
const config = keybindConfig(id)
|
||||
if (!config) return ""
|
||||
return formatKeybind(config, language.t)
|
||||
},
|
||||
keybindParts(id: string) {
|
||||
const config = keybindConfig(id)
|
||||
return config ? formatKeybindParts(config, language.t) : []
|
||||
},
|
||||
show: showPalette,
|
||||
keybinds(enabled: boolean) {
|
||||
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
|
||||
|
||||
@@ -8,6 +8,8 @@ beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
createSimpleContext: () => ({
|
||||
|
||||
@@ -208,7 +208,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||
const decoded = decodeSessionKey(key)
|
||||
return createRoot((dispose) => ({
|
||||
value: createCommentSession(
|
||||
serverSDK.scope,
|
||||
serverSDK().scope,
|
||||
decoded.dir,
|
||||
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
|
||||
),
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||
import { createServerSdkContext, useServerSDK } from "./server-sdk"
|
||||
import { type createServerSdkContext } from "./server-sdk"
|
||||
import { type createServerSyncContextInner } from "./server-sync"
|
||||
|
||||
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 = (
|
||||
directory: string,
|
||||
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
||||
serverSDK: ReturnType<typeof createServerSdkContext> = useServerSDK(),
|
||||
serverSDK: ReturnType<typeof createServerSdkContext>,
|
||||
) => {
|
||||
const client = serverSDK.createClient({ directory, throwOnError: true })
|
||||
|
||||
|
||||
@@ -62,10 +62,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
const language = useLanguage()
|
||||
const layout = useLayout()
|
||||
|
||||
const scope = createMemo(() => sdk.directory)
|
||||
const scope = createMemo(() => sdk().directory)
|
||||
const path = createPathHelpers(scope)
|
||||
const tabs = layout.tabs(() =>
|
||||
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)),
|
||||
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)),
|
||||
)
|
||||
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
@@ -78,7 +78,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
const tree = createFileTreeStore({
|
||||
scope,
|
||||
normalizeDir: path.normalizeDir,
|
||||
list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
|
||||
list: (dir) =>
|
||||
sdk()
|
||||
.client.file.list({ path: dir })
|
||||
.then((x) => x.data ?? []),
|
||||
onError: (message) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -112,7 +115,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 ensure = (file: string) => {
|
||||
@@ -176,8 +179,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
|
||||
setLoading(file)
|
||||
|
||||
const promise = sdk.client.file
|
||||
.read({ path: file })
|
||||
const promise = sdk()
|
||||
.client.file.read({ path: file })
|
||||
.then((x) => {
|
||||
if (scope() !== directory) return
|
||||
const content = x.data
|
||||
@@ -200,12 +203,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const search = (query: string, dirs: "true" | "false") =>
|
||||
sdk.client.find.files({ query, dirs }).then(
|
||||
(x) => (x.data ?? []).map(path.normalize),
|
||||
() => [],
|
||||
)
|
||||
sdk()
|
||||
.client.find.files({ query, dirs })
|
||||
.then(
|
||||
(x) => (x.data ?? []).map(path.normalize),
|
||||
() => [],
|
||||
)
|
||||
|
||||
const stop = sdk.event.listen((e) => {
|
||||
const stop = sdk().event.listen((e) => {
|
||||
invalidateFromWatcher(e.details, {
|
||||
normalize: path.normalize,
|
||||
hasFile: (file) => Boolean(store.file[file]),
|
||||
|
||||
@@ -134,6 +134,26 @@ describe("applyGlobalEvent", () => {
|
||||
})
|
||||
|
||||
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", () => {
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
|
||||
@@ -282,7 +282,13 @@ export function applyDirectoryEvent(input: {
|
||||
if (!parts) break
|
||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||
if (!result.found) break
|
||||
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
|
||||
const field = props.field as keyof (typeof parts)[number]
|
||||
const current = parts[result.index]?.[field]
|
||||
input.setStore(
|
||||
"part_text_accum_delta",
|
||||
props.partID,
|
||||
(existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta,
|
||||
)
|
||||
input.setStore(
|
||||
"part",
|
||||
props.messageID,
|
||||
|
||||
@@ -77,6 +77,7 @@ export type ReviewDiffStyle = "unified" | "split"
|
||||
|
||||
export type LayoutRoute =
|
||||
| { type: "home" }
|
||||
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
|
||||
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
|
||||
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
|
||||
|
||||
@@ -120,10 +121,16 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||
}
|
||||
}
|
||||
|
||||
const currentRoute = (pathname: string): LayoutRoute => {
|
||||
const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||
const parts = pathname.split("/").filter(Boolean)
|
||||
if (parts.length === 0) return { type: "home" }
|
||||
|
||||
if (parts[0] === "new-session") {
|
||||
const draftID = new URLSearchParams(search).get("draftId")
|
||||
if (!draftID) return { type: "home" }
|
||||
return { type: "draft", draftID }
|
||||
}
|
||||
|
||||
const dirBase64 = parts[0]
|
||||
const dir = decode64(dirBase64)
|
||||
if (!dir) return { type: "home" }
|
||||
@@ -145,7 +152,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const platform = usePlatform()
|
||||
const location = useLocation()
|
||||
const route = createMemo(() => {
|
||||
const value = currentRoute(location.pathname)
|
||||
const value = currentRoute(location.pathname, location.search)
|
||||
if (value.type === "home") return value
|
||||
return { ...value, server: server.key }
|
||||
})
|
||||
@@ -239,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(
|
||||
{ ...target, migrate },
|
||||
createStore({
|
||||
@@ -402,11 +409,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
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 metadata = projectID
|
||||
? serverSync.data.project.find((x) => x.id === projectID)
|
||||
: serverSync.data.project.find((x) => x.worktree === project.worktree)
|
||||
? serverSync().data.project.find((x) => x.id === projectID)
|
||||
: serverSync().data.project.find((x) => x.worktree === project.worktree)
|
||||
|
||||
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
||||
// Without this, different subdirectories of the same git repo would share the same
|
||||
@@ -420,7 +427,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
const roots = createMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const project of serverSync.data.project) {
|
||||
for (const project of serverSync().data.project) {
|
||||
const sandboxes = project.sandboxes ?? []
|
||||
for (const sandbox of sandboxes) {
|
||||
map.set(sandbox, project.worktree)
|
||||
@@ -486,12 +493,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
createEffect(() => {
|
||||
const projects = enriched()
|
||||
if (projects.length === 0) return
|
||||
if (!serverSync.ready) return
|
||||
if (!serverSync().ready) return
|
||||
|
||||
for (const project of projects) {
|
||||
if (!project.id) continue
|
||||
if (project.id === "global") continue
|
||||
serverSync.project.icon(project.worktree, project.icon?.override)
|
||||
serverSync().project.icon(project.worktree, project.icon?.override)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -525,12 +532,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
colorRequested.set(worktree, color)
|
||||
|
||||
if (project.id === "global") {
|
||||
serverSync.project.meta(worktree, { icon: { color } })
|
||||
serverSync().project.meta(worktree, { icon: { color } })
|
||||
continue
|
||||
}
|
||||
|
||||
void serverSdk.client.project
|
||||
.update({ projectID: project.id, directory: worktree, icon: { color } })
|
||||
void serverSdk()
|
||||
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
|
||||
.catch(() => {
|
||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||
})
|
||||
@@ -547,7 +554,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
sessionTimer = undefined
|
||||
void Promise.all(
|
||||
server.projects.list().map((project) => {
|
||||
return serverSync.project.loadSessions(project.worktree)
|
||||
return serverSync().project.loadSessions(project.worktree)
|
||||
}),
|
||||
)
|
||||
}, 0)
|
||||
@@ -577,7 +584,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
open(directory: string) {
|
||||
const root = rootFor(directory)
|
||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||
void serverSync.project.loadSessions(root)
|
||||
void serverSync().project.loadSessions(root)
|
||||
server.projects.open(root)
|
||||
},
|
||||
close(directory: string) {
|
||||
|
||||
@@ -64,12 +64,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const models = useModels()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
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,
|
||||
},
|
||||
createStore<Saved>({
|
||||
@@ -124,14 +124,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const scope = createMemo<State | undefined>(() => {
|
||||
const session = id()
|
||||
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(() => {
|
||||
const session = id()
|
||||
if (!session) return
|
||||
|
||||
const key = handoffKey(serverSDK.scope, sdk.directory, session)
|
||||
const key = handoffKey(serverSDK().scope, sdk().directory, session)
|
||||
const next = handoff.get(key)
|
||||
if (!next) return
|
||||
if (saved.session[session] !== undefined) {
|
||||
@@ -144,8 +144,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const configuredModel = () => {
|
||||
if (!sync.data.config.model) return
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
const configured = sync().data.config.model
|
||||
if (!configured) return
|
||||
const [providerID, modelID] = configured.split("/")
|
||||
const model = { providerID, modelID }
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
@@ -363,7 +364,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const result = {
|
||||
slug: createMemo(() => base64Encode(sdk.directory)),
|
||||
slug: createMemo(() => base64Encode(sdk().directory)),
|
||||
model,
|
||||
agent,
|
||||
session: {
|
||||
@@ -374,13 +375,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const next = clone(snapshot())
|
||||
if (!next) return
|
||||
|
||||
if (dir === sdk.directory) {
|
||||
if (dir === sdk().directory) {
|
||||
setSaved("session", session, next)
|
||||
setStore("draft", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
handoff.set(handoffKey(serverSDK.scope, dir, session), next)
|
||||
handoff.set(handoffKey(serverSDK().scope, dir, session), next)
|
||||
setStore("draft", undefined)
|
||||
},
|
||||
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
||||
@@ -388,7 +389,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
if (!session) return
|
||||
if (msg.sessionID !== session) 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, {
|
||||
agent: msg.agent,
|
||||
|
||||
@@ -8,7 +8,7 @@ export function useMcpToggle() {
|
||||
const language = useLanguage()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: sync.mcp.toggle,
|
||||
mutationFn: sync().mcp.toggle,
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
|
||||
@@ -125,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
const currentSession = createMemo(() => params.id)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK.scope, "notification", ["notification.v1"]),
|
||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
@@ -208,11 +208,11 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
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)
|
||||
if (match.found) return syncStore.session[match.index]
|
||||
return serverSDK.client.session
|
||||
.get({ directory, sessionID })
|
||||
return serverSDK()
|
||||
.client.session.get({ directory, sessionID })
|
||||
.then((x) => x.data)
|
||||
.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
|
||||
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 directory = decode64(params.dir)
|
||||
if (!directory) return false
|
||||
const [store] = serverSync.child(directory)
|
||||
const [store] = serverSync().child(directory)
|
||||
return hasPermissionPromptRules(store.config.permission)
|
||||
})
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(serverSDK.scope, "permission", ["permission.v3"]),
|
||||
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
|
||||
migrate(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
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return
|
||||
const [childStore] = serverSync.child(directory)
|
||||
const [childStore] = serverSync().child(directory)
|
||||
const perm = childStore.config.permission
|
||||
if (typeof perm === "string" && perm === "allow") {
|
||||
const key = directoryAcceptKey(directory)
|
||||
@@ -119,9 +119,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}
|
||||
|
||||
const respond: PermissionRespondFn = (input) => {
|
||||
serverSDK.client.permission.respond(input).catch(() => {
|
||||
responded.delete(input.permissionID)
|
||||
})
|
||||
serverSDK()
|
||||
.client.permission.respond(input)
|
||||
.catch(() => {
|
||||
responded.delete(input.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
@@ -140,7 +142,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -149,7 +151,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -160,7 +162,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
return next
|
||||
}
|
||||
|
||||
const unsubscribe = serverSDK.event.listen((e) => {
|
||||
const unsubscribe = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
|
||||
@@ -179,8 +181,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}),
|
||||
)
|
||||
|
||||
serverSDK.client.permission
|
||||
.list({ directory })
|
||||
serverSDK()
|
||||
.client.permission.list({ directory })
|
||||
.then((x) => {
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
@@ -211,8 +213,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}),
|
||||
)
|
||||
|
||||
serverSDK.client.permission
|
||||
.list({ directory })
|
||||
serverSDK()
|
||||
.client.permission.list({ directory })
|
||||
.then((x) => {
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
@@ -269,7 +271,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
},
|
||||
permissionsEnabled,
|
||||
isPermissionAllowAll(directory: string) {
|
||||
const [childStore] = serverSync.child(directory)
|
||||
const [childStore] = serverSync().child(directory)
|
||||
const perm = childStore.config.permission
|
||||
return typeof perm === "string" && perm === "allow"
|
||||
},
|
||||
|
||||
@@ -55,6 +55,9 @@ type PlatformBase = {
|
||||
onFile: (file: File) => Promise<unknown>,
|
||||
): Promise<void>
|
||||
|
||||
/** Resolve the native source path for a desktop File. */
|
||||
getPathForFile?(file: File): string
|
||||
|
||||
/** Open a native save file picker dialog (desktop only) */
|
||||
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
}
|
||||
@@ -153,6 +154,14 @@ const MAX_PROMPT_SESSIONS = 20
|
||||
|
||||
type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
type PromptStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}
|
||||
|
||||
type Scope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
function scopeKey(scope: Scope) {
|
||||
@@ -174,25 +183,26 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
|
||||
function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
promptTarget(serverScope, scope),
|
||||
createStore<{
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}>({
|
||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||
cursor: undefined,
|
||||
context: {
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
createStore<PromptStore>(promptStore()),
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
return {
|
||||
ready,
|
||||
current: () => store.prompt,
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||
@@ -232,6 +242,15 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
name: "Prompt",
|
||||
gate: false,
|
||||
@@ -272,7 +291,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
|
||||
const entry = createRoot(
|
||||
(dispose) => ({
|
||||
value: createPromptSession(serverSDK.scope, scope),
|
||||
value: createPromptSession(serverSDK().scope, scope),
|
||||
dispose,
|
||||
}),
|
||||
owner,
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { type ServerSDK, useServerSDK } from "./server-sdk"
|
||||
|
||||
export type DirectorySDK = ReturnType<ServerSDK["createDirSdkContext"]>
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
init: (props: { directory: string }) => {
|
||||
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
||||
init: (props: { directory: string | Accessor<string> }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
|
||||
return serverSDK.createDirSdkContext(props.directory)
|
||||
return createMemo(() => {
|
||||
const directory = typeof props.directory === "function" ? props.directory() : props.directory
|
||||
return serverSDK().createDirSdkContext(directory)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
@@ -12,3 +13,41 @@ describe("resumeStreamAfterPageShow", () => {
|
||||
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 { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
@@ -15,13 +15,46 @@ const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||
type QueuedServerEvent = { directory: string; payload: Event }
|
||||
|
||||
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) {
|
||||
if (!event.persisted) return
|
||||
start()
|
||||
}
|
||||
|
||||
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope) {
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
@@ -45,7 +78,7 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
|
||||
[key: string]: Event
|
||||
}>()
|
||||
|
||||
type Queued = { directory: string; payload: Event }
|
||||
type Queued = QueuedServerEvent
|
||||
const FLUSH_FRAME_MS = 16
|
||||
const STREAM_YIELD_MS = 8
|
||||
const RECONNECT_DELAY_MS = 250
|
||||
@@ -57,8 +90,6 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
||||
|
||||
const key = (directory: string, payload: Event) => {
|
||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||
@@ -83,14 +114,9 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
|
||||
staleDeltas.clear()
|
||||
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events, skip)
|
||||
batch(() => {
|
||||
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)
|
||||
}
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
@@ -261,21 +287,31 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerSDK = ReturnType<typeof createServerSdkContext>
|
||||
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
||||
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({
|
||||
name: "ServerSDK",
|
||||
init: (props: { server?: ServerConnection.Any }) => {
|
||||
// Returns an accessor so the resolved server can change reactively (e.g. a
|
||||
// /new-session draft retargeting its server) without re-instantiating the subtree.
|
||||
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
const conn = props.server ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
|
||||
const ctx = global.createServerCtx(conn)
|
||||
return Object.assign(ctx.sdk, {
|
||||
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, ctx.sdk)),
|
||||
return createMemo<ServerSDK>(() => {
|
||||
const conn = props.server?.() ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
return global.createServerCtx(conn).sdk
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -284,7 +320,7 @@ type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDK) {
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
const client = serverSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { batch, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { ServerSDK, useServerSDK } from "./server-sdk"
|
||||
import { ServerSDK } from "./server-sdk"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
bootstrapGlobal,
|
||||
@@ -84,8 +84,7 @@ function makeQueryOptionsApi(
|
||||
}
|
||||
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
||||
|
||||
export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
||||
const serverSDK: ServerSDK = _serverSDK ?? useServerSDK()
|
||||
export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const language = useLanguage()
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||
@@ -507,33 +506,37 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createServerSyncContext(_serverSDK?: ServerSDK) {
|
||||
const inner = createServerSyncContextInner(_serverSDK)
|
||||
export function createServerSyncContext(serverSDK: ServerSDK) {
|
||||
const inner = createServerSyncContextInner(serverSDK)
|
||||
return Object.assign(inner, {
|
||||
createDirSyncContext: createRefCountMap(
|
||||
(dir) => createDirSyncContext(dir, inner, _serverSDK),
|
||||
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
||||
(dir) => inner.disableMcp(dir),
|
||||
directoryKey,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export type ServerSync = ReturnType<typeof createServerSyncContext>
|
||||
|
||||
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
|
||||
name: "ServerSync",
|
||||
gate: false,
|
||||
init: (props: { server?: ServerConnection.Any }) => {
|
||||
// Returns an accessor so the resolved server can change reactively without
|
||||
// re-instantiating the subtree (mirrors useServerSDK).
|
||||
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
const conn = props.server ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
const ctx = global.createServerCtx(conn)
|
||||
|
||||
return ctx.sync
|
||||
return createMemo<ServerSync>(() => {
|
||||
const conn = props.server?.() ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
return global.createServerCtx(conn).sync
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export function useQueryOptions() {
|
||||
return useServerSync().queryOptions
|
||||
const sync = useServerSync()
|
||||
return createMemo(() => sync().queryOptions)
|
||||
}
|
||||
|
||||
@@ -153,6 +153,15 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
gate: false,
|
||||
init: () => {
|
||||
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(() => {
|
||||
if (typeof document === "undefined") return
|
||||
@@ -187,7 +196,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setFollowup(value: "queue" | "steer") {
|
||||
setStore("general", "followup", value === "queue" ? "steer" : value)
|
||||
},
|
||||
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
|
||||
showFileTree,
|
||||
setShowFileTree(value: boolean) {
|
||||
setStore("general", "showFileTree", value)
|
||||
},
|
||||
@@ -195,11 +204,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowNavigation(value: boolean) {
|
||||
setStore("general", "showNavigation", value)
|
||||
},
|
||||
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
|
||||
showSearch,
|
||||
setShowSearch(value: boolean) {
|
||||
setStore("general", "showSearch", value)
|
||||
},
|
||||
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
|
||||
showStatus,
|
||||
setShowStatus(value: boolean) {
|
||||
setStore("general", "showStatus", value)
|
||||
},
|
||||
@@ -235,15 +244,21 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowSessionProgressBar(value: boolean) {
|
||||
setStore("general", "showSessionProgressBar", value)
|
||||
},
|
||||
showCustomAgents: withFallback(() => store.general?.showCustomAgents, defaultSettings.general.showCustomAgents),
|
||||
showCustomAgents,
|
||||
setShowCustomAgents(value: boolean) {
|
||||
setStore("general", "showCustomAgents", value)
|
||||
},
|
||||
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
|
||||
newLayoutDesigns,
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
fileTree: visible(showFileTree),
|
||||
search: visible(showSearch),
|
||||
status: visible(showStatus),
|
||||
customAgents: visible(showCustomAgents),
|
||||
},
|
||||
appearance: {
|
||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||
setFontSize(value: number) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -112,5 +113,7 @@ export const useSync = () => {
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useSDK()
|
||||
|
||||
return serverSync.createDirSyncContext(sdk.directory)
|
||||
return createMemo(() => serverSync().createDirSyncContext(sdk().directory))
|
||||
}
|
||||
|
||||
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createStore, produce } from "solid-js/store"
|
||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { createEffect, startTransition } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { usePlatform } from "./platform"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
@@ -27,6 +27,10 @@ export type DraftTab = {
|
||||
|
||||
export type Tab = SessionTab | DraftTab
|
||||
|
||||
type RecentTab = {
|
||||
key?: string
|
||||
}
|
||||
|
||||
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
|
||||
|
||||
export const tabHref = (tab: Tab) =>
|
||||
@@ -62,25 +66,45 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
createStore<Tab[]>([]),
|
||||
)
|
||||
const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
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) => {
|
||||
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (!ready() || !recentReady()) return
|
||||
const servers = new Set(server.list.map(ServerConnection.key))
|
||||
if (store.every((tab) => servers.has(tab.server))) return
|
||||
setStore((tabs) => tabs.filter((tab) => servers.has(tab.server)))
|
||||
const next = store.filter((tab) => servers.has(tab.server))
|
||||
if (next.length !== store.length) setStore(() => next)
|
||||
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
|
||||
})
|
||||
|
||||
const navigateTab = (tab: Tab) => {
|
||||
const href = tabHref(tab)
|
||||
setRecentKey(tabKey(tab))
|
||||
if (tab.server === server.key) {
|
||||
navigate(href)
|
||||
return
|
||||
@@ -123,14 +147,22 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
)
|
||||
},
|
||||
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
||||
const active = `${location.pathname}${location.search}` === draftHref(draftID)
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||
if (index !== -1) tabs[index] = { type: "session", ...session }
|
||||
}),
|
||||
)
|
||||
if (active) navigateTab({ type: "session", ...session })
|
||||
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
|
||||
// replaces the draft tab with a session tab, so the draft route would stop resolving
|
||||
// and fall back home. Navigate to the new session first so we leave /new-session
|
||||
// before the draft is removed from the store.
|
||||
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
||||
const next = { type: "session" as const, ...session }
|
||||
startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||
if (index !== -1) tabs[index] = next
|
||||
}),
|
||||
)
|
||||
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
|
||||
if (active) navigateTab(next)
|
||||
})
|
||||
removeDraftPersisted(draftID)
|
||||
},
|
||||
removeTab: (index: number) => {
|
||||
@@ -146,6 +178,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
tabs.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
|
||||
if (nextTab) navigateTab(nextTab)
|
||||
else navigate("/")
|
||||
}).finally(() => closing.delete(key))
|
||||
@@ -153,11 +186,22 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
removeServer(key: ServerConnection.Key) {
|
||||
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||
const removed = store.filter((tab) => tab.server === key).map(tabKey)
|
||||
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
||||
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
||||
for (const draftID of drafts) removeDraftPersisted(draftID)
|
||||
if (server.key === key) navigate("/")
|
||||
},
|
||||
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(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
@@ -200,10 +244,29 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
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 }
|
||||
return { ...actions, store, ready, recentReady }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@ beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
createSimpleContext: () => ({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSDK, type DirectorySDK } from "./sdk"
|
||||
import type { Platform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
import { defaultTitle, titleNumber } from "./terminal-title"
|
||||
@@ -143,7 +143,7 @@ export function clearWorkspaceTerminals(
|
||||
}
|
||||
|
||||
function createWorkspaceTerminalSession(
|
||||
sdk: ReturnType<typeof useSDK>,
|
||||
sdk: DirectorySDK,
|
||||
dir: string,
|
||||
scope: ServerScopeValue,
|
||||
legacySessionID?: string,
|
||||
@@ -202,7 +202,7 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
||||
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
||||
const previous = index >= 0 ? store.all[index] : undefined
|
||||
if (index >= 0) {
|
||||
@@ -223,7 +223,7 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
}
|
||||
|
||||
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
|
||||
const clone = async (client: DirectorySDK["client"], id: string) => {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
@@ -412,7 +412,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
}
|
||||
|
||||
const entry = createRoot((dispose) => ({
|
||||
value: createWorkspaceTerminalSession(sdk, dir, serverScope, legacySessionID),
|
||||
value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
id: "view",
|
||||
label: "View",
|
||||
items: [
|
||||
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } },
|
||||
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" },
|
||||
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
|
||||
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
|
||||
{ type: "separator" },
|
||||
|
||||
@@ -22,10 +22,10 @@ export function useProviders() {
|
||||
const dir = createMemo(() => decode64(params.dir) ?? "")
|
||||
const providers = () => {
|
||||
if (dir()) {
|
||||
const [projectStore] = serverSync.child(dir())
|
||||
const [projectStore] = serverSync().child(dir())
|
||||
if (projectStore.provider_ready) return projectStore.provider
|
||||
}
|
||||
return serverSync.data.provider
|
||||
return serverSync().data.provider
|
||||
}
|
||||
return {
|
||||
all: () => providers().all,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user