Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c019d0e67 | |||
| 6dbb46a29f | |||
| 355a0bcf5b | |||
| 2892e97c57 | |||
| 62c746f2e8 | |||
| b149866134 | |||
| 5273f8dd7b | |||
| 4e50c40f50 | |||
| 736e9c335f | |||
| 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 |
@@ -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.17.4",
|
||||
"version": "1.17.8",
|
||||
"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.17.4",
|
||||
"version": "1.17.8",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -110,7 +111,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"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.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -173,7 +174,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -195,7 +196,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"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.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -239,7 +240,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -330,7 +331,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -384,7 +385,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -398,7 +399,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -410,7 +411,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -441,7 +442,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -457,7 +458,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
@@ -476,7 +477,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -494,7 +495,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -622,7 +623,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -660,7 +661,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -675,7 +676,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -689,7 +690,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -702,7 +703,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
@@ -735,7 +736,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -754,7 +755,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -794,7 +795,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -821,12 +822,13 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.4",
|
||||
"version": "1.17.8",
|
||||
"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.17.4",
|
||||
"version": "1.17.8",
|
||||
"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:",
|
||||
@@ -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",
|
||||
@@ -978,14 +984,13 @@
|
||||
"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",
|
||||
@@ -2121,9 +2126,13 @@
|
||||
|
||||
"@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.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="],
|
||||
"@pierre/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@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="],
|
||||
"@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=="],
|
||||
|
||||
@@ -2327,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=="],
|
||||
|
||||
@@ -2577,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=="],
|
||||
@@ -4561,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=="],
|
||||
@@ -4843,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=="],
|
||||
|
||||
@@ -5253,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=="],
|
||||
@@ -5503,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=="],
|
||||
@@ -5841,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=="],
|
||||
|
||||
@@ -5957,6 +5982,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=="],
|
||||
@@ -6201,9 +6228,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=="],
|
||||
|
||||
@@ -6345,6 +6372,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=="],
|
||||
@@ -6641,6 +6680,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=="],
|
||||
@@ -6707,6 +6748,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=="],
|
||||
|
||||
+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", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||
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-u3CBUflGrE1unVRTvvLKaDkvx5ZHXyRLd/SJOUUsRdc=",
|
||||
"aarch64-linux": "sha256-hUu70IlOXXQ9apQe3IPOdQGf3ZSVCpvRuAWSKxipk/k=",
|
||||
"aarch64-darwin": "sha256-N6Mqmo94q0rsskLcXoUGWCMJjQ9wCuuo0Lby3j6+Dps=",
|
||||
"x86_64-darwin": "sha256-5691VKK2zWqAwe94CCjZuRjXEaswedevFtHxensVLfg="
|
||||
"x86_64-linux": "sha256-LOxTad/iCquvJyonFOcz6/rDTPNDmwyBnykhWZJ5GC4=",
|
||||
"aarch64-linux": "sha256-iO+0vYhp+2x6ACmh5lQJ/2Ac4uZTqRZE/KhG3u0o6D8=",
|
||||
"aarch64-darwin": "sha256-tpBydRbrJ+4QxmkGUt/BhME8q6ysCW/CXrsNshYgqDU=",
|
||||
"x86_64-darwin": "sha256-QQcI6SK7WJ7dSkX6xZuSQPoUdwfoCaimVgoHCnrO0wY="
|
||||
}
|
||||
}
|
||||
|
||||
+9
-5
@@ -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,7 +53,7 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"opentui-spinner": "0.0.7",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
@@ -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",
|
||||
@@ -145,10 +146,13 @@
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -139,7 +139,7 @@ function toolPart(
|
||||
status: "completed",
|
||||
input,
|
||||
output: lorem(index * 23 + partIndex, outputLength),
|
||||
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
||||
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
|
||||
metadata,
|
||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
||||
},
|
||||
@@ -201,7 +201,7 @@ function turn(index: number): Message[] {
|
||||
? [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)]
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)]
|
||||
: []),
|
||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
||||
|
||||
@@ -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) {
|
||||
@@ -44,7 +47,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (url.port !== targetPort) 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,9 +63,12 @@ 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)
|
||||
}
|
||||
|
||||
@@ -83,10 +89,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.17.4",
|
||||
"version": "1.17.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -17,8 +17,9 @@
|
||||
"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",
|
||||
@@ -46,6 +47,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 +65,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 +78,6 @@
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"virtua": "catalog:"
|
||||
"tailwindcss": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
+91
-48
@@ -69,8 +69,8 @@ const SessionRoute = Object.assign(
|
||||
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)
|
||||
if (!tabs.ready() || !sdk().directory) return
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -82,6 +82,45 @@ const SessionRoute = Object.assign(
|
||||
{ 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()
|
||||
@@ -95,19 +134,15 @@ function DraftRoute() {
|
||||
}
|
||||
|
||||
function ResolvedDraftRoute(props: { draftID: string }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const draft = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const current = draft()
|
||||
if (current && current.server !== server.key) server.setActive(current.server)
|
||||
})
|
||||
|
||||
// Key on the directory so retargeting the draft's project re-instantiates the
|
||||
// SDK/data providers for the new directory while keeping the same draft id.
|
||||
// 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 (
|
||||
@@ -171,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>
|
||||
@@ -216,17 +260,6 @@ function DraftProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||
return (
|
||||
<AppShellProviders>
|
||||
{/*<Suspense fallback={<Loading />}>*/}
|
||||
{props.appChildren}
|
||||
{props.children}
|
||||
{/*</Suspense>*/}
|
||||
</AppShellProviders>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
@@ -385,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}
|
||||
@@ -397,23 +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="/new-session" component={DraftRoute} />
|
||||
<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: {
|
||||
@@ -264,6 +271,9 @@ function createSessionEntries(props: {
|
||||
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()
|
||||
@@ -292,21 +302,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
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()
|
||||
@@ -383,6 +393,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +31,7 @@ import {
|
||||
FileAttachmentPart,
|
||||
} from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -46,14 +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 { 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,27 +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 tabsStore = useTabs()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const tabs = () => props.controls.session.tabs
|
||||
let editorRef!: HTMLDivElement
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
let scrollRef!: HTMLDivElement
|
||||
@@ -204,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)
|
||||
}
|
||||
@@ -237,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)
|
||||
@@ -269,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"),
|
||||
)
|
||||
@@ -347,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())
|
||||
|
||||
@@ -474,7 +530,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const pick = () => {
|
||||
pickAttachmentFiles({
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk.directory,
|
||||
directory: () => sdk().directory,
|
||||
fallback: () => fileInputRef?.click(),
|
||||
onFile: addAttachment,
|
||||
onError: (error) =>
|
||||
@@ -573,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)
|
||||
@@ -603,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
|
||||
@@ -673,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,
|
||||
@@ -1033,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(
|
||||
@@ -1082,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(),
|
||||
@@ -1096,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),
|
||||
@@ -1105,6 +1157,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
addPart,
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
getPathForFile: platform.getPathForFile,
|
||||
})
|
||||
|
||||
const fileAttachmentInput = () => (
|
||||
@@ -1122,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") {
|
||||
@@ -1317,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(
|
||||
@@ -1342,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)
|
||||
@@ -1366,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,
|
||||
@@ -1382,33 +1430,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
restoreFocus()
|
||||
return
|
||||
}
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
|
||||
// On the draft route, retarget the existing draft in place so we keep the same
|
||||
// draft id (and its tab/prompt) instead of spawning a new draft for the new directory.
|
||||
const draftID = search.draftId
|
||||
if (draftID) {
|
||||
tabsStore.updateDraft(draftID, { server: server.key, directory: worktree })
|
||||
restoreFocus()
|
||||
return
|
||||
}
|
||||
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
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>(() => ({
|
||||
@@ -1449,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()
|
||||
},
|
||||
}))
|
||||
@@ -1485,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"}
|
||||
@@ -1605,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
|
||||
@@ -1617,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"
|
||||
@@ -1861,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"
|
||||
@@ -1883,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"
|
||||
@@ -1900,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>
|
||||
@@ -1926,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",
|
||||
@@ -1937,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>
|
||||
@@ -1966,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) => (
|
||||
|
||||
@@ -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)
|
||||
@@ -73,6 +89,7 @@ beforeAll(async () => {
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
Toast: { Region: () => null },
|
||||
showToast: () => 0,
|
||||
}))
|
||||
|
||||
@@ -116,16 +133,7 @@ beforeAll(async () => {
|
||||
}))
|
||||
|
||||
mock.module("@/context/prompt", () => ({
|
||||
usePrompt: () => ({
|
||||
current: () => promptValue,
|
||||
reset: () => undefined,
|
||||
set: () => undefined,
|
||||
context: {
|
||||
add: () => undefined,
|
||||
remove: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
}),
|
||||
usePrompt: () => prompt,
|
||||
}))
|
||||
|
||||
mock.module("@/context/layout", () => ({
|
||||
@@ -147,12 +155,12 @@ beforeAll(async () => {
|
||||
return clientFor(opts.directory)
|
||||
},
|
||||
}
|
||||
return sdk
|
||||
return () => sdk
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => ({
|
||||
useSync: () => () => ({
|
||||
data: { command: [] },
|
||||
session: {
|
||||
optimistic: {
|
||||
@@ -176,7 +184,7 @@ beforeAll(async () => {
|
||||
}))
|
||||
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => ({
|
||||
useServerSync: () => () => ({
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
storedSessions[directory] ??= []
|
||||
@@ -232,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,
|
||||
@@ -269,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,
|
||||
@@ -299,6 +309,7 @@ describe("prompt submit worktree selection", () => {
|
||||
variant = "high"
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
@@ -330,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,
|
||||
|
||||
@@ -7,14 +7,14 @@ import { batch, type Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
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"
|
||||
@@ -40,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
|
||||
@@ -174,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>
|
||||
@@ -211,14 +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 [search] = useSearchParams<{ draftId?: string }>()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
@@ -233,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?.()
|
||||
@@ -247,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(() => {})
|
||||
@@ -281,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]
|
||||
@@ -321,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") {
|
||||
@@ -349,7 +350,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
})
|
||||
return
|
||||
}
|
||||
WorktreeState.pending(sdk.scope, createdWorktree.directory)
|
||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
@@ -358,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?.()
|
||||
@@ -470,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
|
||||
@@ -504,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,
|
||||
@@ -515,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)
|
||||
@@ -559,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)
|
||||
@@ -573,8 +576,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
void sendFollowupDraft({
|
||||
client,
|
||||
sync,
|
||||
serverSync,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
@@ -582,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,17 +268,6 @@ 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
|
||||
@@ -289,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
|
||||
@@ -307,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
|
||||
@@ -330,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()
|
||||
@@ -367,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)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -384,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) => {
|
||||
@@ -399,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)
|
||||
},
|
||||
}
|
||||
}),
|
||||
@@ -425,102 +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
|
||||
|
||||
const divider = () =>
|
||||
i() !== 0 && (
|
||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||
)
|
||||
const divider = () =>
|
||||
i() !== 0 && (
|
||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||
)
|
||||
|
||||
if (tab.type === "draft") {
|
||||
return (
|
||||
<>
|
||||
{divider()}
|
||||
<DraftTabItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
title={language.t("command.session.new")}
|
||||
active={currentTab() === tab}
|
||||
onNavigate={() => {
|
||||
tabs.select(tab)
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab.type === "draft") {
|
||||
return (
|
||||
<>
|
||||
{divider()}
|
||||
<DraftTabItem
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
title={language.t("command.session.new")}
|
||||
active={currentTab() === tab}
|
||||
server={tab.server}
|
||||
directory={decode64(tab.dirBase64)!}
|
||||
sessionId={tab.sessionId}
|
||||
onNavigate={() => {
|
||||
navigateTab(tab)
|
||||
tabs.select(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
|
||||
|
||||
return (
|
||||
<>
|
||||
{divider()}
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
server={tab.server}
|
||||
directory={decode64(tab.dirBase64)!}
|
||||
sessionId={tab.sessionId}
|
||||
onNavigate={() => {
|
||||
navigateTab(tab)
|
||||
onMount(() => {
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
})
|
||||
|
||||
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) navigateTab(tab)
|
||||
else navigate("/")
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -246,7 +246,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
}
|
||||
|
||||
const target = Persist.serverGlobal(serverSdk.scope, "layout", ["layout.v6"])
|
||||
const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"])
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{ ...target, migrate },
|
||||
createStore({
|
||||
@@ -409,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
|
||||
@@ -427,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)
|
||||
@@ -493,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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -532,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)
|
||||
})
|
||||
@@ -554,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)
|
||||
@@ -584,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>>
|
||||
|
||||
@@ -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,26 +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
|
||||
@@ -129,14 +152,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
// 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] = { type: "session", ...session }
|
||||
if (index !== -1) tabs[index] = next
|
||||
}),
|
||||
)
|
||||
if (active) navigateTab({ type: "session", ...session })
|
||||
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
|
||||
if (active) navigateTab(next)
|
||||
})
|
||||
removeDraftPersisted(draftID)
|
||||
},
|
||||
@@ -153,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))
|
||||
@@ -160,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) => {
|
||||
@@ -207,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 }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -281,6 +281,11 @@ export const dict = {
|
||||
"dialog.fork.empty": "لا توجد رسائل للتفرع منها",
|
||||
"dialog.directory.search.placeholder": "البحث في المجلدات",
|
||||
"dialog.directory.empty": "لم يتم العثور على مجلدات",
|
||||
"dialog.directory.action.selectFile": "اختيار ملف",
|
||||
"dialog.directory.action.selectFolder": "اختيار مجلد",
|
||||
"dialog.directory.root": "الجذر",
|
||||
"dialog.directory.parent": "المجلد الأصل",
|
||||
"dialog.directory.readError": "تعذرت قراءة هذا المجلد",
|
||||
"dialog.server.title": "الخوادم",
|
||||
"dialog.server.description": "تبديل خادم OpenCode الذي يتصل به هذا التطبيق.",
|
||||
"dialog.server.search.placeholder": "البحث في الخوادم",
|
||||
|
||||
@@ -281,6 +281,11 @@ export const dict = {
|
||||
"dialog.fork.empty": "Nenhuma mensagem para bifurcar",
|
||||
"dialog.directory.search.placeholder": "Buscar pastas",
|
||||
"dialog.directory.empty": "Nenhuma pasta encontrada",
|
||||
"dialog.directory.action.selectFile": "Selecionar arquivo",
|
||||
"dialog.directory.action.selectFolder": "Selecionar pasta",
|
||||
"dialog.directory.root": "Raiz",
|
||||
"dialog.directory.parent": "Pasta superior",
|
||||
"dialog.directory.readError": "Não foi possível ler esta pasta",
|
||||
"dialog.server.title": "Servidores",
|
||||
"dialog.server.description": "Trocar para qual servidor OpenCode este aplicativo se conecta.",
|
||||
"dialog.server.search.placeholder": "Buscar servidores",
|
||||
|
||||
@@ -307,6 +307,11 @@ export const dict = {
|
||||
|
||||
"dialog.directory.search.placeholder": "Pretraži foldere",
|
||||
"dialog.directory.empty": "Nema pronađenih foldera",
|
||||
"dialog.directory.action.selectFile": "Odaberi datoteku",
|
||||
"dialog.directory.action.selectFolder": "Odaberi folder",
|
||||
"dialog.directory.root": "Korijen",
|
||||
"dialog.directory.parent": "Nadređeni folder",
|
||||
"dialog.directory.readError": "Nije moguće pročitati ovaj folder",
|
||||
|
||||
"dialog.server.title": "Serveri",
|
||||
"dialog.server.description": "Promijeni na koji se OpenCode server ova aplikacija povezuje.",
|
||||
|
||||
@@ -305,6 +305,11 @@ export const dict = {
|
||||
|
||||
"dialog.directory.search.placeholder": "Søg mapper",
|
||||
"dialog.directory.empty": "Ingen mapper fundet",
|
||||
"dialog.directory.action.selectFile": "Vælg fil",
|
||||
"dialog.directory.action.selectFolder": "Vælg mappe",
|
||||
"dialog.directory.root": "Rod",
|
||||
"dialog.directory.parent": "Overordnet",
|
||||
"dialog.directory.readError": "Denne mappe kan ikke læses",
|
||||
|
||||
"dialog.server.title": "Servere",
|
||||
"dialog.server.description": "Skift hvilken OpenCode-server denne app forbinder til.",
|
||||
|
||||
@@ -287,6 +287,11 @@ export const dict = {
|
||||
"dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden",
|
||||
"dialog.directory.search.placeholder": "Ordner durchsuchen",
|
||||
"dialog.directory.empty": "Keine Ordner gefunden",
|
||||
"dialog.directory.action.selectFile": "Datei auswählen",
|
||||
"dialog.directory.action.selectFolder": "Ordner auswählen",
|
||||
"dialog.directory.root": "Stammverzeichnis",
|
||||
"dialog.directory.parent": "Übergeordnet",
|
||||
"dialog.directory.readError": "Dieser Ordner kann nicht gelesen werden",
|
||||
"dialog.server.title": "Server",
|
||||
"dialog.server.description": "Wechseln Sie den OpenCode-Server, mit dem sich diese App verbindet.",
|
||||
"dialog.server.search.placeholder": "Server durchsuchen",
|
||||
|
||||
@@ -313,6 +313,11 @@ export const dict = {
|
||||
|
||||
"dialog.directory.search.placeholder": "Search folders",
|
||||
"dialog.directory.empty": "No folders found",
|
||||
"dialog.directory.action.selectFile": "Select file",
|
||||
"dialog.directory.action.selectFolder": "Select folder",
|
||||
"dialog.directory.root": "Root",
|
||||
"dialog.directory.parent": "Parent",
|
||||
"dialog.directory.readError": "Unable to read this folder",
|
||||
|
||||
"app.server.unreachable": "Could not reach {{server}}",
|
||||
"app.server.retrying": "Retrying automatically...",
|
||||
@@ -825,17 +830,17 @@ export const dict = {
|
||||
"settings.general.row.followup.option.queue": "Queue",
|
||||
"settings.general.row.followup.option.steer": "Steer",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree panel in desktop sessions",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||
"settings.general.row.showSearch.title": "Command palette",
|
||||
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
|
||||
"settings.general.row.showSearch.description": "Show the search and command palette button in the title bar",
|
||||
"settings.general.row.showTerminal.title": "Terminal",
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||
"settings.general.row.showCustomAgents.title": "Custom agents",
|
||||
"settings.general.row.showCustomAgents.description": "Show the agent picker in the v2 desktop composer",
|
||||
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
|
||||
@@ -306,6 +306,11 @@ export const dict = {
|
||||
|
||||
"dialog.directory.search.placeholder": "Buscar carpetas",
|
||||
"dialog.directory.empty": "No se encontraron carpetas",
|
||||
"dialog.directory.action.selectFile": "Seleccionar archivo",
|
||||
"dialog.directory.action.selectFolder": "Seleccionar carpeta",
|
||||
"dialog.directory.root": "Raíz",
|
||||
"dialog.directory.parent": "Superior",
|
||||
"dialog.directory.readError": "No se puede leer esta carpeta",
|
||||
|
||||
"dialog.server.title": "Servidores",
|
||||
"dialog.server.description": "Cambiar a qué servidor de OpenCode se conecta esta app.",
|
||||
|
||||
@@ -282,6 +282,11 @@ export const dict = {
|
||||
"dialog.fork.empty": "Aucun message à partir duquel bifurquer",
|
||||
"dialog.directory.search.placeholder": "Rechercher des dossiers",
|
||||
"dialog.directory.empty": "Aucun dossier trouvé",
|
||||
"dialog.directory.action.selectFile": "Sélectionner le fichier",
|
||||
"dialog.directory.action.selectFolder": "Sélectionner le dossier",
|
||||
"dialog.directory.root": "Racine",
|
||||
"dialog.directory.parent": "Parent",
|
||||
"dialog.directory.readError": "Impossible de lire ce dossier",
|
||||
"dialog.server.title": "Serveurs",
|
||||
"dialog.server.description": "Changez le serveur OpenCode auquel cette application se connecte.",
|
||||
"dialog.server.search.placeholder": "Rechercher des serveurs",
|
||||
|
||||
@@ -280,6 +280,11 @@ export const dict = {
|
||||
"dialog.fork.empty": "フォーク元のメッセージがありません",
|
||||
"dialog.directory.search.placeholder": "フォルダを検索",
|
||||
"dialog.directory.empty": "フォルダが見つかりません",
|
||||
"dialog.directory.action.selectFile": "ファイルを選択",
|
||||
"dialog.directory.action.selectFolder": "フォルダを選択",
|
||||
"dialog.directory.root": "ルート",
|
||||
"dialog.directory.parent": "親フォルダ",
|
||||
"dialog.directory.readError": "このフォルダを読み取れません",
|
||||
"dialog.server.title": "サーバー",
|
||||
"dialog.server.description": "このアプリが接続するOpenCodeサーバーを切り替えます。",
|
||||
"dialog.server.search.placeholder": "サーバーを検索",
|
||||
|
||||
@@ -280,6 +280,11 @@ export const dict = {
|
||||
"dialog.fork.empty": "분기할 메시지 없음",
|
||||
"dialog.directory.search.placeholder": "폴더 검색",
|
||||
"dialog.directory.empty": "폴더 없음",
|
||||
"dialog.directory.action.selectFile": "파일 선택",
|
||||
"dialog.directory.action.selectFolder": "폴더 선택",
|
||||
"dialog.directory.root": "루트",
|
||||
"dialog.directory.parent": "상위 폴더",
|
||||
"dialog.directory.readError": "이 폴더를 읽을 수 없습니다",
|
||||
"dialog.server.title": "서버",
|
||||
"dialog.server.description": "이 앱이 연결할 OpenCode 서버를 전환합니다.",
|
||||
"dialog.server.search.placeholder": "서버 검색",
|
||||
|
||||
@@ -309,6 +309,11 @@ export const dict = {
|
||||
|
||||
"dialog.directory.search.placeholder": "Søk etter mapper",
|
||||
"dialog.directory.empty": "Ingen mapper funnet",
|
||||
"dialog.directory.action.selectFile": "Velg fil",
|
||||
"dialog.directory.action.selectFolder": "Velg mappe",
|
||||
"dialog.directory.root": "Rot",
|
||||
"dialog.directory.parent": "Overordnet",
|
||||
"dialog.directory.readError": "Kan ikke lese denne mappen",
|
||||
|
||||
"dialog.server.title": "Servere",
|
||||
"dialog.server.description": "Bytt hvilken OpenCode-server denne appen kobler til.",
|
||||
|
||||
@@ -282,6 +282,11 @@ export const dict = {
|
||||
"dialog.fork.empty": "Brak wiadomości do rozwidlenia",
|
||||
"dialog.directory.search.placeholder": "Szukaj folderów",
|
||||
"dialog.directory.empty": "Nie znaleziono folderów",
|
||||
"dialog.directory.action.selectFile": "Wybierz plik",
|
||||
"dialog.directory.action.selectFolder": "Wybierz folder",
|
||||
"dialog.directory.root": "Katalog główny",
|
||||
"dialog.directory.parent": "Nadrzędny",
|
||||
"dialog.directory.readError": "Nie można odczytać tego folderu",
|
||||
"dialog.server.title": "Serwery",
|
||||
"dialog.server.description": "Przełącz serwer OpenCode, z którym łączy się ta aplikacja.",
|
||||
"dialog.server.search.placeholder": "Szukaj serwerów",
|
||||
|
||||
@@ -306,6 +306,11 @@ export const dict = {
|
||||
|
||||
"dialog.directory.search.placeholder": "Поиск папок",
|
||||
"dialog.directory.empty": "Папки не найдены",
|
||||
"dialog.directory.action.selectFile": "Выбрать файл",
|
||||
"dialog.directory.action.selectFolder": "Выбрать папку",
|
||||
"dialog.directory.root": "Корень",
|
||||
"dialog.directory.parent": "Родительская папка",
|
||||
"dialog.directory.readError": "Не удалось прочитать эту папку",
|
||||
|
||||
"dialog.server.title": "Серверы",
|
||||
"dialog.server.description": "Переключите сервер OpenCode к которому подключается приложение.",
|
||||
|
||||
@@ -306,6 +306,11 @@ export const dict = {
|
||||
|
||||
"dialog.directory.search.placeholder": "ค้นหาโฟลเดอร์",
|
||||
"dialog.directory.empty": "ไม่พบโฟลเดอร์",
|
||||
"dialog.directory.action.selectFile": "เลือกไฟล์",
|
||||
"dialog.directory.action.selectFolder": "เลือกโฟลเดอร์",
|
||||
"dialog.directory.root": "ราก",
|
||||
"dialog.directory.parent": "โฟลเดอร์หลัก",
|
||||
"dialog.directory.readError": "ไม่สามารถอ่านโฟลเดอร์นี้ได้",
|
||||
|
||||
"dialog.server.title": "เซิร์ฟเวอร์",
|
||||
"dialog.server.description": "สลับเซิร์ฟเวอร์ OpenCode ที่แอปนี้เชื่อมต่อด้วย",
|
||||
|
||||
@@ -311,6 +311,11 @@ export const dict = {
|
||||
|
||||
"dialog.directory.search.placeholder": "Klasör ara",
|
||||
"dialog.directory.empty": "Klasör bulunamadı",
|
||||
"dialog.directory.action.selectFile": "Dosya seç",
|
||||
"dialog.directory.action.selectFolder": "Klasör seç",
|
||||
"dialog.directory.root": "Kök",
|
||||
"dialog.directory.parent": "Üst klasör",
|
||||
"dialog.directory.readError": "Bu klasör okunamıyor",
|
||||
|
||||
"dialog.server.title": "Sunucular",
|
||||
"dialog.server.description": "Bu uygulamanın hangi OpenCode sunucusuna bağlanacağını değiştirin.",
|
||||
|
||||
@@ -313,6 +313,11 @@ export const dict = {
|
||||
|
||||
"dialog.directory.search.placeholder": "Пошук папок",
|
||||
"dialog.directory.empty": "Папок не знайдено",
|
||||
"dialog.directory.action.selectFile": "Вибрати файл",
|
||||
"dialog.directory.action.selectFolder": "Вибрати папку",
|
||||
"dialog.directory.root": "Корінь",
|
||||
"dialog.directory.parent": "Батьківська папка",
|
||||
"dialog.directory.readError": "Не вдалося прочитати цю папку",
|
||||
|
||||
"app.server.unreachable": "Не вдалося досягти {{server}}",
|
||||
"app.server.retrying": "Автоматичне повторення...",
|
||||
@@ -759,16 +764,15 @@ export const dict = {
|
||||
"settings.general.row.followup.option.queue": "Черга",
|
||||
"settings.general.row.followup.option.steer": "Керування",
|
||||
"settings.general.row.showFileTree.title": "Дерево файлів",
|
||||
"settings.general.row.showFileTree.description": "Показувати панель дерева файлів у сесіях на робочому столі",
|
||||
"settings.general.row.showFileTree.description": "Показувати панель дерева файлів у сесіях",
|
||||
"settings.general.row.showNavigation.title": "Елементи навігації",
|
||||
"settings.general.row.showNavigation.description": "Показувати кнопки назад і вперед у заголовку робочого столу",
|
||||
"settings.general.row.showSearch.title": "Палітра команд",
|
||||
"settings.general.row.showSearch.description":
|
||||
"Показувати кнопку пошуку та палітри команд у заголовку робочого столу",
|
||||
"settings.general.row.showSearch.description": "Показувати кнопку пошуку та палітри команд у заголовку",
|
||||
"settings.general.row.showTerminal.title": "Термінал",
|
||||
"settings.general.row.showTerminal.description": "Показувати кнопку термінала в заголовку робочого столу",
|
||||
"settings.general.row.showStatus.title": "Статус сервера",
|
||||
"settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку робочого столу",
|
||||
"settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку",
|
||||
"settings.general.row.reasoningSummaries.title": "Показувати підсумки мислення",
|
||||
"settings.general.row.reasoningSummaries.description": "Відображати підсумки мислення моделі на часовій шкалі",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Розгортати частини інструменту оболонки",
|
||||
|
||||
@@ -326,6 +326,11 @@ export const dict = {
|
||||
|
||||
"dialog.directory.search.placeholder": "搜索文件夹",
|
||||
"dialog.directory.empty": "未找到文件夹",
|
||||
"dialog.directory.action.selectFile": "选择文件",
|
||||
"dialog.directory.action.selectFolder": "选择文件夹",
|
||||
"dialog.directory.root": "根目录",
|
||||
"dialog.directory.parent": "上级目录",
|
||||
"dialog.directory.readError": "无法读取此文件夹",
|
||||
|
||||
"dialog.server.title": "服务器",
|
||||
"dialog.server.description": "切换此应用连接的 OpenCode 服务器。",
|
||||
|
||||
@@ -306,6 +306,11 @@ export const dict = {
|
||||
|
||||
"dialog.directory.search.placeholder": "搜尋資料夾",
|
||||
"dialog.directory.empty": "找不到資料夾",
|
||||
"dialog.directory.action.selectFile": "選擇檔案",
|
||||
"dialog.directory.action.selectFolder": "選擇資料夾",
|
||||
"dialog.directory.root": "根目錄",
|
||||
"dialog.directory.parent": "上層目錄",
|
||||
"dialog.directory.readError": "無法讀取此資料夾",
|
||||
|
||||
"dialog.server.title": "伺服器",
|
||||
"dialog.server.description": "切換此應用程式連線的 OpenCode 伺服器。",
|
||||
|
||||
@@ -20,7 +20,7 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
|
||||
createEffect(() => {
|
||||
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
|
||||
if (props.draftID) return
|
||||
const next = sync.data.path.directory
|
||||
const next = sync().data.path.directory
|
||||
if (!next || next === props.directory) return
|
||||
const path = location.pathname.slice(slug().length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
@@ -28,12 +28,15 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
|
||||
|
||||
createResource(
|
||||
() => params.id,
|
||||
(id) => sync.session.sync(id).catch(() => {}),
|
||||
(id) =>
|
||||
sync()
|
||||
.session.sync(id)
|
||||
.catch(() => {}),
|
||||
)
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync.data}
|
||||
data={sync().data}
|
||||
directory={props.directory}
|
||||
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
|
||||
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { DialogSelectServer, useServerManagementController } from "@/components/
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import {
|
||||
@@ -80,7 +80,7 @@ const HOME_SEARCH_RESULT_META =
|
||||
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
|
||||
|
||||
function buildHomeSessionRecords(input: {
|
||||
sync: Pick<ReturnType<typeof useServerSync>, "child">
|
||||
sync: Pick<ServerSync, "child">
|
||||
projectDirectories: () => string[]
|
||||
projects: () => LocalProject[]
|
||||
projectByID: () => Map<string, LocalProject>
|
||||
@@ -149,7 +149,7 @@ function HomeDesign() {
|
||||
if (!conn) return
|
||||
return global.createServerCtx(conn)
|
||||
})
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory))
|
||||
const newSessionProject = createMemo(
|
||||
@@ -1102,10 +1102,10 @@ function LegacyHome() {
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
const homedir = createMemo(() => sync().data.path.home)
|
||||
const recent = createMemo(() => {
|
||||
return sync.data.project
|
||||
.slice()
|
||||
return sync()
|
||||
.data.project.slice()
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.slice(0, 5)
|
||||
})
|
||||
@@ -1164,7 +1164,7 @@ function LegacyHome() {
|
||||
{server.name}
|
||||
</Button>
|
||||
<Switch>
|
||||
<Match when={sync.data.project.length > 0}>
|
||||
<Match when={sync().data.project.length > 0}>
|
||||
<div class="mt-20 w-full flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center justify-between pl-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
|
||||
@@ -1191,7 +1191,7 @@ function LegacyHome() {
|
||||
</ul>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={!sync.ready}>
|
||||
<Match when={!sync().ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" onClick={chooseProject}>
|
||||
|
||||
@@ -95,7 +95,7 @@ import { SidebarContent } from "./layout/sidebar-shell"
|
||||
export default function Layout(props: ParentProps) {
|
||||
const serverSDK = useServerSDK()
|
||||
const [store, setStore, , ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK.scope, "layout.page", ["layout.page.v1"]),
|
||||
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
|
||||
createStore({
|
||||
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
|
||||
activeProject: undefined as string | undefined,
|
||||
@@ -140,7 +140,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (!slug) return { slug, dir: "" }
|
||||
const dir = decode64(slug)
|
||||
if (!dir) return { slug, dir: "" }
|
||||
const store = serverSync.peek(dir, { bootstrap: false })
|
||||
const store = serverSync().peek(dir, { bootstrap: false })
|
||||
return {
|
||||
slug,
|
||||
store,
|
||||
@@ -213,7 +213,7 @@ export default function Layout(props: ParentProps) {
|
||||
active: () => state.hoverProject,
|
||||
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
|
||||
onActivate: (directory) => {
|
||||
serverSync.child(directory)
|
||||
serverSync().child(directory)
|
||||
setState("hoverProject", directory)
|
||||
},
|
||||
})
|
||||
@@ -397,17 +397,17 @@ export default function Layout(props: ParentProps) {
|
||||
alertedAtBySession.delete(sessionKey)
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const unsub = serverSDK().event.listen((e) => {
|
||||
if (e.details?.type === "worktree.ready") {
|
||||
setBusy(e.name, false)
|
||||
WorktreeState.ready(serverSDK.scope, e.name)
|
||||
WorktreeState.ready(serverSDK().scope, e.name)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.details?.type === "worktree.failed") {
|
||||
setBusy(e.name, false)
|
||||
WorktreeState.failed(
|
||||
serverSDK.scope,
|
||||
serverSDK().scope,
|
||||
e.name,
|
||||
e.details.properties?.message ?? language.t("common.requestFailed"),
|
||||
)
|
||||
@@ -435,7 +435,7 @@ export default function Layout(props: ParentProps) {
|
||||
const props = e.details.properties
|
||||
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
|
||||
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
const session = store.session.find((s) => s.id === props.sessionID)
|
||||
const sessionKey = `${directory}:${props.sessionID}`
|
||||
|
||||
@@ -498,7 +498,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (!currentDir() || !currentSession) return
|
||||
const sessionKey = `${currentDir()}:${currentSession}`
|
||||
dismissSessionAlert(sessionKey)
|
||||
const [store] = serverSync.child(currentDir(), { bootstrap: false })
|
||||
const [store] = serverSync().child(currentDir(), { bootstrap: false })
|
||||
const childSessions = store.session.filter((s) => s.parentID === currentSession)
|
||||
for (const child of childSessions) {
|
||||
dismissSessionAlert(`${currentDir()}:${child.id}`)
|
||||
@@ -536,11 +536,11 @@ export default function Layout(props: ParentProps) {
|
||||
const direct = projects.find((p) => pathKey(p.worktree) === key)
|
||||
if (direct) return direct
|
||||
|
||||
const [child] = serverSync.child(directory, { bootstrap: false })
|
||||
const [child] = serverSync().child(directory, { bootstrap: false })
|
||||
const id = child.project
|
||||
if (!id) return
|
||||
|
||||
const meta = serverSync.data.project.find((p) => p.id === id)
|
||||
const meta = serverSync().data.project.find((p) => p.id === id)
|
||||
const root = meta?.worktree
|
||||
if (!root) return
|
||||
|
||||
@@ -631,7 +631,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const result: Session[] = []
|
||||
for (const dir of dirs) {
|
||||
const [dirStore] = serverSync.child(dir, { bootstrap: true })
|
||||
const [dirStore] = serverSync().child(dir, { bootstrap: true })
|
||||
const dirSessions = sortedRootSessions(dirStore, now)
|
||||
result.push(...dirSessions)
|
||||
}
|
||||
@@ -683,10 +683,10 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
createEffect(() => {
|
||||
route()
|
||||
serverSDK.url
|
||||
serverSDK().url
|
||||
|
||||
prefetchToken.value += 1
|
||||
clearSessionPrefetchInflight(serverSDK.scope)
|
||||
clearSessionPrefetchInflight(serverSDK().scope)
|
||||
prefetchQueues.clear()
|
||||
})
|
||||
|
||||
@@ -730,17 +730,17 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
||||
const [store, setStore] = serverSync.child(directory, { bootstrap: false })
|
||||
const [store, setStore] = serverSync().child(directory, { bootstrap: false })
|
||||
|
||||
return runSessionPrefetch({
|
||||
scope: serverSDK.scope,
|
||||
scope: serverSDK().scope,
|
||||
directory,
|
||||
sessionID,
|
||||
task: (rev) =>
|
||||
retry(() => serverSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||
retry(() => serverSDK().client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||
.then((messages) => {
|
||||
if (prefetchToken.value !== token) return
|
||||
if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
|
||||
if (!isSessionPrefetchCurrent(serverSDK().scope, directory, sessionID, rev)) return
|
||||
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||
@@ -755,9 +755,9 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
if (stale.length > 0) {
|
||||
clearSessionPrefetch(serverSDK.scope, directory, stale)
|
||||
clearSessionPrefetch(serverSDK().scope, directory, stale)
|
||||
for (const id of stale) {
|
||||
serverSync.todo.set(id, undefined)
|
||||
serverSync().todo.set(id, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,7 +767,7 @@ export default function Layout(props: ParentProps) {
|
||||
sorted,
|
||||
)
|
||||
|
||||
if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
|
||||
if (!isSessionPrefetchCurrent(serverSDK().scope, directory, sessionID, rev)) return
|
||||
|
||||
batch(() => {
|
||||
if (stale.length > 0) {
|
||||
@@ -779,7 +779,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
setStore("message", sessionID, reconcile(merged, { key: "id" }))
|
||||
setSessionPrefetch({ scope: serverSDK.scope, directory, sessionID, ...meta })
|
||||
setSessionPrefetch({ scope: serverSDK().scope, directory, sessionID, ...meta })
|
||||
|
||||
for (const message of items) {
|
||||
const currentParts = store.part[message.info.id] ?? []
|
||||
@@ -822,9 +822,9 @@ export default function Layout(props: ParentProps) {
|
||||
const directory = session.directory
|
||||
if (!directory) return
|
||||
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
const cached = untrack(() => {
|
||||
const info = getSessionPrefetch(serverSDK.scope, directory, session.id)
|
||||
const info = getSessionPrefetch(serverSDK().scope, directory, session.id)
|
||||
return shouldSkipSessionPrefetch({
|
||||
message: store.message[session.id] !== undefined,
|
||||
info,
|
||||
@@ -927,7 +927,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (!target) return
|
||||
|
||||
// warm up child store to prevent flicker
|
||||
serverSync.child(target.worktree)
|
||||
serverSync().child(target.worktree)
|
||||
void openProject(target.worktree)
|
||||
}
|
||||
|
||||
@@ -936,7 +936,7 @@ export default function Layout(props: ParentProps) {
|
||||
const target = projects[index]
|
||||
if (!target) return
|
||||
|
||||
serverSync.child(target.worktree)
|
||||
serverSync().child(target.worktree)
|
||||
void openProject(target.worktree)
|
||||
}
|
||||
|
||||
@@ -965,12 +965,12 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
const [store, setStore] = serverSync.child(session.directory)
|
||||
const [store, setStore] = serverSync().child(session.directory)
|
||||
const sessions = store.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === session.id)
|
||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||
|
||||
await serverSDK.client.session.update({
|
||||
await serverSDK().client.session.update({
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
time: { archived: Date.now() },
|
||||
@@ -996,7 +996,7 @@ export default function Layout(props: ParentProps) {
|
||||
id: "sidebar.toggle",
|
||||
title: language.t("command.sidebar.toggle"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "mod+b",
|
||||
keybind: newDesign() ? undefined : "mod+b",
|
||||
onSelect: () => layout.sidebar.toggle(),
|
||||
},
|
||||
{
|
||||
@@ -1241,11 +1241,11 @@ export default function Layout(props: ParentProps) {
|
||||
)
|
||||
if (known) return known[0]
|
||||
|
||||
const [child] = serverSync.child(directory, { bootstrap: false })
|
||||
const [child] = serverSync().child(directory, { bootstrap: false })
|
||||
const id = child.project
|
||||
if (!id) return directory
|
||||
|
||||
const meta = serverSync.data.project.find((item) => item.id === id)
|
||||
const meta = serverSync().data.project.find((item) => item.id === id)
|
||||
return meta?.worktree ?? directory
|
||||
}
|
||||
|
||||
@@ -1293,8 +1293,8 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
const refreshDirs = async (target?: string) => {
|
||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||
const listed = await serverSDK.client.worktree
|
||||
.list({ directory: root })
|
||||
const listed = await serverSDK()
|
||||
.client.worktree.list({ directory: root })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [] as string[])
|
||||
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
||||
@@ -1302,14 +1302,14 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
const openSession = async (target: { directory: string; id: string }) => {
|
||||
if (!canOpen(target.directory)) return false
|
||||
const [data] = serverSync.child(target.directory, { bootstrap: false })
|
||||
const [data] = serverSync().child(target.directory, { bootstrap: false })
|
||||
if (data.session.some((item) => item.id === target.id)) {
|
||||
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
|
||||
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
|
||||
return true
|
||||
}
|
||||
const resolved = await serverSDK.client.session
|
||||
.get({ sessionID: target.id })
|
||||
const resolved = await serverSDK()
|
||||
.client.session.get({ sessionID: target.id })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!resolved?.directory) return false
|
||||
@@ -1328,7 +1328,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
const latest = latestRootSession(
|
||||
dirs.map((item) => serverSync.child(item, { bootstrap: false })[0]),
|
||||
dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]),
|
||||
Date.now(),
|
||||
)
|
||||
if (latest && (await openSession(latest))) {
|
||||
@@ -1339,8 +1339,8 @@ export default function Layout(props: ParentProps) {
|
||||
await Promise.all(
|
||||
dirs.map(async (item) => ({
|
||||
path: { directory: item },
|
||||
session: await serverSDK.client.session
|
||||
.list({ directory: item })
|
||||
session: await serverSDK()
|
||||
.client.session.list({ directory: item })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
})),
|
||||
@@ -1402,11 +1402,11 @@ export default function Layout(props: ParentProps) {
|
||||
const name = next === getFilename(project.worktree) ? "" : next
|
||||
|
||||
if (project.id && project.id !== "global") {
|
||||
await serverSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
|
||||
await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
|
||||
return
|
||||
}
|
||||
|
||||
serverSync.project.meta(project.worktree, { name })
|
||||
serverSync().project.meta(project.worktree, { name })
|
||||
}
|
||||
|
||||
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||
@@ -1495,8 +1495,8 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
setBusy(directory, true)
|
||||
|
||||
const result = await serverSDK.client.worktree
|
||||
.remove({ directory: root, worktreeRemoveInput: { directory } })
|
||||
const result = await serverSDK()
|
||||
.client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -1514,7 +1514,7 @@ export default function Layout(props: ParentProps) {
|
||||
clearLastProjectSession(root)
|
||||
}
|
||||
|
||||
serverSync.set(
|
||||
serverSync().set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
const project = draft.find((item) => item.worktree === root)
|
||||
@@ -1553,8 +1553,8 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
const dismiss = () => toaster.dismiss(progress)
|
||||
|
||||
const sessions: Session[] = await serverSDK.client.session
|
||||
.list({ directory })
|
||||
const sessions: Session[] = await serverSDK()
|
||||
.client.session.list({ directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
@@ -1562,12 +1562,14 @@ export default function Layout(props: ParentProps) {
|
||||
directory,
|
||||
sessions.map((s) => s.id),
|
||||
platform,
|
||||
serverSDK.scope,
|
||||
serverSDK().scope,
|
||||
)
|
||||
await serverSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||
await serverSDK()
|
||||
.client.instance.dispose({ directory })
|
||||
.catch(() => undefined)
|
||||
|
||||
const result = await serverSDK.client.worktree
|
||||
.reset({ directory: root, worktreeResetInput: { directory } })
|
||||
const result = await serverSDK()
|
||||
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -1588,8 +1590,8 @@ export default function Layout(props: ParentProps) {
|
||||
sessions
|
||||
.filter((session) => session.time.archived === undefined)
|
||||
.map((session) =>
|
||||
serverSDK.client.session
|
||||
.update({
|
||||
serverSDK()
|
||||
.client.session.update({
|
||||
sessionID: session.id,
|
||||
directory: session.directory,
|
||||
time: { archived: archivedAt },
|
||||
@@ -1629,8 +1631,8 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
serverSDK.client.vcs
|
||||
.status({ directory: props.directory })
|
||||
serverSDK()
|
||||
.client.vcs.status({ directory: props.directory })
|
||||
.then((x) => {
|
||||
const files = x.data ?? []
|
||||
const dirty = files.length > 0
|
||||
@@ -1688,8 +1690,8 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
const sessions = await serverSDK.client.session
|
||||
.list({ directory: props.directory })
|
||||
const sessions = await serverSDK()
|
||||
.client.session.list({ directory: props.directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
const active = sessions.filter((session) => session.time.archived === undefined)
|
||||
@@ -1697,8 +1699,8 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
serverSDK.client.vcs
|
||||
.status({ directory: props.directory })
|
||||
serverSDK()
|
||||
.client.vcs.status({ directory: props.directory })
|
||||
.then((x) => {
|
||||
const files = x.data ?? []
|
||||
const dirty = files.length > 0
|
||||
@@ -1831,7 +1833,7 @@ export default function Layout(props: ParentProps) {
|
||||
const next = new Set(dirs)
|
||||
for (const directory of next) {
|
||||
if (loadedSessionDirs.has(directory)) continue
|
||||
void serverSync.project.loadSessions(directory)
|
||||
void serverSync().project.loadSessions(directory)
|
||||
}
|
||||
|
||||
loadedSessionDirs.clear()
|
||||
@@ -1876,7 +1878,7 @@ export default function Layout(props: ParentProps) {
|
||||
directory && pathKey(directory) !== pathKey(local) && !dirs.some((item) => pathKey(item) === pathKey(directory))
|
||||
? directory
|
||||
: undefined
|
||||
const pending = extra ? WorktreeState.get(serverSDK.scope, extra)?.status === "pending" : false
|
||||
const pending = extra ? WorktreeState.get(serverSDK().scope, extra)?.status === "pending" : false
|
||||
|
||||
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
|
||||
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
|
||||
@@ -1928,8 +1930,8 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const createWorkspace = async (project: LocalProject) => {
|
||||
clearSidebarHoverState()
|
||||
const created = await serverSDK.client.worktree
|
||||
.create({ directory: project.worktree })
|
||||
const created = await serverSDK()
|
||||
.client.worktree.create({ directory: project.worktree })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -1948,7 +1950,7 @@ export default function Layout(props: ParentProps) {
|
||||
const root = pathKey(local)
|
||||
|
||||
setBusy(created.directory, true)
|
||||
WorktreeState.pending(serverSDK.scope, created.directory)
|
||||
WorktreeState.pending(serverSDK().scope, created.directory)
|
||||
setStore("workspaceExpanded", key, true)
|
||||
if (key !== created.directory) {
|
||||
setStore("workspaceExpanded", created.directory, true)
|
||||
@@ -1962,7 +1964,7 @@ export default function Layout(props: ParentProps) {
|
||||
return [created.directory, ...next]
|
||||
})
|
||||
|
||||
serverSync.child(created.directory)
|
||||
serverSync().child(created.directory)
|
||||
navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
|
||||
}
|
||||
|
||||
@@ -2067,7 +2069,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (!item) return false
|
||||
return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
|
||||
})
|
||||
const homedir = createMemo(() => serverSync.data.path.home)
|
||||
const homedir = createMemo(() => serverSync().data.path.home)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -2364,7 +2366,7 @@ export default function Layout(props: ParentProps) {
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
<HelpButton />
|
||||
<ToastRegion v2={newDesign()} />
|
||||
</div>
|
||||
@@ -2517,7 +2519,7 @@ export default function Layout(props: ParentProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
</div>
|
||||
<HelpButton />
|
||||
<ToastRegion v2={newDesign()} />
|
||||
|
||||
@@ -14,7 +14,7 @@ export function useSessionTabAvatarState(
|
||||
const permission = usePermission()
|
||||
const hasPermissions = createMemo(() => {
|
||||
if (!active()) return false
|
||||
const [store] = globalSync.child(directory(), { bootstrap: false })
|
||||
const [store] = globalSync().child(directory(), { bootstrap: false })
|
||||
return !!sessionPermissionRequest(store.session, store.permission, sessionId(), (item) => {
|
||||
return !permission.autoResponds(item, directory())
|
||||
})
|
||||
@@ -23,7 +23,7 @@ export function useSessionTabAvatarState(
|
||||
const loading = createMemo(() => {
|
||||
if (!active()) return false
|
||||
if (hasPermissions()) return false
|
||||
const [store] = globalSync.child(directory(), { bootstrap: false })
|
||||
const [store] = globalSync().child(directory(), { bootstrap: false })
|
||||
return store.session_working(sessionId())
|
||||
})
|
||||
return { unread, loading }
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ProjectIcon = (props: {
|
||||
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
|
||||
const hasPermissions = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory))
|
||||
}),
|
||||
)
|
||||
@@ -149,7 +149,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
const serverSync = useServerSync()
|
||||
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
|
||||
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
|
||||
const [sessionStore] = serverSync.child(props.session.directory)
|
||||
const [sessionStore] = serverSync().child(props.session.directory)
|
||||
const hasPermissions = createMemo(() => {
|
||||
return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => {
|
||||
return !permission.autoResponds(item, props.session.directory)
|
||||
|
||||
@@ -294,23 +294,23 @@ export const SortableProject = (props: {
|
||||
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
||||
|
||||
const label = (directory: string) => {
|
||||
const [data] = serverSync.child(directory, { bootstrap: false })
|
||||
const [data] = serverSync().child(directory, { bootstrap: false })
|
||||
const kind =
|
||||
directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
||||
const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
|
||||
return `${kind} : ${name}`
|
||||
}
|
||||
|
||||
const projectStore = createMemo(() => serverSync.child(props.project.worktree, { bootstrap: false })[0])
|
||||
const projectStore = createMemo(() => serverSync().child(props.project.worktree, { bootstrap: false })[0])
|
||||
const isWorking = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
return Object.keys(store.session_status).some((id) => store.session_working(id))
|
||||
}),
|
||||
)
|
||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
||||
const workspaceSessions = (directory: string) => {
|
||||
const [data] = serverSync.child(directory, { bootstrap: false })
|
||||
const [data] = serverSync().child(directory, { bootstrap: false })
|
||||
return sortedRootSessions(data, props.sortNow())
|
||||
}
|
||||
const tile = () => (
|
||||
|
||||
@@ -68,7 +68,7 @@ export const WorkspaceDragOverlay = (props: {
|
||||
const directory = props.activeWorkspace()
|
||||
if (!directory) return
|
||||
|
||||
const [workspaceStore] = serverSync.child(directory, { bootstrap: false })
|
||||
const [workspaceStore] = serverSync().child(directory, { bootstrap: false })
|
||||
const kind =
|
||||
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
||||
const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
|
||||
@@ -303,7 +303,7 @@ export const SortableWorkspace = (props: {
|
||||
const queryOptions = useQueryOptions()
|
||||
const language = useLanguage()
|
||||
const sortable = createSortable(props.directory)
|
||||
const [workspaceStore, setWorkspaceStore] = serverSync.child(props.directory, { bootstrap: false })
|
||||
const [workspaceStore, setWorkspaceStore] = serverSync().child(props.directory, { bootstrap: false })
|
||||
const [menu, setMenu] = createStore({
|
||||
open: false,
|
||||
pendingRename: false,
|
||||
@@ -321,14 +321,14 @@ export const SortableWorkspace = (props: {
|
||||
const boot = createMemo(() => open() || active())
|
||||
const count = createMemo(() => sessions()?.length ?? 0)
|
||||
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
|
||||
const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.directory)))
|
||||
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.directory)))
|
||||
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
||||
const loading = () => fetching() > 0 && count() === 0
|
||||
const touch = createMediaQuery("(hover: none)")
|
||||
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
||||
const loadMore = async () => {
|
||||
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
||||
await serverSync.project.loadSessions(props.directory)
|
||||
await serverSync().project.loadSessions(props.directory)
|
||||
}
|
||||
|
||||
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
|
||||
@@ -357,7 +357,7 @@ export const SortableWorkspace = (props: {
|
||||
|
||||
createEffect(() => {
|
||||
if (!boot()) return
|
||||
serverSync.child(props.directory, { bootstrap: true })
|
||||
serverSync().child(props.directory, { bootstrap: true })
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -450,18 +450,18 @@ export const LocalWorkspace = (props: {
|
||||
const queryOptions = useQueryOptions()
|
||||
const language = useLanguage()
|
||||
const workspace = createMemo(() => {
|
||||
const [store, setStore] = serverSync.child(props.project.worktree)
|
||||
const [store, setStore] = serverSync().child(props.project.worktree)
|
||||
return { store, setStore }
|
||||
})
|
||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
||||
const count = createMemo(() => sessions()?.length ?? 0)
|
||||
const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.project.worktree)))
|
||||
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.project.worktree)))
|
||||
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
|
||||
const loading = () => fetching() > 0 && count() === 0
|
||||
const loadMore = async () => {
|
||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||
await serverSync.project.loadSessions(props.project.worktree)
|
||||
await serverSync().project.loadSessions(props.project.worktree)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,8 +30,8 @@ export default function NewSessionPage() {
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.worktree === "create") return "create"
|
||||
const project = sync.project
|
||||
if (project && sdk.directory !== project.worktree) return sdk.directory
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
return "main"
|
||||
})
|
||||
|
||||
|
||||
+142
-280
@@ -13,7 +13,6 @@ import {
|
||||
on,
|
||||
onMount,
|
||||
untrack,
|
||||
createResource,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
@@ -33,7 +32,6 @@ import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
@@ -54,7 +52,8 @@ import {
|
||||
shouldFocusTerminalOnKeyDown,
|
||||
shouldShowFileTree,
|
||||
} from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
|
||||
import { createTimelineModel } from "@/pages/session/timeline/model"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { useServer } from "@/context/server"
|
||||
@@ -67,11 +66,9 @@ import { Identifier } from "@/utils/id"
|
||||
import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { same } from "@/utils/same"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
type FollowupItem = FollowupDraft & { id: string }
|
||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||
const emptyFollowups: FollowupItem[] = []
|
||||
@@ -79,110 +76,6 @@ const emptyFollowups: FollowupItem[] = []
|
||||
type ChangeMode = "git" | "branch" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
loaded: () => number
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
historyMore: () => boolean
|
||||
historyLoading: () => boolean
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
userScrolled: () => boolean
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
}
|
||||
|
||||
function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
|
||||
const historyScrollThreshold = 200
|
||||
let shiftFrame: number | undefined
|
||||
|
||||
const [state, setState] = createStore({
|
||||
shift: false,
|
||||
})
|
||||
|
||||
const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
|
||||
equals: same,
|
||||
})
|
||||
|
||||
const cancelShiftReset = () => {
|
||||
if (shiftFrame === undefined) return
|
||||
cancelAnimationFrame(shiftFrame)
|
||||
shiftFrame = undefined
|
||||
}
|
||||
|
||||
const scheduleShiftReset = () => {
|
||||
cancelShiftReset()
|
||||
shiftFrame = requestAnimationFrame(() => {
|
||||
shiftFrame = undefined
|
||||
setState("shift", false)
|
||||
})
|
||||
}
|
||||
|
||||
const fetchOlderMessages = async () => {
|
||||
const id = input.sessionID()
|
||||
if (!id) return
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
// TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
|
||||
const beforeVisible = input.visibleUserMessages().length
|
||||
let loaded = input.loaded()
|
||||
let growth = 0
|
||||
|
||||
cancelShiftReset()
|
||||
setState("shift", true)
|
||||
|
||||
while (true) {
|
||||
await input.loadMore(id)
|
||||
if (input.sessionID() !== id) return
|
||||
|
||||
const nextLoaded = input.loaded()
|
||||
const raw = nextLoaded - loaded
|
||||
loaded = nextLoaded
|
||||
growth = input.visibleUserMessages().length - beforeVisible
|
||||
|
||||
if (growth > 0) break
|
||||
if (raw <= 0) break
|
||||
if (!input.historyMore()) break
|
||||
}
|
||||
|
||||
if (growth > 0) {
|
||||
scheduleShiftReset()
|
||||
return
|
||||
}
|
||||
|
||||
setState("shift", false)
|
||||
}
|
||||
|
||||
const loadAndReveal = () => fetchOlderMessages()
|
||||
|
||||
const onScrollerScroll = () => {
|
||||
if (!input.userScrolled()) return
|
||||
const el = input.scroller()
|
||||
if (!el) return
|
||||
if (el.scrollTop >= historyScrollThreshold) return
|
||||
|
||||
void fetchOlderMessages()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
input.sessionID,
|
||||
() => {
|
||||
cancelShiftReset()
|
||||
setState({ shift: false })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(cancelShiftReset)
|
||||
|
||||
return {
|
||||
userMessages,
|
||||
shift: () => state.shift,
|
||||
loadAndReveal,
|
||||
onScrollerScroll,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const serverSync = useServerSync()
|
||||
const layout = useLayout()
|
||||
@@ -275,8 +168,7 @@ export default function Page() {
|
||||
() =>
|
||||
isDesktop() &&
|
||||
shouldShowFileTree({
|
||||
desktopV2: platform.platform === "desktop" && settings.general.newLayoutDesigns(),
|
||||
showFileTree: settings.general.showFileTree(),
|
||||
visible: settings.visibility.fileTree(),
|
||||
opened: layout.fileTree.opened(),
|
||||
}),
|
||||
)
|
||||
@@ -309,10 +201,10 @@ export default function Page() {
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
}
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
|
||||
const canReview = createMemo(() => !!sync.project)
|
||||
const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : []))
|
||||
const canReview = createMemo(() => !!sync().project)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
@@ -324,39 +216,15 @@ export default function Page() {
|
||||
const activeTab = tabState.activeTab
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const messagesReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
return sync.data.message[id] !== undefined
|
||||
})
|
||||
const historyMore = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return sync.session.history.more(id)
|
||||
})
|
||||
const historyLoading = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return sync.session.history.loading(id)
|
||||
})
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
const visibleUserMessages = createMemo(
|
||||
() => {
|
||||
const revert = revertMessageID()
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{
|
||||
equals: same,
|
||||
},
|
||||
)
|
||||
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
|
||||
const timeline = createTimelineModel({ sessionID: () => params.id, revertMessageID })
|
||||
const historyLoading = timeline.history.loading
|
||||
const historyMore = timeline.history.more
|
||||
const lastUserMessage = timeline.lastUserMessage
|
||||
const messages = timeline.messages
|
||||
const messagesReady = timeline.ready
|
||||
const sessionSync = timeline.resource
|
||||
const userMessages = timeline.userMessages
|
||||
const visibleUserMessages = timeline.visibleUserMessages
|
||||
|
||||
createEffect(() => {
|
||||
const tab = activeFileTab()
|
||||
@@ -398,7 +266,7 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const [followup, setFollowup] = persisted(
|
||||
Persist.serverWorkspace(serverSDK.scope, sdk.directory, "followup", ["followup.v1"]),
|
||||
Persist.serverWorkspace(serverSDK().scope, sdk().directory, "followup", ["followup.v1"]),
|
||||
createStore<{
|
||||
items: Record<string, FollowupItem[] | undefined>
|
||||
failed: Record<string, string | undefined>
|
||||
@@ -424,8 +292,6 @@ export default function Page() {
|
||||
}, sessionKey())
|
||||
|
||||
let reviewFrame: number | undefined
|
||||
let refreshFrame: number | undefined
|
||||
let refreshTimer: number | undefined
|
||||
let todoFrame: number | undefined
|
||||
let todoTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
@@ -445,16 +311,16 @@ export default function Page() {
|
||||
}, desktopReviewOpen())
|
||||
|
||||
const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
|
||||
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
|
||||
const nogit = createMemo(() => {
|
||||
const project = sync().project
|
||||
return !!project && project.vcs !== "git"
|
||||
})
|
||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
if (sync.project?.vcs === "git") list.push("git")
|
||||
if (
|
||||
sync.project?.vcs === "git" &&
|
||||
sync.data.vcs?.branch &&
|
||||
sync.data.vcs?.default_branch &&
|
||||
sync.data.vcs.branch !== sync.data.vcs.default_branch
|
||||
) {
|
||||
const project = sync().project
|
||||
const vcs = sync().data.vcs
|
||||
if (project?.vcs === "git") list.push("git")
|
||||
if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) {
|
||||
list.push("branch")
|
||||
}
|
||||
list.push("turn")
|
||||
@@ -470,19 +336,20 @@ export default function Page() {
|
||||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||
})
|
||||
const vcsKey = createMemo(
|
||||
() => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const,
|
||||
() =>
|
||||
["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const,
|
||||
)
|
||||
const vcsQuery = createQuery(() => {
|
||||
const mode = vcsMode()
|
||||
const enabled = wantsReview() && sync.project?.vcs === "git"
|
||||
const enabled = wantsReview() && sync().project?.vcs === "git"
|
||||
|
||||
return {
|
||||
queryKey: [...vcsKey(), mode] as const,
|
||||
enabled,
|
||||
queryFn: mode
|
||||
? () =>
|
||||
sdk.client.vcs
|
||||
.diff({ mode })
|
||||
sdk()
|
||||
.client.vcs.diff({ mode })
|
||||
.then((result) => list(result.data))
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
@@ -507,8 +374,8 @@ export default function Page() {
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
const project = sync.project
|
||||
if (project && sdk.directory !== project.worktree) return sdk.directory
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
return "main"
|
||||
})
|
||||
|
||||
@@ -570,11 +437,11 @@ export default function Page() {
|
||||
}
|
||||
|
||||
function upsert(next: Project) {
|
||||
const list = serverSync.data.project
|
||||
sync.set("project", next.id)
|
||||
const list = serverSync().data.project
|
||||
sync().set("project", next.id)
|
||||
const idx = list.findIndex((item) => item.id === next.id)
|
||||
if (idx >= 0) {
|
||||
serverSync.set(
|
||||
serverSync().set(
|
||||
"project",
|
||||
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
|
||||
)
|
||||
@@ -582,14 +449,14 @@ export default function Page() {
|
||||
}
|
||||
const at = list.findIndex((item) => item.id > next.id)
|
||||
if (at >= 0) {
|
||||
serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
|
||||
serverSync().set("project", [...list.slice(0, at), next, ...list.slice(at)])
|
||||
return
|
||||
}
|
||||
serverSync.set("project", [...list, next])
|
||||
serverSync().set("project", [...list, next])
|
||||
}
|
||||
|
||||
const gitMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk.client.project.initGit(),
|
||||
mutationFn: () => sdk().client.project.initGit(),
|
||||
onSuccess: (x) => {
|
||||
if (!x.data) return
|
||||
upsert(x.data)
|
||||
@@ -614,6 +481,7 @@ export default function Page() {
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
let revealMessage = (_id: string) => {}
|
||||
let scrollToEnd = () => {}
|
||||
let scrollMark = 0
|
||||
let messageMark = 0
|
||||
|
||||
@@ -632,47 +500,14 @@ export default function Page() {
|
||||
|
||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||
|
||||
const [sessionSync] = createResource(
|
||||
() => [sdk.directory, params.id] as const,
|
||||
([directory, id]) => {
|
||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||
refreshFrame = undefined
|
||||
refreshTimer = undefined
|
||||
if (!id) return
|
||||
|
||||
const cached = untrack(() => sync.data.message[id] !== undefined)
|
||||
const stale = !cached
|
||||
? false
|
||||
: (() => {
|
||||
const info = getSessionPrefetch(serverSDK.scope, directory, id)
|
||||
if (!info) return true
|
||||
return Date.now() - info.at > SESSION_PREFETCH_TTL
|
||||
})()
|
||||
|
||||
refreshFrame = requestAnimationFrame(() => {
|
||||
refreshFrame = undefined
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = undefined
|
||||
if (params.id !== id) return
|
||||
untrack(() => {
|
||||
if (stale) void sync.session.sync(id, { force: true })
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
|
||||
return sync.session.sync(id)
|
||||
},
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
const id = params.id
|
||||
return [
|
||||
sdk.directory,
|
||||
sdk().directory,
|
||||
id,
|
||||
id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
|
||||
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
||||
id ? composer.blocked() : false,
|
||||
] as const
|
||||
},
|
||||
@@ -683,15 +518,17 @@ export default function Page() {
|
||||
todoTimer = undefined
|
||||
if (!id) return
|
||||
if (status === "idle" && !blocked) return
|
||||
const cached = untrack(() => sync.data.todo[id] !== undefined || serverSync.data.session_todo[id] !== undefined)
|
||||
const cached = untrack(
|
||||
() => sync().data.todo[id] !== undefined || serverSync().data.session_todo[id] !== undefined,
|
||||
)
|
||||
|
||||
todoFrame = requestAnimationFrame(() => {
|
||||
todoFrame = undefined
|
||||
todoTimer = window.setTimeout(() => {
|
||||
todoTimer = undefined
|
||||
if (sdk.directory !== dir || params.id !== id) return
|
||||
if (sdk().directory !== dir || params.id !== id) return
|
||||
untrack(() => {
|
||||
void sync.session.todo(id, cached ? { force: true } : undefined)
|
||||
void sync().session.todo(id, cached ? { force: true } : undefined)
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
@@ -724,7 +561,7 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
const stopVcs = sdk.event.listen((evt) => {
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
@@ -867,7 +704,7 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.data.session_status[params.id ?? ""]?.type,
|
||||
() => sync().data.session_status[params.id ?? ""]?.type,
|
||||
(next, prev) => {
|
||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||
refreshVcs()
|
||||
@@ -1138,10 +975,10 @@ export default function Page() {
|
||||
if (!id) return
|
||||
|
||||
if (!wantsReview()) return
|
||||
if (sync.data.session_diff[id] !== undefined) return
|
||||
if (sync.status === "loading") return
|
||||
if (sync().data.session_diff[id] !== undefined) return
|
||||
if (sync().status === "loading") return
|
||||
|
||||
void sync.session.diff(id)
|
||||
void sync().session.diff(id)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
@@ -1156,14 +993,14 @@ export default function Page() {
|
||||
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (!untrack(() => sync.data.session_diff[id] !== undefined)) return
|
||||
if (!untrack(() => sync().data.session_diff[id] !== undefined)) return
|
||||
|
||||
diffFrame = requestAnimationFrame(() => {
|
||||
diffFrame = undefined
|
||||
diffTimer = window.setTimeout(() => {
|
||||
diffTimer = undefined
|
||||
if (sessionKey() !== key) return
|
||||
void sync.session.diff(id, { force: true })
|
||||
void sync().session.diff(id, { force: true })
|
||||
}, 0)
|
||||
})
|
||||
},
|
||||
@@ -1173,10 +1010,10 @@ export default function Page() {
|
||||
|
||||
let treeDir: string | undefined
|
||||
createEffect(() => {
|
||||
const dir = sdk.directory
|
||||
const dir = sdk().directory
|
||||
if (!isDesktop()) return
|
||||
if (!layout.fileTree.opened()) return
|
||||
if (sync.status === "loading") return
|
||||
if (sync().status === "loading") return
|
||||
|
||||
fileTreeTab()
|
||||
const refresh = treeDir !== dir
|
||||
@@ -1186,7 +1023,7 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sdk.directory,
|
||||
() => sdk().directory,
|
||||
() => {
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
@@ -1200,8 +1037,18 @@ export default function Page() {
|
||||
|
||||
const autoScroll = createAutoScroll({
|
||||
working: () => true,
|
||||
overflowAnchor: "dynamic",
|
||||
overflowAnchor: "none",
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => params.id,
|
||||
(id, previous) => {
|
||||
if (!id || !previous || id === previous) return
|
||||
if (location.hash || store.messageId || ui.pendingMessage) return
|
||||
autoScroll.resume()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
let scrollStateFrame: number | undefined
|
||||
let scrollStateTarget: HTMLDivElement | undefined
|
||||
@@ -1237,7 +1084,8 @@ export default function Page() {
|
||||
|
||||
const resumeScroll = () => {
|
||||
setStore("messageId", undefined)
|
||||
autoScroll.forceScrollToBottom()
|
||||
autoScroll.resume()
|
||||
scrollToEnd()
|
||||
clearMessageHash()
|
||||
|
||||
const el = scroller
|
||||
@@ -1280,16 +1128,14 @@ export default function Page() {
|
||||
},
|
||||
)
|
||||
|
||||
const historyLoader = createSessionHistoryLoader({
|
||||
sessionID: () => params.id,
|
||||
loaded: () => messages().length,
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
userScrolled: autoScroll.userScrolled,
|
||||
scroller: () => scroller,
|
||||
})
|
||||
let captureHistoryAnchor = () => {}
|
||||
let restoreHistoryAnchor = (_done: boolean) => {}
|
||||
const loadOlder = () =>
|
||||
timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
|
||||
const onHistoryScroll = () => {
|
||||
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return
|
||||
void loadOlder()
|
||||
}
|
||||
|
||||
fill = () => {
|
||||
if (fillFrame !== undefined) return
|
||||
@@ -1305,7 +1151,7 @@ export default function Page() {
|
||||
if (el.scrollHeight > el.clientHeight + 1) return
|
||||
if (!historyMore()) return
|
||||
|
||||
void historyLoader.loadAndReveal()
|
||||
void loadOlder()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1330,8 +1176,8 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const draft = (id: string) =>
|
||||
extractPromptFromParts(sync.data.part[id] ?? [], {
|
||||
directory: sdk.directory,
|
||||
extractPromptFromParts(sync().data.part[id] ?? [], {
|
||||
directory: sdk().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
|
||||
@@ -1354,7 +1200,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const merge = (next: NonNullable<ReturnType<typeof info>>) =>
|
||||
sync.set("session", (list) => {
|
||||
sync().set("session", (list) => {
|
||||
const idx = list.findIndex((item) => item.id === next.id)
|
||||
if (idx < 0) return list
|
||||
const out = list.slice()
|
||||
@@ -1363,7 +1209,7 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) =>
|
||||
sync.set("session", (list) => {
|
||||
sync().set("session", (list) => {
|
||||
const idx = list.findIndex((item) => item.id === sessionID)
|
||||
if (idx < 0) return list
|
||||
const out = list.slice()
|
||||
@@ -1371,7 +1217,7 @@ export default function Page() {
|
||||
return out
|
||||
})
|
||||
|
||||
const busy = (sessionID: string) => sync.data.session_working(sessionID)
|
||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||
|
||||
const queuedFollowups = createMemo(() => {
|
||||
const id = params.id
|
||||
@@ -1394,11 +1240,11 @@ export default function Page() {
|
||||
setFollowup("failed", input.sessionID, undefined)
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
client: sdk.client,
|
||||
sync,
|
||||
serverSync,
|
||||
client: sdk().client,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft: item,
|
||||
optimisticBusy: item.sessionDirectory === sdk.directory,
|
||||
optimisticBusy: item.sessionDirectory === sdk().directory,
|
||||
}).catch((err) => {
|
||||
setFollowup("failed", input.sessionID, input.id)
|
||||
fail(err)
|
||||
@@ -1456,7 +1302,7 @@ export default function Page() {
|
||||
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
|
||||
|
||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||
if (sync.session.get(sessionID)?.parentID) return Promise.resolve()
|
||||
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
|
||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||
if (!item) return Promise.resolve()
|
||||
if (followupBusy(sessionID)) return Promise.resolve()
|
||||
@@ -1488,7 +1334,11 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const halt = (sessionID: string) =>
|
||||
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
|
||||
busy(sessionID)
|
||||
? sdk()
|
||||
.client.session.abort({ sessionID })
|
||||
.catch(() => {})
|
||||
: Promise.resolve()
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
@@ -1500,7 +1350,7 @@ export default function Page() {
|
||||
prompt.set(value)
|
||||
})
|
||||
await halt(input.sessionID)
|
||||
.then(() => sdk.client.session.revert(input))
|
||||
.then(() => sdk().client.session.revert(input))
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
@@ -1533,9 +1383,9 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const task = !next
|
||||
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
|
||||
? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() =>
|
||||
sdk.client.session.revert({
|
||||
sdk().client.session.revert({
|
||||
sessionID,
|
||||
messageID: next.id,
|
||||
}),
|
||||
@@ -1609,7 +1459,7 @@ export default function Page() {
|
||||
|
||||
dockHeight = next
|
||||
|
||||
if (stick) autoScroll.forceScrollToBottom()
|
||||
if (stick) scrollToEnd()
|
||||
|
||||
if (el) scheduleScrollState(el)
|
||||
fill()
|
||||
@@ -1623,12 +1473,18 @@ export default function Page() {
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
||||
currentMessageId: () => store.messageId,
|
||||
pendingMessage: () => ui.pendingMessage,
|
||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||
setActiveMessage,
|
||||
autoScroll,
|
||||
autoScroll: {
|
||||
pause: autoScroll.pause,
|
||||
forceScrollToBottom: () => {
|
||||
autoScroll.resume()
|
||||
scrollToEnd()
|
||||
},
|
||||
},
|
||||
scroller: () => scroller,
|
||||
anchor,
|
||||
revealMessage: (id) => revealMessage(id),
|
||||
@@ -1651,8 +1507,6 @@ export default function Page() {
|
||||
|
||||
onCleanup(() => {
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
@@ -1785,37 +1639,45 @@ export default function Page() {
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady()}>
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={historyLoader.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
<Show when={messagesReady() ? params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={onHistoryScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
historyShift={historyLoader.shift()}
|
||||
userMessages={historyLoader.userMessages()}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
/>
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
userMessages={visibleUserMessages()}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
}}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
setScrollToEnd={(fn) => {
|
||||
scrollToEnd = fn
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
@@ -18,6 +18,17 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
@@ -54,10 +65,70 @@ export function SessionComposerRegion(props: {
|
||||
const language = useLanguage()
|
||||
const route = useSessionKey()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const queryOptions = useQueryOptions()
|
||||
const local = useLocal()
|
||||
const providers = useProviders()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const view = layout.view(route.sessionKey)
|
||||
|
||||
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
|
||||
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
|
||||
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
|
||||
const selectProject = (worktree: string) => {
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
if (search.draftId) {
|
||||
tabs.updateDraft(search.draftId, { server: server.key, directory: worktree })
|
||||
return
|
||||
}
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
const addProject = (title: string) => {
|
||||
if (!server.current) return
|
||||
pickDirectory({
|
||||
server: server.current,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
if (directory) selectProject(directory)
|
||||
},
|
||||
})
|
||||
}
|
||||
const controls = createMemo(() => ({
|
||||
agents: {
|
||||
available: sync().data.agent,
|
||||
options: local.agent.list().map((agent) => agent.name),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
loading: agentsQuery.isLoading,
|
||||
visible: settings.visibility.customAgents(),
|
||||
select: local.agent.set,
|
||||
},
|
||||
model: {
|
||||
selection: local.model,
|
||||
paid: providers.paid().length > 0,
|
||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||
},
|
||||
projects: {
|
||||
available: layout.projects.list(),
|
||||
directory: sdk().directory,
|
||||
select: selectProject,
|
||||
add: addProject,
|
||||
},
|
||||
session: {
|
||||
id: route.params.id,
|
||||
tabs: layout.tabs(route.sessionKey),
|
||||
reviewPanel: view.reviewPanel,
|
||||
},
|
||||
newLayoutDesigns: settings.general.newLayoutDesigns(),
|
||||
}))
|
||||
|
||||
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
||||
const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
|
||||
const info = createMemo(() => (route.params.id ? sync().session.get(route.params.id) : undefined))
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const child = createMemo(() => !!parentID())
|
||||
const showComposer = createMemo(() => !props.state.blocked() || child())
|
||||
@@ -263,6 +334,7 @@ export function SessionComposerRegion(props: {
|
||||
fallback={
|
||||
<Show when={!props.state.blocked()}>
|
||||
<PromptInput
|
||||
controls={controls()}
|
||||
variant={props.placement === "inline" ? "new-session" : undefined}
|
||||
ref={props.inputRef}
|
||||
newSessionWorktree={props.newSessionWorktree}
|
||||
|
||||
@@ -32,12 +32,12 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
const permission = usePermission()
|
||||
|
||||
const questionRequest = createMemo((): QuestionRequest | undefined => {
|
||||
return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
|
||||
return sessionQuestionRequest(sync().data.session, sync().data.question, params.id)
|
||||
})
|
||||
|
||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||
return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => {
|
||||
return !permission.autoResponds(item, sdk.directory)
|
||||
return sessionPermissionRequest(sync().data.session, sync().data.permission, params.id, (item) => {
|
||||
return !permission.autoResponds(item, sdk().directory)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,14 +50,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
const todos = createMemo((): Todo[] => {
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
return serverSync.data.session_todo[id] ?? []
|
||||
return serverSync().data.session_todo[id] ?? []
|
||||
})
|
||||
|
||||
const done = createMemo(
|
||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
)
|
||||
|
||||
const live = createMemo(() => sync.data.session_working(params.id ?? "") || blocked())
|
||||
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
responding: undefined as string | undefined,
|
||||
@@ -78,8 +78,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
if (store.responding === perm.id) return
|
||||
|
||||
setStore("responding", perm.id)
|
||||
sdk.client.permission
|
||||
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
||||
sdk()
|
||||
.client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
||||
.catch((err: unknown) => {
|
||||
const description = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description })
|
||||
@@ -111,8 +111,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
const clear = () => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
serverSync.todo.set(id, [])
|
||||
sync.set("todo", id, [])
|
||||
serverSync().todo.set(id, [])
|
||||
sync().set("todo", id, [])
|
||||
}
|
||||
|
||||
createEffect(
|
||||
|
||||
@@ -64,7 +64,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
|
||||
const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id)
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const total = createMemo(() => questions().length)
|
||||
@@ -209,7 +209,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}
|
||||
|
||||
const replyMutation = useMutation(() => ({
|
||||
mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
|
||||
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
@@ -221,7 +221,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}))
|
||||
|
||||
const rejectMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
|
||||
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
|
||||
@@ -12,10 +12,9 @@ import {
|
||||
} from "./helpers"
|
||||
|
||||
describe("shouldShowFileTree", () => {
|
||||
test("does not reserve space for a disabled v2 file tree", () => {
|
||||
expect(shouldShowFileTree({ desktopV2: true, showFileTree: false, opened: true })).toBe(false)
|
||||
expect(shouldShowFileTree({ desktopV2: false, showFileTree: false, opened: true })).toBe(true)
|
||||
expect(shouldShowFileTree({ desktopV2: true, showFileTree: true, opened: true })).toBe(true)
|
||||
test("does not reserve space for a disabled file tree", () => {
|
||||
expect(shouldShowFileTree({ visible: false, opened: true })).toBe(false)
|
||||
expect(shouldShowFileTree({ visible: true, opened: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user