mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bba1c5bbf4 | |||
| 007cdd6d70 | |||
| 41f2705e72 | |||
| ef5c9f4931 | |||
| 40ecfceec2 | |||
| a1f093a748 | |||
| 9859b19b93 | |||
| 45badf7163 | |||
| 28a00ad6af | |||
| 0f272d931d | |||
| ded29f03f0 | |||
| f428755851 | |||
| 17166b271f | |||
| 5f61d21487 | |||
| 0568855cc6 | |||
| 3fbc005f26 | |||
| 21c0e03a70 | |||
| a2b847e29e | |||
| fae100fe72 | |||
| b60c0a5e8c | |||
| 0870a838a8 | |||
| 0e731c2a1a | |||
| 21b78c49fc | |||
| f44423609b | |||
| 44806777ca | |||
| a267222080 | |||
| ecabab49a7 | |||
| 9e9d405d7e | |||
| 5682371d0a | |||
| 10e4b87d25 | |||
| e7c59b17a8 | |||
| 69f75dff1a | |||
| fec087bf37 | |||
| 4f4aa68db9 | |||
| 77f2d2227a | |||
| 7854f5b9f7 | |||
| 5565a8ee10 | |||
| 9ad7067971 | |||
| ddbc8d3b75 | |||
| 4f88cab20d | |||
| 208cd56d03 | |||
| c097d51135 | |||
| 20fd323591 | |||
| 7f40aeab21 | |||
| bcff162c60 | |||
| 5ea405bf81 | |||
| 7c0e1b431c | |||
| f4103f5c3a | |||
| e6c45134ab | |||
| f55d8fa3ff | |||
| 78234fa305 | |||
| 98dcea02a8 | |||
| 0149d460e3 | |||
| 5471d2a769 | |||
| b44af11e03 | |||
| 717b740d24 | |||
| b2c688f2a8 | |||
| 1b107301fb | |||
| aee262f019 | |||
| 6ec6db4ad6 | |||
| 40ddede2b1 | |||
| 71402bcbc3 | |||
| d13779b1d5 | |||
| bdee94ca40 | |||
| 9c0d813b8e | |||
| 1bcb9d7cb9 |
@@ -1,2 +1,4 @@
|
||||
sst-env.d.ts
|
||||
packages/desktop/src/bindings.ts
|
||||
packages/client/src/generated/
|
||||
packages/client/src/generated-effect/
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
|
||||
+16
-2
@@ -57,11 +57,21 @@ The bounded projection of a Core-executed tool result persisted in Session histo
|
||||
**Managed Tool Output File**:
|
||||
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
|
||||
|
||||
**Model Request Options**:
|
||||
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
|
||||
_Avoid_: Request body, wire options
|
||||
|
||||
**Generation Controls**:
|
||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||
|
||||
**Native Continuation Metadata**:
|
||||
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
|
||||
|
||||
**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.
|
||||
|
||||
**OpenCode Client**:
|
||||
The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers.
|
||||
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
|
||||
_Avoid_: Remote client
|
||||
|
||||
**SDK Contract IR**:
|
||||
@@ -122,6 +132,9 @@ _Avoid_: Response envelope
|
||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||
- **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`.
|
||||
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
|
||||
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
|
||||
@@ -159,8 +172,9 @@ _Avoid_: Response envelope
|
||||
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
|
||||
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
|
||||
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
|
||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `SessionMessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
||||
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
|
||||
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
|
||||
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
|
||||
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -87,7 +87,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -135,7 +135,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -171,7 +171,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -198,7 +198,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -220,7 +220,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -244,7 +244,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -264,7 +264,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -357,7 +357,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -411,7 +411,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -425,7 +425,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -437,7 +437,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -469,7 +469,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -485,7 +485,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -535,7 +535,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -665,7 +665,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -682,9 +682,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.4.2",
|
||||
"@opentui/keymap": ">=0.4.2",
|
||||
"@opentui/solid": ">=0.4.2",
|
||||
"@opentui/core": ">=0.3.4",
|
||||
"@opentui/keymap": ">=0.3.4",
|
||||
"@opentui/solid": ">=0.3.4",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -741,7 +741,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -756,7 +756,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -771,7 +771,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -815,7 +815,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -828,7 +828,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
@@ -861,7 +861,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -880,7 +880,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -921,7 +921,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -948,7 +948,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -958,8 +958,6 @@
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "catalog:",
|
||||
@@ -975,27 +973,32 @@
|
||||
"remeda": "catalog:",
|
||||
"remend": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/katex": "0.16.7",
|
||||
"@types/luxon": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
"vite-plugin-solid": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@solidjs/meta": "^0.29.0",
|
||||
"solid-js": "^1.9.0",
|
||||
},
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1069,9 +1072,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.4.2",
|
||||
"@opentui/keymap": "0.4.2",
|
||||
"@opentui/solid": "0.4.2",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1988,27 +1991,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.4.2", "", { "dependencies": { "bun-ffi-structs": "0.2.3", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.2", "@opentui/core-darwin-x64": "0.4.2", "@opentui/core-linux-arm64": "0.4.2", "@opentui/core-linux-arm64-musl": "0.4.2", "@opentui/core-linux-x64": "0.4.2", "@opentui/core-linux-x64-musl": "0.4.2", "@opentui/core-win32-arm64": "0.4.2", "@opentui/core-win32-x64": "0.4.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ulx6RMqftf2fm7Itf9e81GcCDMNY6NAhmnKYhllDOMYD+PxYXR+vomy2bxQNV5ow31RE7s8WQFnb7hWTRUbx2g=="],
|
||||
"@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-is+O+sS/l3E9cZXyM9pRF1WhqnE+hYSPYoZkbseR9CthJcaWPGi3R3jUJa1cLj325252jWgxVupnDqFUtKg36w=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ACi42h81DurSeybUAD1XyKT6xmXZcKeTxS54lZFi0CVZh46w0g99vNj8PlQzIFXvvFLT0e0IlRS//eWSWS2zGQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RjOx2HcjLRtGSy9WrAGSdr5M9SpJuPifPORpImx6Mciovw0ltnE0uoYjIyor82uf6/LExWC7YA2AcAl+YBxayA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-heNciL2ngPU+kq1h01PHLsxn6Fr8iqTFtbxSdVbhaY3XihuIjkuXyEhFeuoa1lsXY7Bb2gpWnX5EQVWnZsAuDQ=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0s/ooK+AhWP306By3gu+XhzcVEThC2sqKMPK1nQmGDujQhd+xOrtbtfCVcJSx62UzAovC2VNqypvP8vHByOg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cjv6Bv7l3p/KLNJr5RyqCS0FmRlAGJnkA2IK3S+HkHhCOv/O02S1G+DBUY6POnyjp1eNy95vauustApobhdbig=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mfJZrJ0TNPFRZUzXNsxAPe1YdiWsy/vbTl93+yeXGHPI1B8Qnk9V5hpzSxxEyBGhlTHSfGNtgiO+VrrdRC3kZA=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-P2oguG3ng3OMjAdasFSA3GhHaQXtzDUsIRDGbzWFOimpZ/zMemidp+JQ0V8V6XwK6Utk5G0aQ03oBaRCoLyYDw=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.4.2", "", { "dependencies": { "@opentui/core": "0.4.2" }, "peerDependencies": { "@opentui/react": "0.4.2", "@opentui/solid": "0.4.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wxBEFfWgm3feqCRLckWg1JH4tbMJinpyK3yobkLTsWJ7PDsM+fPoFMyQ8ieKVdUL2eP6ELTmHvM1bHKShZ7SUQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.4.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zuYXsnrlsMtnXrS7QCYBdPzMtUSonG2LqnJikBR2NjEE2O4zEKvJd48n3eB1igcxjv96tiotTXRNCylYS0SNdQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -3186,7 +3189,7 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -5544,6 +5547,8 @@
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-4RYkrGAbsrUw/n0ecPJpntSZYuV6GsmMMjK9R6MbMxU=",
|
||||
"aarch64-linux": "sha256-kwSkouFxbEYzYAsr9gaVUQrT7YfbcoKb4kB9dhNtaFM=",
|
||||
"aarch64-darwin": "sha256-mukRph5X1noBRhd5+0Ct7ZshTYxooGcoY+tZEXzfSvo=",
|
||||
"x86_64-darwin": "sha256-CE8JgBAfZQmUnunPPw4XPPw3bkso1OALmNgmyGgJr1k="
|
||||
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
|
||||
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
|
||||
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
|
||||
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -39,9 +39,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.4.2",
|
||||
"@opentui/keymap": "0.4.2",
|
||||
"@opentui/solid": "0.4.2",
|
||||
"@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",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/HiddenTerminalRegression"
|
||||
const projectID = "proj_hidden_terminal_regression"
|
||||
const sessionID = "ses_hidden_terminal_regression"
|
||||
const title = "Hidden terminal regression"
|
||||
|
||||
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "hidden-terminal-regression",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "hidden-terminal-regression",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/pty/pty_hidden_terminal", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const panel = page.locator("#terminal-panel")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 700 })
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
})
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
+49
-29
@@ -32,7 +32,7 @@ import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||
import { GlobalProvider } from "@/context/global"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
selectSessionLineage,
|
||||
sessionHref,
|
||||
} from "./utils/session-route"
|
||||
import { isSessionNotFoundError } from "./utils/server-errors"
|
||||
|
||||
import Session from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
@@ -102,10 +103,10 @@ const SessionRoute = () => {
|
||||
|
||||
const TargetSessionRoute = () => {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return server.list.find((item) => ServerConnection.key(item) === key)
|
||||
return global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -129,9 +130,13 @@ function ResolvedTargetSessionRoute() {
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (cached()) return
|
||||
return { id: params.id, sync: sync() }
|
||||
return { id: params.id, server: serverKey(), sync: sync() }
|
||||
},
|
||||
({ id, sync }) => sync.session.lineage.resolve(id),
|
||||
({ id, server, sync }) =>
|
||||
sync.session.lineage.resolve(id).catch((error) => {
|
||||
if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id })
|
||||
throw error
|
||||
}),
|
||||
)
|
||||
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
|
||||
const directory = createMemo(() => current()?.session.directory)
|
||||
@@ -216,25 +221,27 @@ function DraftRoute() {
|
||||
}
|
||||
|
||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
const server = useServer()
|
||||
const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server))
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
|
||||
const directory = () => props.draft.directory
|
||||
const serverKey = () => props.draft.server
|
||||
|
||||
return (
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetServerScopedProviders directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</TargetServerScopedProviders>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetServerScopedProviders directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</TargetServerScopedProviders>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -583,18 +590,31 @@ function Routes() {
|
||||
</Route>
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
<Route path="/" component={NewHome} />
|
||||
<Route
|
||||
path="/:dir/session/:id"
|
||||
component={() => {
|
||||
const server = useServer()
|
||||
const { id } = useParams()
|
||||
|
||||
return <Navigate href={`/server/${server.key}/session/${id}`} />
|
||||
}}
|
||||
/>
|
||||
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
|
||||
</Show>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyTargetSessionRoute() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const params = useParams<{ id: string }>()
|
||||
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Navigate
|
||||
href={sessionHref(
|
||||
legacySessionServer(
|
||||
tabs.store.filter((item) => item.type === "session"),
|
||||
params.id,
|
||||
server.key,
|
||||
),
|
||||
params.id,
|
||||
)}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { newTabTooltipKeybind, reviewTooltipKeybind } from "./command-tooltip-keybind"
|
||||
|
||||
describe("command tooltip keybinds", () => {
|
||||
test("keeps localized review shortcut modifiers", () => {
|
||||
const command = {
|
||||
keybind: () => "Ctrl+Maj+R",
|
||||
keybindParts: () => ["Ctrl", "Maj", "R"],
|
||||
}
|
||||
|
||||
expect(reviewTooltipKeybind(command, (key) => key)).toEqual(["Ctrl", "Maj", "R"])
|
||||
})
|
||||
|
||||
test("uses the configured new-tab shortcut", () => {
|
||||
const command = {
|
||||
keybind: () => "Alt+N",
|
||||
keybindParts: () => ["Alt", "N"],
|
||||
}
|
||||
|
||||
expect(newTabTooltipKeybind(command, (key) => key)).toEqual(["Alt", "N"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
type CommandKeybind = {
|
||||
keybindParts: (id: string) => string[]
|
||||
}
|
||||
|
||||
export function reviewTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
|
||||
return command.keybindParts("review.toggle")
|
||||
}
|
||||
|
||||
export function newTabTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
|
||||
return command.keybindParts("tab.new")
|
||||
}
|
||||
@@ -15,7 +15,6 @@ 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"
|
||||
@@ -272,7 +271,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const server = useServer()
|
||||
const settings = useSettings()
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
@@ -393,10 +391,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
state.cleanup?.()
|
||||
})
|
||||
|
||||
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) {
|
||||
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
||||
return (
|
||||
<DialogSelectFileV2
|
||||
server={server.current}
|
||||
server={serverSDK().server}
|
||||
mode="file"
|
||||
start={projectDirectory()}
|
||||
title={language.t("session.header.searchFiles")}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
function createPromptInputStoryRuntime() {
|
||||
@@ -54,12 +54,6 @@ function PromptInputExample() {
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
projects: {
|
||||
available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
|
||||
directory: "/tmp/story",
|
||||
select() {},
|
||||
add() {},
|
||||
},
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: {
|
||||
@@ -142,7 +136,6 @@ function PromptInputWithOpenDock() {
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: {
|
||||
@@ -166,22 +159,35 @@ function PromptInputWithOpenDock() {
|
||||
closing: () => false,
|
||||
opening: () => false,
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
state={state}
|
||||
sessionKey="story-session"
|
||||
sessionID="story-session"
|
||||
controls={inputControls}
|
||||
promptInput={{ ...input, ref: () => {}, newSessionWorktree: "", onNewSessionWorktreeReset: () => {} }}
|
||||
todo={{
|
||||
collapsed: controls.todoCollapsed,
|
||||
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||
}}
|
||||
ready
|
||||
centered={false}
|
||||
onResponseSubmit={() => {}}
|
||||
setPromptDockRef={() => {}}
|
||||
controller={createSessionComposerRegionController({
|
||||
state,
|
||||
sessionKey: () => "story-session",
|
||||
sessionID: () => "story-session",
|
||||
prompt: input.state,
|
||||
ready: () => true,
|
||||
centered: () => false,
|
||||
todo: {
|
||||
collapsed: () => controls.todoCollapsed,
|
||||
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||
},
|
||||
followup: () => undefined,
|
||||
revert: () => undefined,
|
||||
onResponseSubmit: () => {},
|
||||
openParent: () => {},
|
||||
setPromptRef: () => {},
|
||||
setDockRef: () => {},
|
||||
})}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={inputControls}
|
||||
{...input}
|
||||
ref={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
createEffect,
|
||||
on,
|
||||
Component,
|
||||
splitProps,
|
||||
For,
|
||||
Show,
|
||||
onCleanup,
|
||||
createMemo,
|
||||
@@ -13,10 +11,8 @@ import {
|
||||
createResource,
|
||||
Switch,
|
||||
Match,
|
||||
type ComponentProps,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
@@ -36,7 +32,7 @@ import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -70,8 +66,6 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
|
||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
|
||||
@@ -99,12 +93,6 @@ export type PromptInputControls = {
|
||||
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: {
|
||||
@@ -175,6 +163,7 @@ export interface PromptInputProps {
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
toolbar?: JSX.Element
|
||||
}
|
||||
|
||||
const EXAMPLES = [
|
||||
@@ -223,7 +212,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
let scrollRef!: HTMLDivElement
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
let projectSearchRef: HTMLInputElement | undefined
|
||||
|
||||
const mirror = { input: false }
|
||||
const inset = 56
|
||||
@@ -351,11 +339,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
() => prompt.capture(),
|
||||
Math.floor(Math.random() * EXAMPLES.length),
|
||||
)
|
||||
const [picker, setPicker] = createStore({
|
||||
projectOpen: false,
|
||||
projectSearch: "",
|
||||
})
|
||||
|
||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||
const motion = (value: number) => ({
|
||||
opacity: value,
|
||||
@@ -1392,72 +1375,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}))
|
||||
|
||||
const newSession = () => props.variant === "new-session"
|
||||
const projects = createMemo(() => props.controls.projects.available)
|
||||
const projectForDirectory = (directory: string | undefined) => {
|
||||
if (!directory) return
|
||||
const key = pathKey(directory)
|
||||
return projects().find(
|
||||
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||
)
|
||||
}
|
||||
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(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
||||
const selectProject = (worktree: string) => {
|
||||
setPicker({
|
||||
projectOpen: false,
|
||||
projectSearch: "",
|
||||
})
|
||||
if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) {
|
||||
restoreFocus()
|
||||
return
|
||||
}
|
||||
props.controls.projects.select(worktree)
|
||||
restoreFocus()
|
||||
}
|
||||
const addProject = () => {
|
||||
props.controls.projects.add(language.t("command.project.open"))
|
||||
}
|
||||
|
||||
const projectPickerState = createMemo<ComposerPickerState>(() => ({
|
||||
open: picker.projectOpen,
|
||||
trigger: {
|
||||
action: "prompt-project",
|
||||
icon: "folder",
|
||||
label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"),
|
||||
class: "max-w-[203px]",
|
||||
style: control(),
|
||||
onPress: () => setPicker("projectOpen", true),
|
||||
},
|
||||
search: picker.projectSearch,
|
||||
searchPlaceholder: language.t("session.new.project.search"),
|
||||
clearLabel: language.t("common.clear"),
|
||||
items: projectResults().map((project) => ({
|
||||
icon: "folder",
|
||||
label: displayName(project),
|
||||
selected: selectedProject()?.worktree === project.worktree,
|
||||
onSelect: () => selectProject(project.worktree),
|
||||
})),
|
||||
action: {
|
||||
icon: "plus",
|
||||
label: language.t("session.new.project.add"),
|
||||
onSelect: () => {
|
||||
setPicker("projectOpen", false)
|
||||
void addProject()
|
||||
},
|
||||
},
|
||||
onOpenChange: (open) => {
|
||||
setPicker("projectOpen", open)
|
||||
if (open) requestAnimationFrame(() => projectSearchRef?.focus())
|
||||
},
|
||||
onSearchInput: (value) => setPicker("projectSearch", value),
|
||||
onSearchClear: () => setPicker("projectSearch", ""),
|
||||
searchRef: (el) => (projectSearchRef = el),
|
||||
}))
|
||||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||
title: language.t("command.agent.cycle"),
|
||||
keybind: command.keybind("agent.cycle"),
|
||||
@@ -1469,15 +1387,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
restoreFocus()
|
||||
},
|
||||
}))
|
||||
const newProjectTriggerState = createMemo<ComposerPickerTriggerState>(() => ({
|
||||
action: "prompt-project",
|
||||
icon: "folder-add-left",
|
||||
label: language.t("session.new.project.new"),
|
||||
class: "max-w-[160px]",
|
||||
style: control(),
|
||||
onPress: () => void addProject(),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div class="relative size-full flex flex-col gap-0">
|
||||
{(promptReady(), null)}
|
||||
@@ -1608,9 +1517,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={showAgentControl()}>
|
||||
<ComposerAgentControl state={agentControlState()} />
|
||||
</Show>
|
||||
<Show when={newSession() && !selectedProject()}>
|
||||
<ComposerPickerTrigger state={newProjectTriggerState()} />
|
||||
</Show>
|
||||
{props.toolbar}
|
||||
<ComposerModelControl state={modelControlState()} />
|
||||
<Show when={store.mode !== "shell" && showVariantControl()}>
|
||||
<div
|
||||
@@ -1664,11 +1571,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
<Show when={newSession() && selectedProject()}>
|
||||
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
|
||||
<ComposerPicker state={projectPickerState()} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when>
|
||||
@@ -2010,37 +1912,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
type ComposerPickerItemState = {
|
||||
icon: IconProps["name"]
|
||||
label: string
|
||||
selected?: boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
type ComposerPickerTriggerState = {
|
||||
action: string
|
||||
icon?: IconProps["name"]
|
||||
label: string
|
||||
class?: string
|
||||
style: JSX.CSSProperties | undefined
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
type ComposerPickerState = {
|
||||
open: boolean
|
||||
trigger: ComposerPickerTriggerState
|
||||
search: string
|
||||
searchPlaceholder: string
|
||||
clearLabel: string
|
||||
items: ComposerPickerItemState[]
|
||||
action: ComposerPickerItemState
|
||||
listClass?: string
|
||||
searchRef: (el: HTMLInputElement) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSearchInput: (value: string) => void
|
||||
onSearchClear: () => void
|
||||
}
|
||||
|
||||
type ComposerAgentControlState = {
|
||||
title: string
|
||||
keybind: string
|
||||
@@ -2063,90 +1934,6 @@ type ComposerModelControlState = {
|
||||
onUnpaidClick: () => void
|
||||
}
|
||||
|
||||
function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: ComposerPickerTriggerState }) {
|
||||
const [local, rest] = splitProps(props, ["state", "class", "style", "onClick"])
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
data-action={local.state.action}
|
||||
type="button"
|
||||
class={`flex h-7 min-w-0 items-center gap-1.5 rounded px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`}
|
||||
style={local.state.style}
|
||||
onClick={() => local.state.onPress()}
|
||||
>
|
||||
<Show when={local.state.icon}>
|
||||
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
|
||||
</Show>
|
||||
<span class="min-w-0 truncate leading-5">{local.state.label}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-full items-center gap-2 rounded px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={props.state.onSelect}
|
||||
>
|
||||
<Icon name={props.state.icon} size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{props.state.label}</span>
|
||||
<Show when={props.state.selected}>
|
||||
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerPicker(props: { state: ComposerPickerState }) {
|
||||
return (
|
||||
<KobaltePopover
|
||||
open={props.state.open}
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
modal={false}
|
||||
onOpenChange={props.state.onOpenChange}
|
||||
>
|
||||
<KobaltePopover.Trigger as={ComposerPickerTrigger} state={props.state.trigger} />
|
||||
<KobaltePopover.Portal>
|
||||
<KobaltePopover.Content
|
||||
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div class={`flex flex-col p-0.5 ${props.state.listClass ?? ""}`}>
|
||||
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={props.state.searchRef}
|
||||
value={props.state.search}
|
||||
placeholder={props.state.searchPlaceholder}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => props.state.onSearchInput(event.currentTarget.value)}
|
||||
/>
|
||||
<Show when={props.state.search.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={props.state.onSearchClear}
|
||||
aria-label={props.state.clearLabel}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<For each={props.state.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>
|
||||
</div>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
<ComposerPickerMenuItem state={props.state.action} />
|
||||
</div>
|
||||
</KobaltePopover.Content>
|
||||
</KobaltePopover.Portal>
|
||||
</KobaltePopover>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
return (
|
||||
<div class="relative">
|
||||
@@ -2196,7 +1983,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
@@ -2225,7 +2014,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
|
||||
@@ -20,8 +20,10 @@ const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
|
||||
@@ -79,7 +81,7 @@ beforeAll(async () => {
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => params,
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
useSearchParams: () => [search, () => undefined],
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
@@ -129,7 +131,10 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/tabs", () => ({
|
||||
useTabs: () => ({
|
||||
promoteDraft: () => undefined,
|
||||
draft: () => ({ server: "project-server" }),
|
||||
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||
promotedDrafts.push({ draftID, ...session })
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -234,7 +239,9 @@ beforeEach(() => {
|
||||
optimistic.length = 0
|
||||
optimisticSeeded.length = 0
|
||||
promoted.length = 0
|
||||
promotedDrafts.length = 0
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
@@ -309,6 +316,33 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("promotes drafts using the selected project's server", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
||||
})
|
||||
|
||||
test("includes the selected variant on optimistic prompts", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -206,7 +205,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
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)
|
||||
|
||||
@@ -380,7 +378,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
local.session.promote(sessionDirectory, session.id)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
export type PromptProject = {
|
||||
name?: string
|
||||
id?: string
|
||||
worktree: string
|
||||
sandboxes?: string[]
|
||||
icon?: { color?: string; url?: string; override?: string }
|
||||
server?: { key: string; name: string }
|
||||
}
|
||||
|
||||
export type PromptProjectControls = {
|
||||
available: PromptProject[]
|
||||
directory: string
|
||||
server?: string
|
||||
select: (worktree: string, server?: string) => void
|
||||
add: (title: string, server?: string) => void
|
||||
}
|
||||
|
||||
export function createPromptProjectController(input: {
|
||||
controls: Accessor<PromptProjectControls>
|
||||
onDone: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ open: false, search: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
|
||||
const selected = () => {
|
||||
const key = pathKey(input.controls().directory)
|
||||
return input
|
||||
.controls()
|
||||
.available.find(
|
||||
(project) =>
|
||||
(!project.server || project.server.key === input.controls().server) &&
|
||||
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
|
||||
)
|
||||
}
|
||||
const projects = () => {
|
||||
const search = store.search.trim().toLowerCase()
|
||||
if (!search) return input.controls().available
|
||||
return input.controls().available.filter((project) => displayName(project).toLowerCase().includes(search))
|
||||
}
|
||||
const servers = () =>
|
||||
input
|
||||
.controls()
|
||||
.available.map((project) => project.server)
|
||||
.filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
|
||||
const close = () => {
|
||||
setStore({ open: false, search: "" })
|
||||
input.onDone()
|
||||
}
|
||||
const select = (project: PromptProject) => {
|
||||
if (
|
||||
pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
|
||||
project.server?.key !== selected()?.server?.key
|
||||
) {
|
||||
input.controls().select(project.worktree, project.server?.key)
|
||||
}
|
||||
close()
|
||||
}
|
||||
const add = (server?: string) => {
|
||||
setStore("open", false)
|
||||
input.controls().add(language.t("command.project.open"), server)
|
||||
}
|
||||
|
||||
return {
|
||||
selected,
|
||||
projects,
|
||||
servers,
|
||||
open: () => store.open,
|
||||
search: () => store.search,
|
||||
labels: {
|
||||
add: () => language.t("session.new.project.add"),
|
||||
clear: () => language.t("common.clear"),
|
||||
new: () => language.t("session.new.project.new"),
|
||||
search: () => language.t("session.new.project.search"),
|
||||
},
|
||||
add,
|
||||
select,
|
||||
setOpen(open: boolean) {
|
||||
setStore("open", open)
|
||||
if (open) requestAnimationFrame(() => searchRef?.focus())
|
||||
},
|
||||
setSearch(value: string) {
|
||||
setStore("search", value)
|
||||
},
|
||||
setSearchRef(el: HTMLInputElement) {
|
||||
searchRef = el
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
|
||||
|
||||
export function PromptProjectSelector(props: { controller: PromptProjectController }) {
|
||||
return (
|
||||
<Popover
|
||||
open={props.controller.open()}
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
modal={false}
|
||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||
>
|
||||
<Popover.Trigger as={ProjectTrigger} controller={props.controller} />
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div class="flex flex-col p-0.5">
|
||||
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(el) => props.controller.setSearchRef(el)}
|
||||
value={props.controller.search()}
|
||||
placeholder={props.controller.labels.search()}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => props.controller.setSearch(event.currentTarget.value)}
|
||||
/>
|
||||
<Show when={props.controller.search().trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={() => props.controller.setSearch("")}
|
||||
aria-label={props.controller.labels.clear()}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={props.controller.servers().length > 1}
|
||||
fallback={
|
||||
<For each={props.controller.projects()}>
|
||||
{(project) => (
|
||||
<ProjectItem
|
||||
project={project}
|
||||
selected={props.controller.selected()}
|
||||
onSelect={props.controller.select}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<For each={props.controller.servers()}>
|
||||
{(server) => (
|
||||
<div>
|
||||
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
||||
{server!.name}
|
||||
</div>
|
||||
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
||||
{(project) => (
|
||||
<ProjectItem
|
||||
project={project}
|
||||
selected={props.controller.selected()}
|
||||
onSelect={props.controller.select}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<ProjectAction
|
||||
label={props.controller.labels.add()}
|
||||
onSelect={() => props.controller.add(server!.key)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.controller.servers().length <= 1}>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
<ProjectAction
|
||||
label={props.controller.labels.add()}
|
||||
onSelect={() => props.controller.add(props.controller.servers()[0]?.key)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptProjectAddButton(props: { controller: PromptProjectController }) {
|
||||
return (
|
||||
<button
|
||||
data-action="prompt-project"
|
||||
type="button"
|
||||
class="flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={() => props.controller.add()}
|
||||
>
|
||||
<Icon name="folder-add-left" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate leading-5">{props.controller.labels.new()}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
|
||||
const [local, rest] = splitProps(props, ["controller", "class", "onClick"])
|
||||
const project = () => local.controller.selected()
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
data-action="prompt-project"
|
||||
type="button"
|
||||
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={() => local.controller.setOpen(true)}
|
||||
>
|
||||
<Show
|
||||
when={project()}
|
||||
fallback={<Icon name="folder-add-left" size="small" class="shrink-0 text-v2-icon-icon-muted" />}
|
||||
>
|
||||
{(item) => (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(item())}
|
||||
src={getProjectAvatarSource(item().id, item().icon)}
|
||||
variant={getProjectAvatarVariant(item().icon?.color)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="min-w-0 truncate leading-5">
|
||||
{project() ? displayName(project()!) : local.controller.labels.new()}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectItem(props: {
|
||||
project: PromptProject
|
||||
selected?: PromptProject
|
||||
onSelect: (project: PromptProject) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={() => props.onSelect(props.project)}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{displayName(props.project)}</span>
|
||||
<Show
|
||||
when={
|
||||
props.selected?.worktree === props.project.worktree &&
|
||||
props.selected?.server?.key === props.project.server?.key
|
||||
}
|
||||
>
|
||||
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectAction(props: { label: string; onSelect: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={props.onSelect}
|
||||
>
|
||||
<Icon name="plus" size="small" />
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{props.label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export const ServerRowMenu: Component<{
|
||||
const isDefault = () => props.controller.defaultKey() === key
|
||||
|
||||
return (
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
variant="ghost-muted"
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ServerHealthIndicator(props: { health?: ServerHealth }) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
|
||||
"bg-icon-success-base": props.health?.healthy === true,
|
||||
"bg-icon-critical-base": props.health?.healthy === false,
|
||||
"bg-border-weak-base": props.health === undefined,
|
||||
|
||||
@@ -28,6 +28,9 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
|
||||
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 { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
||||
|
||||
const OPEN_APPS = [
|
||||
"vscode",
|
||||
@@ -237,7 +240,7 @@ export function SessionHeader() {
|
||||
statusVisible: status(),
|
||||
statusLabel: language.t("status.popover.trigger"),
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: command.keybind("review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
@@ -520,13 +523,15 @@ type SessionHeaderV2ActionsState = {
|
||||
statusVisible: boolean
|
||||
statusLabel: string
|
||||
reviewLabel: string
|
||||
reviewKeybind: string
|
||||
reviewKeybind: string[]
|
||||
reviewVisible: boolean
|
||||
reviewOpened: boolean
|
||||
onReviewToggle: () => void
|
||||
}
|
||||
|
||||
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={props.state.statusVisible}>
|
||||
@@ -535,7 +540,17 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.state.reviewVisible}>
|
||||
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{props.state.reviewLabel}
|
||||
<Show when={props.state.reviewKeybind.length > 0}>
|
||||
<KeybindV2 keys={props.state.reviewKeybind} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
@@ -548,7 +563,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
aria-controls="review-panel"
|
||||
icon={<IconV2 name="sidebar-right" />}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
export function useSettingsDialog() {
|
||||
const dialog = useDialog()
|
||||
const params = useParams<{ id?: string }>()
|
||||
let run = 0
|
||||
let dead = false
|
||||
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
|
||||
return () => {
|
||||
const current = ++run
|
||||
const sessionID = params.id
|
||||
void import("@/components/settings-v2").then((module) => {
|
||||
if (dead || run !== current) return
|
||||
void dialog.show(() => <module.DialogSettings sessionID={sessionID} />)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function useSettingsCommand() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const show = useSettingsDialog()
|
||||
|
||||
command.register("settings", () => [
|
||||
{
|
||||
id: "settings.open",
|
||||
title: language.t("command.settings.open"),
|
||||
category: language.t("command.category.settings"),
|
||||
keybind: "mod+comma",
|
||||
onSelect: show,
|
||||
},
|
||||
])
|
||||
|
||||
return show
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import { SettingsModelsV2 } from "./models"
|
||||
import "./settings-v2.css"
|
||||
import { SettingsServersV2 } from "./servers"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
export const DialogSettings: Component<{
|
||||
sessionID?: string
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
@@ -62,7 +64,7 @@ export const DialogSettings: Component = () => {
|
||||
</div>
|
||||
</TabsV2.List>
|
||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||
<SettingsGeneralV2 />
|
||||
<SettingsGeneralV2 sessionID={props.sessionID} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||
<SettingsKeybinds v2 />
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
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 } from "@/context/platform"
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
terminalInput,
|
||||
useSettings,
|
||||
} from "@/context/settings"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "../link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
@@ -82,50 +80,46 @@ const playDemoSound = (id: string | undefined) => {
|
||||
}, 100)
|
||||
}
|
||||
|
||||
export const SettingsGeneralV2: Component = () => {
|
||||
export const SettingsGeneralV2: Component<{
|
||||
sessionID?: string
|
||||
}> = (props) => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
|
||||
const dir = createMemo(() => decode64(params.dir))
|
||||
const dir = createMemo(() => {
|
||||
if (!props.sessionID) return undefined
|
||||
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
|
||||
})
|
||||
const accepting = createMemo(() => {
|
||||
const value = dir()
|
||||
if (!value) return false
|
||||
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
||||
return permission.isAutoAccepting(params.id, value)
|
||||
if (!value || !props.sessionID) return false
|
||||
return permission.isAutoAccepting(props.sessionID, value)
|
||||
})
|
||||
|
||||
const toggleAccept = (checked: boolean) => {
|
||||
const value = dir()
|
||||
if (!value) return
|
||||
|
||||
if (!params.id) {
|
||||
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
||||
permission.toggleAutoAcceptDirectory(value)
|
||||
return
|
||||
}
|
||||
if (!value || !props.sessionID) return
|
||||
|
||||
if (checked) {
|
||||
permission.enableAutoAccept(params.id, value)
|
||||
permission.enableAutoAccept(props.sessionID, value)
|
||||
return
|
||||
}
|
||||
|
||||
permission.disableAutoAccept(params.id, value)
|
||||
permission.disableAutoAccept(props.sessionID, value)
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
serverSdk()
|
||||
@@ -336,7 +330,7 @@ export const SettingsGeneralV2: Component = () => {
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<Show when={mobile()}>
|
||||
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
description={language.t("settings.general.row.mobileTitlebarBottom.description")}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServer } from "@/context/server"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobal } from "@/context/global"
|
||||
|
||||
@@ -81,11 +82,11 @@ export function StatusPopoverV2(props: { scope?: "server" }) {
|
||||
|
||||
function DirectoryStatusPopover() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const server = useServerSDK()
|
||||
const global = useGlobal()
|
||||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
||||
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
|
||||
const mcpIssue = createMemo(() => {
|
||||
const mcp = Object.values(sync().data.mcp ?? {})
|
||||
|
||||
@@ -10,7 +10,7 @@ import { matchKeybind, parseKeybind } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
@@ -160,13 +160,15 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
|
||||
const connection = useServerSDK()().server
|
||||
const directory = sdk().directory
|
||||
const client = sdk().client
|
||||
const url = sdk().url
|
||||
const auth = server.current?.http
|
||||
const auth = connection.http
|
||||
const username = auth?.username ?? "opencode"
|
||||
const password = auth?.password ?? ""
|
||||
const authToken = connection.type === "http" ? connection.authToken : false
|
||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||
@@ -540,7 +542,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
sameOrigin,
|
||||
username,
|
||||
password,
|
||||
authToken: server.current?.type === "http" ? server.current.authToken : false,
|
||||
authToken,
|
||||
}),
|
||||
)
|
||||
socket.binaryType = "arraybuffer"
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
|
||||
|
||||
const remote = "remote" as ServerConnection.Key
|
||||
|
||||
describe("titlebar session events", () => {
|
||||
test("reads valid removed session tab details", () => {
|
||||
expect(
|
||||
readSessionTabsRemovedDetail(
|
||||
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
|
||||
detail: { directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
|
||||
detail: { server: "remote", directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
server: remote,
|
||||
directory: "/tmp/project",
|
||||
sessionIDs: ["ses_1", "ses_2"],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
|
||||
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
|
||||
|
||||
export type SessionTabsRemovedDetail = {
|
||||
server?: ServerConnection.Key
|
||||
directory: string
|
||||
sessionIDs: string[]
|
||||
}
|
||||
@@ -18,11 +21,14 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe
|
||||
if (!("sessionIDs" in detail)) return undefined
|
||||
if (typeof detail.directory !== "string") return undefined
|
||||
if (!Array.isArray(detail.sessionIDs)) return undefined
|
||||
if ("server" in detail && detail.server !== undefined && typeof detail.server !== "string") return undefined
|
||||
|
||||
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
|
||||
if (sessionIDs.length === 0) return undefined
|
||||
|
||||
return {
|
||||
server:
|
||||
"server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined,
|
||||
directory: detail.directory,
|
||||
sessionIDs,
|
||||
}
|
||||
|
||||
@@ -56,6 +56,28 @@ describe("titlebar tab drag", () => {
|
||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
|
||||
list.remove()
|
||||
})
|
||||
|
||||
test("uses the list gap as the divider width", () => {
|
||||
const list = document.createElement("div")
|
||||
const first = document.createElement("div")
|
||||
const second = document.createElement("div")
|
||||
const firstTab = document.createElement("div")
|
||||
const secondTab = document.createElement("div")
|
||||
first.dataset.titlebarTabSlot = ""
|
||||
first.dataset.tabKey = "a"
|
||||
second.dataset.titlebarTabSlot = ""
|
||||
second.dataset.tabKey = "b"
|
||||
firstTab.dataset.titlebarTab = ""
|
||||
secondTab.dataset.titlebarTab = ""
|
||||
first.append(firstTab)
|
||||
second.append(secondTab)
|
||||
list.append(first, second)
|
||||
list.style.columnGap = "13.5px"
|
||||
document.body.append(list)
|
||||
|
||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13.5)
|
||||
list.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe("titlebar tab gestures", () => {
|
||||
|
||||
@@ -22,24 +22,26 @@ export function captureTabDragLayout(list: HTMLElement, order: string[]) {
|
||||
for (const slot of slots) {
|
||||
const id = slot.dataset.tabKey
|
||||
if (!id) continue
|
||||
const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
||||
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
||||
if (!tab) continue
|
||||
tabWidthById.set(id, tab.getBoundingClientRect().width)
|
||||
}
|
||||
|
||||
let dividerWidth = 0
|
||||
if (order.length >= 2) {
|
||||
const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0
|
||||
const secondId = order[1]
|
||||
for (const slot of slots) {
|
||||
if (slot.dataset.tabKey !== secondId) continue
|
||||
const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
||||
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
||||
if (!tab) break
|
||||
const style = getComputedStyle(slot)
|
||||
dividerWidth =
|
||||
gap ||
|
||||
slot.getBoundingClientRect().width -
|
||||
tab.getBoundingClientRect().width +
|
||||
(Number.parseFloat(style.marginLeft) || 0) +
|
||||
(Number.parseFloat(style.marginRight) || 0)
|
||||
tab.getBoundingClientRect().width +
|
||||
(Number.parseFloat(style.marginLeft) || 0) +
|
||||
(Number.parseFloat(style.marginRight) || 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
[data-titlebar-tab] [data-slot="tab-close"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-list] {
|
||||
gap: 13.5px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot]:not(:first-child)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: -6.75px;
|
||||
width: 1.5px;
|
||||
height: 12px;
|
||||
border-radius: 9999px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-close"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -31,19 +50,11 @@
|
||||
right: 100%;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-link"] {
|
||||
padding-right: 22px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
|
||||
[data-slot="tab-link"] {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-link"] {
|
||||
padding-right: 22px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-title"] {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -62,8 +73,20 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:hover [data-slot="tab-close"],
|
||||
[data-titlebar-tab][data-active="true"] [data-slot="tab-close"],
|
||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
||||
background: var(--tab-bg);
|
||||
@container (max-width: 64px) {
|
||||
[data-titlebar-tab-link] {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-title] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="tab-close"] {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ export function TabNavItem(props: {
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
tabKey: string
|
||||
dragActive: boolean
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [editing, setEditing] = createSignal(false)
|
||||
@@ -170,102 +173,112 @@ export function TabNavItem(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
forwardTabRef(props.ref, el)
|
||||
}}
|
||||
data-titlebar-tab
|
||||
data-title-overflow={titleOverflowing()}
|
||||
data-editing={editing()}
|
||||
class="group relative flex h-7 min-w-24 max-w-60 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.tabKey}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
||||
onPointerDown={props.onPointerDown}
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
forwardTabRef(props.ref, el)
|
||||
}}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-title-overflow={titleOverflowing()}
|
||||
data-editing={editing()}
|
||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
titleEl.textContent = session().title
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div data-slot="tab-close">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="relative z-10 opacity-0 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = session().title
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -282,6 +295,9 @@ export function DraftTabItem(props: {
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
tabKey: string
|
||||
dragActive: boolean
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -290,54 +306,69 @@ export function DraftTabItem(props: {
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={(el) => forwardTabRef(props.ref, el)}
|
||||
data-titlebar-tab
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.tabKey}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
||||
onPointerDown={props.onPointerDown}
|
||||
>
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
<div
|
||||
ref={(el) => forwardTabRef(props.ref, el)}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span class="truncate leading-5">{props.title}</span>
|
||||
</a>
|
||||
<div data-slot="tab-close" class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
</a>
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
first: () => boolean
|
||||
active: () => boolean
|
||||
activeServerKey: ServerConnection.Key
|
||||
forceTruncate: boolean
|
||||
@@ -101,42 +100,33 @@ function SessionTabSlot(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
class="flex shrink-0"
|
||||
classList={{
|
||||
hidden: !session(),
|
||||
"ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !props.first(),
|
||||
"pointer-events-none": props.dragActive,
|
||||
}}
|
||||
<TabNavItem
|
||||
tabKey={props.id}
|
||||
dragActive={props.dragActive}
|
||||
onPointerDown={props.onPointerDown}
|
||||
>
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={session}
|
||||
onTitleChange={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onTitleChangeFailed={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
activeServer={props.tab.server === props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
suppressNavigation={props.suppressNavigation}
|
||||
pressed={props.pressed()}
|
||||
hidden={props.dragged()}
|
||||
/>
|
||||
</div>
|
||||
ref={ref}
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={session}
|
||||
onTitleChange={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onTitleChangeFailed={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
activeServer={props.tab.server === props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
suppressNavigation={props.suppressNavigation}
|
||||
pressed={props.pressed()}
|
||||
hidden={props.dragged() || !session()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -338,7 +328,10 @@ export function TitlebarTabStrip(props: {
|
||||
if (event.button !== 0 || drag.active) return
|
||||
if (!canStartTabDrag(event.pointerType)) return
|
||||
if (isTabCloseTarget(event.target)) return
|
||||
const tabEl = (event.currentTarget as HTMLElement).querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||
const target = event.currentTarget as HTMLDivElement
|
||||
const tabEl = target.matches("[data-titlebar-tab]")
|
||||
? target
|
||||
: target.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||
if (!tabEl) return
|
||||
if (!tabEl.querySelector('[data-slot="tab-link"]')) return
|
||||
const tab = props.tabs.find((item) => tabKey(item) === id)
|
||||
@@ -467,11 +460,10 @@ export function TitlebarTabStrip(props: {
|
||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div class="flex min-w-0 flex-row items-center" ref={listRef}>
|
||||
<div data-titlebar-tab-list class="flex min-w-0 flex-row items-center" ref={listRef}>
|
||||
<For each={displayTabs()}>
|
||||
{(tab, index) => {
|
||||
const id = tabKey(tab)
|
||||
const first = () => index() === 0
|
||||
let ref!: HTMLDivElement
|
||||
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
||||
|
||||
@@ -487,7 +479,6 @@ export function TitlebarTabStrip(props: {
|
||||
<SessionTabSlot
|
||||
tab={tab}
|
||||
id={id}
|
||||
first={first}
|
||||
active={() => props.currentTab() === tab}
|
||||
activeServerKey={props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
@@ -507,31 +498,23 @@ export function TitlebarTabStrip(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={id}
|
||||
class="flex shrink-0"
|
||||
classList={{
|
||||
"ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !first(),
|
||||
"pointer-events-none": drag.active,
|
||||
}}
|
||||
<DraftTabItem
|
||||
tabKey={id}
|
||||
dragActive={drag.active}
|
||||
onPointerDown={(event) => {
|
||||
if (dragged()) return
|
||||
onPointerDown(id, event)
|
||||
}}
|
||||
>
|
||||
<DraftTabItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
title={language.t("command.session.new")}
|
||||
onNavigate={() => props.onNavigate(tab, ref)}
|
||||
onClose={() => props.onClose(tab)}
|
||||
suppressNavigation={() => suppressNavigation()}
|
||||
active={props.currentTab() === tab}
|
||||
pressed={pressedId() === id}
|
||||
hidden={dragged()}
|
||||
/>
|
||||
</div>
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
title={language.t("command.session.new")}
|
||||
onNavigate={() => props.onNavigate(tab, ref)}
|
||||
onClose={() => props.onClose(tab)}
|
||||
suppressNavigation={() => suppressNavigation()}
|
||||
active={props.currentTab() === tab}
|
||||
pressed={pressedId() === id}
|
||||
hidden={dragged()}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
@@ -1,3 +1,34 @@
|
||||
[data-slot="titlebar-tab-item"] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"] a {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-v2"]
|
||||
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:focus-visible, [data-state="focus"]):not(
|
||||
:disabled
|
||||
) {
|
||||
outline: none;
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"]
|
||||
[data-component="icon-button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"]:has([data-component="icon-button-v2"]:focus-visible) [data-slot="titlebar-tab-close"] {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"]:has([data-component="icon-button-v2"]:focus-visible)
|
||||
[data-slot="titlebar-tab-close-fade"] {
|
||||
background-image: var(--active-bg);
|
||||
}
|
||||
|
||||
@keyframes titlebar-tab-fade-left {
|
||||
from {
|
||||
visibility: hidden;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabKey, useTabs } from "@/context/tabs"
|
||||
import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
@@ -217,6 +218,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
|
||||
return (
|
||||
<header
|
||||
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
|
||||
classList={{
|
||||
"shrink-0 relative flex flex-row": true,
|
||||
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
|
||||
@@ -317,6 +319,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeTab = currentTab()
|
||||
if (activeTab?.type === "draft") {
|
||||
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
|
||||
return
|
||||
}
|
||||
|
||||
const current = layout.projects.list()[0]
|
||||
if (current) {
|
||||
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
|
||||
@@ -370,7 +378,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
id: `tab.prev`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowLeft`,
|
||||
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||
@@ -387,7 +395,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
id: `tab.next`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowRight`,
|
||||
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||
@@ -459,15 +467,25 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Show when={!(creating() && params.dir)}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("command.session.new")}
|
||||
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
<div class="flex-1" />
|
||||
<TitlebarV2Right state={v2RightState()} />
|
||||
@@ -555,7 +573,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={creating() ? "new-session-active" : "new-session"}
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
disabled={layout.sidebar.opened()}
|
||||
tabIndex={layout.sidebar.opened() ? -1 : undefined}
|
||||
@@ -565,7 +582,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
aria-current={creating() ? "page" : undefined}
|
||||
/>
|
||||
>
|
||||
<IconV2 name="edit" size="small" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
@@ -663,16 +682,16 @@ function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
|
||||
|
||||
function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
return (
|
||||
<div class="relative isolate mr-3 size-5 shrink-0">
|
||||
<div class="group relative mr-3 h-5 w-5 shrink-0 rounded-full bg-v2-background-bg-deep transition-[width] duration-150 ease-out hover:z-30 hover:w-[68px] focus-within:z-30 focus-within:w-[68px] motion-reduce:transition-none">
|
||||
<button
|
||||
type="button"
|
||||
class="group absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out hover:z-30 hover:w-[68px] hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:z-30 focus-visible:w-[68px] focus-visible:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none"
|
||||
class="absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out group-hover:w-[68px] group-hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] group-focus-within:w-[68px] group-focus-within:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none"
|
||||
onClick={props.state.onInstall}
|
||||
disabled={props.state.installing}
|
||||
aria-busy={props.state.installing}
|
||||
aria-label={props.state.ariaLabel}
|
||||
>
|
||||
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-visible:opacity-100 group-focus-visible:translate-x-0 motion-reduce:translate-x-0">
|
||||
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0">
|
||||
Update
|
||||
</span>
|
||||
<span class="flex size-5 shrink-0 items-center justify-center">
|
||||
|
||||
@@ -227,6 +227,11 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
// KeybindV2 takes an array instead of a string
|
||||
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
|
||||
return formatKeybindParts(config, t)
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { useLocation } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
@@ -17,6 +17,7 @@ import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { type DraftTab, useTabs } from "./tabs"
|
||||
|
||||
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
||||
|
||||
@@ -73,6 +74,7 @@ type TabHandoff = {
|
||||
}
|
||||
|
||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
||||
|
||||
export type ReviewDiffStyle = "unified" | "split"
|
||||
|
||||
@@ -158,12 +160,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const location = useLocation()
|
||||
const route = createMemo(() => {
|
||||
const value = currentRoute(location.pathname, location.search)
|
||||
if (value.type === "home") return value
|
||||
if (value.server) return value
|
||||
if (value.type === "draft") {
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID)
|
||||
if (draft) return { ...value, server: draft.server }
|
||||
}
|
||||
return { ...value, server: server.key }
|
||||
})
|
||||
|
||||
@@ -290,6 +297,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
handoff: {
|
||||
tabs: undefined as TabHandoff | undefined,
|
||||
},
|
||||
home: {
|
||||
selection: { server: server.key } as HomeProjectSelection,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -579,6 +589,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
return {
|
||||
route,
|
||||
ready,
|
||||
home: {
|
||||
selection: createMemo(() => store.home.selection),
|
||||
setSelection(selection: HomeProjectSelection) {
|
||||
setStore("home", "selection", reconcile(selection))
|
||||
},
|
||||
},
|
||||
handoff: {
|
||||
tabs: createMemo(() => store.handoff?.tabs),
|
||||
setTabs(dir: string, id: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { type Accessor, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
@@ -145,6 +145,15 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
setStore("variant", key, value)
|
||||
}
|
||||
|
||||
const [recentModels] = createResource(
|
||||
async () => {
|
||||
const recent = store.recent
|
||||
await ready.promise
|
||||
return recent
|
||||
},
|
||||
(p) => p,
|
||||
{ initialValue: [] },
|
||||
)
|
||||
return {
|
||||
ready,
|
||||
list,
|
||||
@@ -152,7 +161,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||
visible,
|
||||
setVisibility,
|
||||
recent: {
|
||||
list: createMemo(() => store.recent),
|
||||
list: () => recentModels()!,
|
||||
push,
|
||||
},
|
||||
variant: {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { ServerConnection } from "./server"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { useSettings } from "./settings"
|
||||
|
||||
@@ -287,7 +287,6 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
const sdk = useSDK()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const settings = useSettings()
|
||||
const cache = new Map<string, PromptCacheEntry>()
|
||||
@@ -312,7 +311,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const owner = getOwner()
|
||||
const serverKey = () => (params.serverKey ? requireServerKey(params.serverKey) : server.key)
|
||||
const serverKey = () =>
|
||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||
const scope = () =>
|
||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||
const load = (scope: Scope) => {
|
||||
|
||||
@@ -269,6 +269,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
})
|
||||
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
url: server.http.url,
|
||||
client: sdk,
|
||||
|
||||
@@ -471,14 +471,18 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
case "permission.asked": {
|
||||
const permission = event.properties as PermissionRequest
|
||||
const permissions = data.permission[permission.sessionID] ?? []
|
||||
const permissions = data.permission[permission.sessionID]
|
||||
if (!permissions) {
|
||||
setData("permission", permission.sessionID, [permission])
|
||||
return
|
||||
}
|
||||
const result = Binary.search(permissions, permission.id, (item) => item.id)
|
||||
if (result.found) setData("permission", permission.sessionID, result.index, reconcile(permission))
|
||||
if (!result.found)
|
||||
setData(
|
||||
"permission",
|
||||
permission.sessionID,
|
||||
produce((draft = []) => void draft.splice(result.index, 0, permission)),
|
||||
produce((draft) => void draft.splice(result.index, 0, permission)),
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -497,14 +501,18 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
case "question.asked": {
|
||||
const question = event.properties as QuestionRequest
|
||||
const questions = data.question[question.sessionID] ?? []
|
||||
const questions = data.question[question.sessionID]
|
||||
if (!questions) {
|
||||
setData("question", question.sessionID, [question])
|
||||
return
|
||||
}
|
||||
const result = Binary.search(questions, question.id, (item) => item.id)
|
||||
if (result.found) setData("question", question.sessionID, result.index, reconcile(question))
|
||||
if (!result.found)
|
||||
setData(
|
||||
"question",
|
||||
question.sessionID,
|
||||
produce((draft = []) => void draft.splice(result.index, 0, question)),
|
||||
produce((draft) => void draft.splice(result.index, 0, question)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,6 +112,27 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
navigate(href)
|
||||
}
|
||||
|
||||
const removeTab = (index: number) => {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||
const nextTab = store[index + 1] ?? store[index - 1]
|
||||
closing.add(key)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
|
||||
if (nextTab) navigateTab(nextTab)
|
||||
else navigate("/")
|
||||
}).finally(() => closing.delete(key))
|
||||
memory.remove(key)
|
||||
if (draftID) removeDraftPersisted(draftID)
|
||||
}
|
||||
|
||||
const actions = {
|
||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||
const next = { type: "session" as const, ...tab }
|
||||
@@ -175,25 +196,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
memory.remove(`draft:${draftID}`)
|
||||
removeDraftPersisted(draftID)
|
||||
},
|
||||
removeTab: (index: number) => {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||
const nextTab = store[index + 1] ?? store[index - 1]
|
||||
closing.add(key)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
|
||||
if (nextTab) navigateTab(nextTab)
|
||||
else navigate("/")
|
||||
}).finally(() => closing.delete(key))
|
||||
memory.remove(key)
|
||||
if (draftID) removeDraftPersisted(draftID)
|
||||
removeTab,
|
||||
removeSessionTab(input: Omit<SessionTab, "type">) {
|
||||
const index = store.findIndex(
|
||||
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
|
||||
)
|
||||
if (index !== -1) removeTab(index)
|
||||
},
|
||||
removeServer(key: ServerConnection.Key) {
|
||||
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||
@@ -205,9 +213,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (server.key === key) navigate("/")
|
||||
},
|
||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||
const targetServer = input.server ?? server.key
|
||||
const removed = store
|
||||
.filter(
|
||||
(tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
|
||||
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),
|
||||
)
|
||||
.map(tabKey)
|
||||
void startTransition(() => {
|
||||
@@ -215,28 +224,28 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
produce((tabs) => {
|
||||
const sessionIDs = new Set(input.sessionIDs)
|
||||
const currentHref =
|
||||
params.dir && params.id
|
||||
targetServer === server.key && params.dir && params.id
|
||||
? tabHref({
|
||||
type: "session",
|
||||
server: server.key,
|
||||
server: targetServer,
|
||||
sessionId: params.id,
|
||||
})
|
||||
: undefined
|
||||
const currentIndex = currentHref
|
||||
? tabs.findIndex(
|
||||
(tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref,
|
||||
(tab) => tab.type === "session" && tab.server === targetServer && tabHref(tab) === currentHref,
|
||||
)
|
||||
: -1
|
||||
const currentTab = tabs[currentIndex]
|
||||
const removedCurrent =
|
||||
currentTab?.type === "session" &&
|
||||
currentTab.server === server.key &&
|
||||
currentTab.server === targetServer &&
|
||||
sessionIDs.has(currentTab.sessionId)
|
||||
|
||||
for (let i = tabs.length - 1; i >= 0; i--) {
|
||||
const tab = tabs[i]
|
||||
if (!tab || tab.type !== "session") continue
|
||||
if (tab.server !== server.key) continue
|
||||
if (tab.server !== targetServer) continue
|
||||
if (!sessionIDs.has(tab.sessionId)) continue
|
||||
tabs.splice(i, 1)
|
||||
}
|
||||
|
||||
@@ -592,10 +592,11 @@ export const dict = {
|
||||
"home.server.collapse": "Collapse server projects",
|
||||
"home.server.expand": "Expand server projects",
|
||||
"home.sessions.search.placeholder": "Search sessions",
|
||||
"home.sessions.search.placeholder.scoped": "Search sessions in {{scope}}",
|
||||
"home.sessions.search.sessions": "Sessions",
|
||||
"home.sessions.search.noResults": "No sessions found for {{query}}",
|
||||
"home.sessions.empty": "No sessions found",
|
||||
"home.sessions.empty.description": "Start a new session for this project",
|
||||
"home.sessions.empty": "Nothing here yet",
|
||||
"home.sessions.empty.description": "Create a session to get started",
|
||||
"home.sessions.group.today": "Today",
|
||||
"home.sessions.group.yesterday": "Yesterday",
|
||||
"home.sessions.group.older": "Older",
|
||||
|
||||
@@ -507,9 +507,11 @@ export const dict = {
|
||||
"home.projects": "项目",
|
||||
"home.project.add": "添加项目",
|
||||
"home.sessions.search.placeholder": "搜索会话",
|
||||
"home.sessions.search.placeholder.scoped": "在 {{scope}} 中搜索会话",
|
||||
"home.sessions.search.sessions": "会话",
|
||||
"home.sessions.search.noResults": "未找到与 {{query}} 相关的会话",
|
||||
"home.sessions.empty": "未找到会话",
|
||||
"home.sessions.empty": "这里还没有内容",
|
||||
"home.sessions.empty.description": "创建一个会话以开始。",
|
||||
"home.sessions.group.today": "今天",
|
||||
"home.sessions.group.yesterday": "昨天",
|
||||
"home.sessions.group.older": "更早",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
import { archiveHomeSession } from "./home-session-archive"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
|
||||
const remote = "remote" as ServerConnection.Key
|
||||
|
||||
test("archiving a Home session removes its open titlebar tab", async () => {
|
||||
let detail: ReturnType<typeof readSessionTabsRemovedDetail>
|
||||
let removed = false
|
||||
window.addEventListener(
|
||||
SESSION_TABS_REMOVED_EVENT,
|
||||
(event) => {
|
||||
detail = readSessionTabsRemovedDetail(event)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", directory: "/workspace" },
|
||||
update: async () => undefined,
|
||||
remove: () => {
|
||||
removed = true
|
||||
},
|
||||
})
|
||||
|
||||
expect(removed).toBe(true)
|
||||
expect(detail).toEqual({ server: remote, directory: "/workspace", sessionIDs: ["ses_1"] })
|
||||
})
|
||||
|
||||
test("reports archive failures without removing the session", async () => {
|
||||
const failure = new Error("offline")
|
||||
let error: unknown
|
||||
let removed = false
|
||||
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", directory: "/workspace" },
|
||||
update: async () => Promise.reject(failure),
|
||||
remove: () => {
|
||||
removed = true
|
||||
},
|
||||
onError: (value) => {
|
||||
error = value
|
||||
},
|
||||
})
|
||||
|
||||
expect(error).toBe(failure)
|
||||
expect(removed).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
|
||||
type HomeSession = {
|
||||
id: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
type SessionUpdate = {
|
||||
directory: string
|
||||
sessionID: string
|
||||
time: { archived: number }
|
||||
}
|
||||
|
||||
export async function archiveHomeSession(input: {
|
||||
server: ServerConnection.Key
|
||||
session: HomeSession
|
||||
update: (value: SessionUpdate) => Promise<unknown>
|
||||
remove: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}) {
|
||||
await input
|
||||
.update({
|
||||
directory: input.session.directory,
|
||||
sessionID: input.session.id,
|
||||
time: { archived: Date.now() },
|
||||
})
|
||||
.then(() => {
|
||||
input.remove()
|
||||
notifySessionTabsRemoved({
|
||||
server: input.server,
|
||||
directory: input.session.directory,
|
||||
sessionIDs: [input.session.id],
|
||||
})
|
||||
})
|
||||
.catch((error) => input.onError?.(error))
|
||||
}
|
||||
+224
-127
@@ -1,6 +1,5 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRoot,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
@@ -25,7 +24,8 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { getProjectAvatarVariant, useLayout, type HomeProjectSelection, type LocalProject } from "@/context/layout"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -33,9 +33,10 @@ import { usePlatform } from "@/context/platform"
|
||||
import { DateTime } from "luxon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -43,9 +44,9 @@ import { useNotification } from "@/context/notification"
|
||||
import {
|
||||
closeHomeProject,
|
||||
displayName,
|
||||
errorMessage,
|
||||
getProjectAvatarSource,
|
||||
homeProjectDirectories,
|
||||
type HomeProjectSelection,
|
||||
projectForSession,
|
||||
sortedRootSessions,
|
||||
toggleHomeProjectSelection,
|
||||
@@ -55,12 +56,15 @@ import { sessionTitle } from "@/utils/session-title"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||
import { archiveHomeSession } from "./home-session-archive"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const HOME_ROW_LAYOUT =
|
||||
@@ -85,7 +89,7 @@ type HomeSessionGroup = {
|
||||
|
||||
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
|
||||
const HOME_SEARCH_RESULT_ROW =
|
||||
"flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-4 pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
"flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
const HOME_SEARCH_RESULT_TITLE =
|
||||
"min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]"
|
||||
const HOME_SEARCH_RESULT_META =
|
||||
@@ -141,15 +145,16 @@ export function NewHome() {
|
||||
const command = useCommand()
|
||||
const notification = useNotification()
|
||||
const marked = useMarked()
|
||||
const openSettings = useSettingsCommand()
|
||||
let focusSessionSearch: (() => void) | undefined
|
||||
const [state, setState] = createStore({
|
||||
search: "",
|
||||
selection: { server: server.key } as HomeProjectSelection,
|
||||
searchFocused: false,
|
||||
})
|
||||
const selection = layout.home.selection
|
||||
|
||||
const focusedServer = createMemo(
|
||||
() => global.servers.list().find((conn) => ServerConnection.key(conn) === state.selection.server) ?? server.current,
|
||||
() => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
|
||||
)
|
||||
const focusedServerCtx = createMemo(() => {
|
||||
const conn = focusedServer()
|
||||
@@ -158,7 +163,7 @@ export function NewHome() {
|
||||
})
|
||||
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 selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
|
||||
const newSessionProject = createMemo(
|
||||
() =>
|
||||
selectedProject() ??
|
||||
@@ -172,8 +177,21 @@ export function NewHome() {
|
||||
return directories(project)
|
||||
})
|
||||
const search = createMemo(() => state.search.trim())
|
||||
const searchPlaceholder = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (project) {
|
||||
return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
|
||||
}
|
||||
if (global.servers.list().length > 1) {
|
||||
const conn = focusedServer()
|
||||
if (conn) {
|
||||
return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
|
||||
}
|
||||
}
|
||||
return language.t("home.sessions.search.placeholder")
|
||||
})
|
||||
const sessionLoad = useQuery(() => ({
|
||||
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
|
||||
queryKey: ["home", "sessions", selection().server, ...projectDirectories()] as const,
|
||||
queryFn: async () => {
|
||||
await Promise.all(
|
||||
projectDirectories().map((directory) =>
|
||||
@@ -239,10 +257,7 @@ export function NewHome() {
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
batch(() => {
|
||||
if (state.selection.server !== next.server) setState("selection", "server", next.server)
|
||||
if (state.selection.directory !== next.directory) setState("selection", "directory", next.directory)
|
||||
})
|
||||
layout.home.setSelection(next)
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
@@ -258,7 +273,7 @@ export function NewHome() {
|
||||
command.register("home", () => [
|
||||
{
|
||||
id: "home.sessions.search.focus",
|
||||
title: language.t("home.sessions.search.placeholder"),
|
||||
title: searchPlaceholder(),
|
||||
keybind: "mod+f",
|
||||
hidden: true,
|
||||
onSelect: () => focusSessionSearch?.(),
|
||||
@@ -267,7 +282,7 @@ export function NewHome() {
|
||||
|
||||
createEffect(() => {
|
||||
const list = global.servers.list()
|
||||
if (list.some((conn) => ServerConnection.key(conn) === state.selection.server)) return
|
||||
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
|
||||
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
|
||||
if (conn) setSelection({ server: ServerConnection.key(conn) })
|
||||
})
|
||||
@@ -292,7 +307,7 @@ export function NewHome() {
|
||||
.some((project) => project.worktree === directory)
|
||||
)
|
||||
return
|
||||
setSelection(toggleHomeProjectSelection(state.selection, key, directory))
|
||||
setSelection(toggleHomeProjectSelection(selection(), key, directory))
|
||||
}
|
||||
|
||||
function addProjects(conn: ServerConnection.Any, directories: string[]) {
|
||||
@@ -350,7 +365,33 @@ export function NewHome() {
|
||||
})
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
const conn = focusedServer()
|
||||
const ctx = focusedServerCtx()
|
||||
if (!conn || !ctx) return
|
||||
const [, setStore] = ctx.sync.child(session.directory)
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
session,
|
||||
update: (value) => ctx.sdk.client.session.update(value),
|
||||
remove: () =>
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
),
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(error, language.t("common.requestFailed")),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function chooseProject(conn: ServerConnection.Any) {
|
||||
if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
||||
|
||||
function resolve(result: string | string[] | null) {
|
||||
addProjects(conn, homeProjectDirectories(result))
|
||||
}
|
||||
@@ -363,18 +404,12 @@ export function NewHome() {
|
||||
})
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
void import("@/components/settings-v2").then((x) => {
|
||||
dialog.show(() => <x.DialogSettings />)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
selected={state.selection}
|
||||
selected={selection()}
|
||||
focusServer={focusServer}
|
||||
selectProject={selectProject}
|
||||
openNewSession={openProjectNewSession}
|
||||
@@ -382,7 +417,7 @@ export function NewHome() {
|
||||
editProject={editProject}
|
||||
closeProject={(conn, directory) => {
|
||||
const next = closeHomeProject(
|
||||
state.selection,
|
||||
selection(),
|
||||
ServerConnection.key(conn),
|
||||
global.ensureServerCtx(conn).projects,
|
||||
directory,
|
||||
@@ -402,12 +437,13 @@ export function NewHome() {
|
||||
>
|
||||
<HomeSessionSearch
|
||||
value={state.search}
|
||||
placeholder={language.t("home.sessions.search.placeholder")}
|
||||
placeholder={searchPlaceholder()}
|
||||
open={searchOpen()}
|
||||
loading={sessionLoad.isLoading}
|
||||
results={searchResults()}
|
||||
server={state.selection.server}
|
||||
activeServer={state.selection.server === server.key}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
activeServer={selection().server === server.key}
|
||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||
bindFocus={(focus) => {
|
||||
focusSessionSearch = focus
|
||||
@@ -418,22 +454,19 @@ export function NewHome() {
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<ScrollView class="mt-3 min-h-0 flex-1">
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}
|
||||
when={groups().length > 0}
|
||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||
>
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<HomeSessionGroupHeader
|
||||
title={language.t("home.sessions.empty")}
|
||||
onNewSession={newSessionProject() ? openNewSession : undefined}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<For each={groups()}>
|
||||
{(group, index) => (
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
@@ -446,9 +479,11 @@ export function NewHome() {
|
||||
{(record) => (
|
||||
<HomeSessionRow
|
||||
record={record}
|
||||
server={state.selection.server}
|
||||
activeServer={state.selection.server === server.key}
|
||||
onClick={() => openSession(record.session)}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
activeServer={selection().server === server.key}
|
||||
openSession={openSession}
|
||||
archiveSession={archiveSession}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -456,9 +491,9 @@ export function NewHome() {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
</section>
|
||||
<HomeUtilityNav
|
||||
@@ -502,15 +537,18 @@ function HomeProjectColumn(props: {
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<Show when={global.servers.list().length === 1}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
onClick={() => props.chooseProject(global.servers.list()[0]!)}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
disabled={global.servers.health[ServerConnection.key(global.servers.list()[0]!)]?.healthy === false}
|
||||
onClick={() => props.chooseProject(global.servers.list()[0]!)}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
@@ -646,7 +684,7 @@ function HomeServerRow(props: {
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={state.menuOpen}
|
||||
>
|
||||
<ServerRowMenu
|
||||
@@ -656,14 +694,17 @@ function HomeServerRow(props: {
|
||||
open={state.menuOpen}
|
||||
onOpenChange={(open) => setState("menuOpen", open)}
|
||||
/>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
onClick={() => props.chooseProject(props.server)}
|
||||
/>
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
disabled={props.health?.healthy === false}
|
||||
onClick={() => props.chooseProject(props.server)}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -733,19 +774,11 @@ function HomeProjectRow(props: {
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</button>
|
||||
<div
|
||||
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={state.menuOpen}
|
||||
>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="edit" />}
|
||||
aria-label={props.language.t("command.session.new")}
|
||||
onClick={() => props.openNewSession(props.server, props.project.worktree)}
|
||||
/>
|
||||
<MenuV2
|
||||
gutter={4}
|
||||
gutter={6}
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
open={state.menuOpen}
|
||||
@@ -765,7 +798,7 @@ function HomeProjectRow(props: {
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}>
|
||||
{props.language.t("common.edit")}
|
||||
{props.language.t("dialog.project.edit.title")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item
|
||||
disabled={props.unseenCount === 0}
|
||||
@@ -780,6 +813,14 @@ function HomeProjectRow(props: {
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="edit" />}
|
||||
aria-label={props.language.t("command.session.new")}
|
||||
onClick={() => props.openNewSession(props.server, props.project.worktree)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -810,7 +851,7 @@ function HomeSessionLeading(props: {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||
style={{ right: "calc(100% + 12px)" }}
|
||||
style={{ right: "calc(100% + 5px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTabAvatar
|
||||
@@ -829,6 +870,7 @@ function HomeSessionSearch(props: {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
results: HomeSessionRecord[]
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
noResultsLabel: string
|
||||
@@ -906,12 +948,12 @@ function HomeSessionSearch(props: {
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="ml-4 mr-2 w-[calc(100%_-_24px)]">
|
||||
<div class="w-full">
|
||||
<div ref={root} data-component="home-session-search" class="relative z-10 w-full">
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
data-component="home-session-search-panel"
|
||||
class="absolute flex flex-col rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
|
||||
class="absolute flex flex-col overflow-hidden rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
|
||||
style={{
|
||||
top: "-6px",
|
||||
left: "-6px",
|
||||
@@ -919,7 +961,7 @@ function HomeSessionSearch(props: {
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col pt-9">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4 pb-2">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
||||
<Show
|
||||
when={!props.loading}
|
||||
fallback={
|
||||
@@ -931,29 +973,32 @@ function HomeSessionSearch(props: {
|
||||
<Show
|
||||
when={props.results.length > 0}
|
||||
fallback={
|
||||
<p class="my-1.5 px-4 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
<p class="my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
{props.noResultsLabel}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<p class="my-1.5 px-4 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
<p class="my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
{language.t("home.sessions.search.sessions")}
|
||||
</p>
|
||||
<div ref={listRef} class="flex max-h-80 flex-col gap-px overflow-y-auto">
|
||||
<For each={props.results}>
|
||||
{(record) => (
|
||||
<HomeSessionSearchResultRow
|
||||
record={record}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
selected={store.active === homeSessionSearchKey(record)}
|
||||
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
||||
onSelect={(session) => props.onSelect(session)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<ScrollView class="max-h-80" viewportRef={(el) => (listRef = el)}>
|
||||
<div class="flex flex-col gap-px pb-2">
|
||||
<For each={props.results}>
|
||||
{(record) => (
|
||||
<HomeSessionSearchResultRow
|
||||
record={record}
|
||||
showProjectName={props.showProjectName}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
selected={store.active === homeSessionSearchKey(record)}
|
||||
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
||||
onSelect={(session) => props.onSelect(session)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -962,11 +1007,10 @@ function HomeSessionSearch(props: {
|
||||
</div>
|
||||
</Show>
|
||||
<label
|
||||
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
|
||||
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
|
||||
classList={{
|
||||
"bg-v2-background-bg-layer-03 focus-within:bg-v2-background-bg-layer-03 focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]":
|
||||
!props.open,
|
||||
"bg-transparent shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
|
||||
"focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]": !props.open,
|
||||
"shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
|
||||
}}
|
||||
>
|
||||
<IconV2 name="magnifying-glass" />
|
||||
@@ -1031,6 +1075,7 @@ function HomeSessionSearch(props: {
|
||||
|
||||
function HomeSessionSearchResultRow(props: {
|
||||
record: HomeSessionRecord
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
selected: boolean
|
||||
@@ -1038,6 +1083,7 @@ function HomeSessionSearchResultRow(props: {
|
||||
onSelect: (session: Session) => void
|
||||
}) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
@@ -1064,11 +1110,11 @@ function HomeSessionSearchResultRow(props: {
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span
|
||||
class={`${HOME_SEARCH_RESULT_TITLE} ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
class={`${HOME_SEARCH_RESULT_TITLE} ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
>
|
||||
{title()}
|
||||
</span>
|
||||
<Show when={props.record.projectName}>
|
||||
<Show when={showProjectName()}>
|
||||
<span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -1079,7 +1125,7 @@ function HomeSessionSearchResultRow(props: {
|
||||
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-4 pr-2">
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-[18px]">
|
||||
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
@@ -1101,36 +1147,79 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
|
||||
|
||||
function HomeSessionRow(props: {
|
||||
record: HomeSessionRecord
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
onClick: () => void
|
||||
openSession: (session: Session) => void
|
||||
archiveSession: (session: Session) => Promise<void>
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
<span
|
||||
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
<div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
|
||||
onClick={() => props.openSession(props.record.session)}
|
||||
>
|
||||
{title()}
|
||||
</span>
|
||||
<Show when={props.record.projectName}>
|
||||
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
|
||||
{props.record.projectName}
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
<span
|
||||
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
>
|
||||
{title()}
|
||||
</span>
|
||||
<Show when={showProjectName()}>
|
||||
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
|
||||
{props.record.projectName}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionsEmpty(props: { onNewSession?: () => void }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
|
||||
<div class="shrink-0 text-[13px] leading-[13px] tracking-[-0.04px] text-v2-text-text-base [font-weight:530]">
|
||||
{language.t("home.sessions.empty")}
|
||||
</div>
|
||||
<p class="mb-1 text-center text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
{language.t("home.sessions.empty.description")}
|
||||
</p>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
<ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
|
||||
{language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1182,6 +1271,7 @@ export function LegacyHome() {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const homedir = createMemo(() => sync().data.path.home)
|
||||
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
|
||||
const recent = createMemo(() => {
|
||||
return sync()
|
||||
.data.project.slice()
|
||||
@@ -1204,6 +1294,7 @@ export function LegacyHome() {
|
||||
}
|
||||
|
||||
function chooseProject() {
|
||||
if (serverUnreachable()) return
|
||||
const s = server.current
|
||||
if (!s) return
|
||||
|
||||
@@ -1247,7 +1338,13 @@ export function LegacyHome() {
|
||||
<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>
|
||||
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
|
||||
<Button
|
||||
icon="folder-add-left"
|
||||
size="normal"
|
||||
class="pl-2 pr-3"
|
||||
disabled={serverUnreachable()}
|
||||
onClick={chooseProject}
|
||||
>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1273,7 +1370,7 @@ export function LegacyHome() {
|
||||
<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}>
|
||||
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1285,7 +1382,7 @@ export function LegacyHome() {
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
||||
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
||||
</div>
|
||||
<Button class="px-3 mt-1" onClick={chooseProject}>
|
||||
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
@@ -2115,7 +2116,6 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
<div class="shrink-0 py-4">
|
||||
<Button
|
||||
size="large"
|
||||
icon="new-session"
|
||||
class="w-full"
|
||||
onClick={() => {
|
||||
const dir = worktree()
|
||||
@@ -2123,6 +2123,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
|
||||
}}
|
||||
>
|
||||
<IconV2 name="edit" size="small" />
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import type { HomeProjectSelection } from "@/context/layout"
|
||||
|
||||
type SessionStore = {
|
||||
session?: Session[]
|
||||
@@ -56,8 +57,6 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
|
||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||
project.name || getFilename(project.worktree) || project.worktree
|
||||
|
||||
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
||||
|
||||
export function toggleHomeProjectSelection(
|
||||
current: HomeProjectSelection | undefined,
|
||||
server: ServerConnection.Key,
|
||||
|
||||
@@ -27,7 +27,17 @@ export function SessionTabAvatar(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SessionProgressIndicatorV2 class="shrink-0" />
|
||||
<span class="relative block size-4 shrink-0">
|
||||
<SessionProgressIndicatorV2 class="absolute inset-0 group-hover:invisible" />
|
||||
<span class="invisible absolute inset-0 group-hover:visible">
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
unread={state.unread()}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
@@ -300,7 +301,7 @@ export const NewSessionItem = (props: {
|
||||
}}
|
||||
>
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<Icon name="new-session" size="small" class="text-icon-weak" />
|
||||
<IconV2 name="edit" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
|
||||
</A>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -214,9 +216,10 @@ const WorkspaceActions = (props: {
|
||||
</DropdownMenu>
|
||||
<Show when={!props.touch()}>
|
||||
<Tooltip value={props.language.t("command.session.new")} placement="top">
|
||||
<IconButton
|
||||
icon="new-session"
|
||||
<IconButtonV2
|
||||
icon={<IconV2 name="edit" size="small" />}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
|
||||
data-action="workspace-new-session"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { createEffect, createMemo, onMount, untrack } from "solid-js"
|
||||
import { Show, createEffect, createMemo, createResource, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionDesignView } from "@/components/session"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import {
|
||||
PromptProjectAddButton,
|
||||
PromptProjectSelector,
|
||||
createPromptProjectController,
|
||||
} from "@/components/prompt-project-selector"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import {
|
||||
createSessionComposerControls,
|
||||
createSessionComposerState,
|
||||
SessionComposerRegion,
|
||||
} from "@/pages/session/composer"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
|
||||
/**
|
||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||
@@ -25,17 +31,25 @@ export default function NewSessionPage() {
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
const language = useLanguage()
|
||||
const route = useSessionKey()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
|
||||
useComposerCommands()
|
||||
useSettingsCommand()
|
||||
|
||||
let inputRef: HTMLDivElement | undefined
|
||||
|
||||
const composer = createSessionComposerState()
|
||||
const composerControls = createSessionComposerControls({
|
||||
const inputController = createPromptInputController({
|
||||
sessionKey: route.sessionKey,
|
||||
sessionID: () => route.params.id,
|
||||
queryOptions: serverSync().queryOptions,
|
||||
})
|
||||
const projectControls = createPromptProjectControls()
|
||||
const projectController = createPromptProjectController({
|
||||
controls: projectControls,
|
||||
onDone: () => inputRef?.focus(),
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
worktree: "main",
|
||||
@@ -58,9 +72,15 @@ export default function NewSessionPage() {
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
requestAnimationFrame(() => inputRef?.focus())
|
||||
})
|
||||
const ready = Promise.resolve()
|
||||
const [promptReady] = createResource(
|
||||
() => prompt.ready.promise ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
@@ -68,26 +88,39 @@ export default function NewSessionPage() {
|
||||
<div class="@container relative flex flex-col min-h-0 h-full bg-background-stronger flex-1">
|
||||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||
<NewSessionDesignView>
|
||||
<SessionComposerRegion
|
||||
state={composer}
|
||||
sessionKey={route.sessionKey()}
|
||||
sessionID={route.params.id}
|
||||
controls={composerControls()}
|
||||
promptInput={{
|
||||
ref: (el) => {
|
||||
inputRef = el
|
||||
},
|
||||
newSessionWorktree: newSessionWorktree(),
|
||||
onNewSessionWorktreeReset: () => setStore("worktree", "main"),
|
||||
onSubmit: () => comments.clear(),
|
||||
}}
|
||||
todo={{ collapsed: false, onToggle: () => {} }}
|
||||
ready
|
||||
centered={false}
|
||||
placement="inline"
|
||||
onResponseSubmit={() => {}}
|
||||
setPromptDockRef={() => {}}
|
||||
/>
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<Show
|
||||
when={prompt.ready() || promptReady()}
|
||||
fallback={
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak pointer-events-none">
|
||||
{language.t("prompt.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInput
|
||||
controls={inputController()}
|
||||
variant="new-session"
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
|
||||
onSubmit={() => comments.clear()}
|
||||
toolbar={
|
||||
<Show when={!projectController.selected()}>
|
||||
<PromptProjectAddButton controller={projectController} />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<Show when={projectController.selected()}>
|
||||
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
|
||||
<PromptProjectSelector controller={projectController} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</NewSessionDesignView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,10 +42,13 @@ import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
import {
|
||||
createSessionComposerControls,
|
||||
createSessionComposerState,
|
||||
createPromptInputController,
|
||||
createSessionComposerController,
|
||||
createSessionComposerRegionController,
|
||||
SessionComposerRegion,
|
||||
} from "@/pages/session/composer"
|
||||
import {
|
||||
@@ -63,6 +66,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { Identifier } from "@/utils/id"
|
||||
@@ -155,8 +159,8 @@ export default function Page() {
|
||||
},
|
||||
})
|
||||
|
||||
const composer = createSessionComposerState()
|
||||
const composerControls = createSessionComposerControls({
|
||||
const composer = createSessionComposerController()
|
||||
const inputController = createPromptInputController({
|
||||
sessionKey,
|
||||
sessionID: () => params.id,
|
||||
queryOptions: serverSync().queryOptions,
|
||||
@@ -783,6 +787,8 @@ export default function Page() {
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
useComposerCommands()
|
||||
useSettingsCommand()
|
||||
useSessionCommands({
|
||||
navigateMessageByOffset,
|
||||
setActiveMessage,
|
||||
@@ -1567,62 +1573,28 @@ export default function Page() {
|
||||
|
||||
useUsageExceededDialogs()
|
||||
|
||||
const composerRegion = (placement: "dock" | "inline") => (
|
||||
<SessionComposerRegion
|
||||
state={composer}
|
||||
sessionKey={sessionKey()}
|
||||
sessionID={params.id}
|
||||
controls={composerControls()}
|
||||
promptInput={{
|
||||
ref: (el) => {
|
||||
inputRef = el
|
||||
},
|
||||
newSessionWorktree: newSessionWorktree(),
|
||||
onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
|
||||
onSubmit: () => {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
},
|
||||
}}
|
||||
todo={{
|
||||
collapsed: view().todoCollapsed.get(),
|
||||
const composerRegion = () => {
|
||||
const controller = createSessionComposerRegionController({
|
||||
state: composer,
|
||||
sessionKey,
|
||||
sessionID: () => params.id,
|
||||
prompt,
|
||||
ready: () => !store.deferRender && messagesReady(),
|
||||
centered,
|
||||
todo: {
|
||||
collapsed: () => view().todoCollapsed.get(),
|
||||
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
|
||||
}}
|
||||
ready={!store.deferRender && messagesReady()}
|
||||
centered={placement === "dock" && centered()}
|
||||
placement={placement}
|
||||
openParent={() => {
|
||||
const id = info()?.parentID
|
||||
if (!id) return
|
||||
navigate(
|
||||
params.serverKey
|
||||
? sessionHref(requireServerKey(params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
}}
|
||||
onResponseSubmit={resumeScroll}
|
||||
followup={
|
||||
},
|
||||
followup: () =>
|
||||
params.id && !isChildSession()
|
||||
? {
|
||||
queue: queueEnabled,
|
||||
items: followupDock(),
|
||||
sending: sendingFollowup(),
|
||||
edit: editingFollowup(),
|
||||
onQueue: queueFollowup,
|
||||
onAbort: () => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
setFollowup("paused", id, true)
|
||||
},
|
||||
onSend: (id) => {
|
||||
void sendFollowup(params.id!, id, { manual: true })
|
||||
},
|
||||
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
|
||||
onEdit: editFollowup,
|
||||
onEditLoaded: clearFollowupEdit,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
revert={
|
||||
: undefined,
|
||||
revert: () =>
|
||||
rolled().length > 0
|
||||
? {
|
||||
items: rolled(),
|
||||
@@ -1630,13 +1602,53 @@ export default function Page() {
|
||||
disabled: reverting(),
|
||||
onRestore: restore,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
setPromptDockRef={(el) => {
|
||||
: undefined,
|
||||
onResponseSubmit: resumeScroll,
|
||||
openParent: () => {
|
||||
const id = info()?.parentID
|
||||
if (!id) return
|
||||
navigate(
|
||||
params.serverKey
|
||||
? sessionHref(requireServerKey(params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
},
|
||||
setPromptRef: (el) => {
|
||||
inputRef = el
|
||||
},
|
||||
setDockRef: (el) => {
|
||||
promptDock = el
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={controller}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={inputController()}
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||
onSubmit={() => {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
}}
|
||||
edit={editingFollowup()}
|
||||
onEditLoaded={clearFollowupEdit}
|
||||
shouldQueue={queueEnabled}
|
||||
onQueue={queueFollowup}
|
||||
onAbort={() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
setFollowup("paused", id, true)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const mobileTabs = (compact = false, bottom = false) => (
|
||||
<Tabs value={store.mobileTab} class="h-auto">
|
||||
@@ -1773,7 +1785,7 @@ export default function Page() {
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { SessionComposerRegion } from "./session-composer-region"
|
||||
export { createSessionComposerControls } from "./session-composer-controls"
|
||||
export { createSessionComposerState } from "./session-composer-state"
|
||||
export { createPromptInputController, createPromptProjectControls } from "./session-composer-controls"
|
||||
export { createSessionComposerController } from "./session-composer-state"
|
||||
export { createSessionComposerRegionController } from "./session-composer-region-controller"
|
||||
|
||||
@@ -3,88 +3,37 @@ import { createQuery } from "@tanstack/solid-query"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import type { PromptInputControls } from "@/components/prompt-input"
|
||||
import type { PromptProjectControls } from "@/components/prompt-project-selector"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import type { QueryOptionsApi } from "@/context/server-sync"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { serverName, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { type DraftTab, useTabs } from "@/context/tabs"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
export function createSessionComposerControls(input: {
|
||||
export function createPromptInputController(input: {
|
||||
sessionKey: Accessor<string>
|
||||
sessionID: Accessor<string | undefined>
|
||||
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const providers = useProviders()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const view = layout.view(input.sessionKey)
|
||||
|
||||
const draft = createMemo(() => {
|
||||
if (!search.draftId) return
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
})
|
||||
const projectServer = createMemo(() => {
|
||||
if (!search.draftId) return server.current
|
||||
const target = draft()?.server
|
||||
if (!target) return
|
||||
return server.list.find((conn) => ServerConnection.key(conn) === target)
|
||||
})
|
||||
const projectServerCtx = createMemo(() => {
|
||||
const conn = projectServer()
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
const projects = createMemo(() =>
|
||||
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
|
||||
)
|
||||
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
|
||||
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
|
||||
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
|
||||
|
||||
const selectProject = (worktree: string) => {
|
||||
const conn = projectServer()
|
||||
const target = projectServerCtx()
|
||||
if (search.draftId) {
|
||||
if (!conn || !target) return
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
||||
return
|
||||
}
|
||||
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
|
||||
const addProject = (title: string) => {
|
||||
const conn = projectServer()
|
||||
if (!conn) return
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
if (directory) selectProject(directory)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return createMemo<PromptInputControls>(() => ({
|
||||
agents: {
|
||||
available: sync().data.agent,
|
||||
@@ -99,12 +48,6 @@ export function createSessionComposerControls(input: {
|
||||
paid: providers.paid().length > 0,
|
||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||
},
|
||||
projects: {
|
||||
available: projects(),
|
||||
directory: sdk().directory,
|
||||
select: selectProject,
|
||||
add: addProject,
|
||||
},
|
||||
session: {
|
||||
id: input.sessionID(),
|
||||
tabs: layout.tabs(input.sessionKey),
|
||||
@@ -113,3 +56,75 @@ export function createSessionComposerControls(input: {
|
||||
newLayoutDesigns: settings.general.newLayoutDesigns(),
|
||||
}))
|
||||
}
|
||||
|
||||
export function createPromptProjectControls() {
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
const server = useServer()
|
||||
const serverSDK = useServerSDK()
|
||||
const sdk = useSDK()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const projectServer = () => serverSDK().server
|
||||
const projectServerCtx = createMemo(() => global.ensureServerCtx(projectServer()))
|
||||
const projects = createMemo(() => {
|
||||
if (server.list.length <= 1) {
|
||||
return search.draftId ? projectServerCtx().projects.list() : layout.projects.list()
|
||||
}
|
||||
return server.list.flatMap((conn) => {
|
||||
const item = { key: ServerConnection.key(conn), name: serverName(conn) }
|
||||
return global
|
||||
.ensureServerCtx(conn)
|
||||
.projects.list()
|
||||
.map((project) => ({ ...project, server: item }))
|
||||
})
|
||||
})
|
||||
const selectProject = (worktree: string, serverKey?: string) => {
|
||||
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
|
||||
if (search.draftId) {
|
||||
if (!conn) return
|
||||
const target = global.ensureServerCtx(conn)
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
||||
return
|
||||
}
|
||||
|
||||
if (!serverKey) {
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!conn) return
|
||||
const target = global.ensureServerCtx(conn)
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
server.setActive(ServerConnection.key(conn))
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
|
||||
const addProject = (title: string, serverKey?: string) => {
|
||||
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
|
||||
if (!conn) return
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
if (directory) selectProject(directory, serverKey)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return createMemo<PromptProjectControls>(() => ({
|
||||
available: projects(),
|
||||
directory: sdk().directory,
|
||||
server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
|
||||
select: selectProject,
|
||||
add: addProject,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PromptInputState } from "@/components/prompt-input"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import type { SessionComposerController } from "./session-composer-state"
|
||||
|
||||
export type SessionComposerFollowupDock = {
|
||||
items: { id: string; text: string }[]
|
||||
sending?: string
|
||||
onSend: (id: string) => void
|
||||
onEdit: (id: string) => void
|
||||
}
|
||||
|
||||
export type SessionComposerRevertDock = {
|
||||
items: { id: string; text: string }[]
|
||||
restoring?: string
|
||||
disabled?: boolean
|
||||
onRestore: (id: string) => void
|
||||
}
|
||||
|
||||
export function createSessionComposerRegionController(input: {
|
||||
state: SessionComposerController
|
||||
sessionKey: Accessor<string>
|
||||
sessionID: Accessor<string | undefined>
|
||||
prompt: PromptInputState
|
||||
ready: Accessor<boolean>
|
||||
centered: Accessor<boolean>
|
||||
todo: {
|
||||
collapsed: Accessor<boolean>
|
||||
onToggle: () => void
|
||||
}
|
||||
followup: Accessor<SessionComposerFollowupDock | undefined>
|
||||
revert: Accessor<SessionComposerRevertDock | undefined>
|
||||
onResponseSubmit: () => void
|
||||
openParent: () => void
|
||||
setPromptRef: (el: HTMLDivElement) => void
|
||||
setDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const sync = useSync()
|
||||
const [store, setStore] = createStore({
|
||||
ready: input.ready() || input.state.dock(),
|
||||
height: 320,
|
||||
body: undefined as HTMLDivElement | undefined,
|
||||
})
|
||||
let timer: number | undefined
|
||||
let frame: number | undefined
|
||||
|
||||
const clear = () => {
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
timer = undefined
|
||||
frame = undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
input.sessionKey()
|
||||
const ready = input.ready()
|
||||
const dock = input.state.dock()
|
||||
|
||||
clear()
|
||||
if (store.ready || (!ready && !dock)) return
|
||||
if (dock) {
|
||||
setStore("ready", true)
|
||||
return
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
timer = window.setTimeout(() => {
|
||||
setStore("ready", true)
|
||||
timer = undefined
|
||||
}, 140)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input.prompt.ready()) return
|
||||
setSessionHandoff(input.sessionKey(), {
|
||||
prompt: input.prompt
|
||||
.current()
|
||||
.map((part) => {
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
if (part.type === "image") return `[image:${part.filename}]`
|
||||
return part.content
|
||||
})
|
||||
.join("")
|
||||
.trim(),
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const el = store.body
|
||||
if (!el) return
|
||||
const update = () => setStore("height", el.getBoundingClientRect().height)
|
||||
createResizeObserver(el, update)
|
||||
update()
|
||||
})
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
const parentID = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
return id ? sync().session.get(id)?.parentID : undefined
|
||||
})
|
||||
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
|
||||
const progress = useSpring(
|
||||
() => (open() ? 1 : 0),
|
||||
{ visualDuration: 0.3, bounce: 0 },
|
||||
() => `${input.sessionKey()}\0${store.ready}`,
|
||||
)
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const ready = Promise.resolve()
|
||||
const [promptReady] = createResource(
|
||||
() => input.prompt.ready.promise ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return {
|
||||
state: input.state,
|
||||
centered: input.centered,
|
||||
todo: input.todo,
|
||||
followup: input.followup,
|
||||
revert: input.revert,
|
||||
onResponseSubmit: input.onResponseSubmit,
|
||||
openParent: input.openParent,
|
||||
setPromptRef: input.setPromptRef,
|
||||
setDockRef: input.setDockRef,
|
||||
parentID,
|
||||
child: () => !!parentID(),
|
||||
showComposer: () => !input.state.blocked() || !!parentID(),
|
||||
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
|
||||
promptReady: () => input.prompt.ready() || promptReady(),
|
||||
dock: () => (store.ready && input.state.dock()) || value() > 0.001,
|
||||
dockProgress: value,
|
||||
dockHeight: () => Math.max(78, store.height),
|
||||
lift: () => (input.revert()?.items.length ? 18 : 36 * value()),
|
||||
setDockBodyRef: (el: HTMLDivElement) => setStore("body", el),
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionComposerRegionController = ReturnType<typeof createSessionComposerRegionController>
|
||||
@@ -1,216 +1,83 @@
|
||||
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { PromptInput, type PromptInputControls, type PromptInputProps } from "@/components/prompt-input"
|
||||
import { Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
|
||||
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 type { SessionComposerRegionController } from "./session-composer-region-controller"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
sessionKey: string
|
||||
sessionID?: string
|
||||
controls: PromptInputControls
|
||||
promptInput: Omit<PromptInputProps, "controls" | "variant">
|
||||
todo: {
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
ready: boolean
|
||||
centered: boolean
|
||||
placement?: "dock" | "inline"
|
||||
openParent?: () => void
|
||||
onResponseSubmit: () => void
|
||||
followup?: {
|
||||
queue: () => boolean
|
||||
items: { id: string; text: string }[]
|
||||
sending?: string
|
||||
edit?: { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] }
|
||||
onQueue: (draft: FollowupDraft) => void
|
||||
onAbort: () => void
|
||||
onSend: (id: string) => void
|
||||
onEdit: (id: string) => void
|
||||
onEditLoaded: () => void
|
||||
}
|
||||
revert?: {
|
||||
items: { id: string; text: string }[]
|
||||
restoring?: string
|
||||
disabled?: boolean
|
||||
onRestore: (id: string) => void
|
||||
}
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
controller: SessionComposerRegionController
|
||||
promptInput: JSX.Element
|
||||
}) {
|
||||
const prompt = props.promptInput.state ?? usePrompt()
|
||||
const language = useLanguage()
|
||||
const sync = useSync()
|
||||
|
||||
const handoffPrompt = createMemo(() => getSessionHandoff(props.sessionKey)?.prompt)
|
||||
const info = createMemo(() => (props.sessionID ? sync().session.get(props.sessionID) : undefined))
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const child = createMemo(() => !!parentID())
|
||||
const showComposer = createMemo(() => !props.state.blocked() || child())
|
||||
|
||||
const previewPrompt = () =>
|
||||
prompt
|
||||
.current()
|
||||
.map((part) => {
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
if (part.type === "image") return `[image:${part.filename}]`
|
||||
return part.content
|
||||
})
|
||||
.join("")
|
||||
.trim()
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
setSessionHandoff(props.sessionKey, { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
ready: props.ready || props.state.dock(),
|
||||
height: 320,
|
||||
body: undefined as HTMLDivElement | undefined,
|
||||
})
|
||||
let timer: number | undefined
|
||||
let frame: number | undefined
|
||||
|
||||
const clear = () => {
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
if (frame !== undefined) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
const controller = props.controller
|
||||
const rolled = () => {
|
||||
const revert = controller.revert()
|
||||
return revert?.items.length ? revert : undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.sessionKey
|
||||
const ready = props.ready
|
||||
const dock = props.state.dock()
|
||||
const delay = 140
|
||||
|
||||
clear()
|
||||
if (store.ready || (!ready && !dock)) return
|
||||
if (dock) {
|
||||
setStore("ready", true)
|
||||
return
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
timer = window.setTimeout(() => {
|
||||
setStore("ready", true)
|
||||
timer = undefined
|
||||
}, delay)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
|
||||
const progress = useSpring(
|
||||
() => (open() ? 1 : 0),
|
||||
{ visualDuration: 0.3, bounce: 0 },
|
||||
() => `${props.sessionKey}\0${store.ready}`,
|
||||
)
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
|
||||
const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
|
||||
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
|
||||
const full = createMemo(() => Math.max(78, store.height))
|
||||
|
||||
createEffect(() => {
|
||||
const el = store.body
|
||||
if (!el) return
|
||||
const update = () => setStore("height", el.getBoundingClientRect().height)
|
||||
createResizeObserver(store.body, update)
|
||||
update()
|
||||
})
|
||||
|
||||
const ready = Promise.resolve()
|
||||
const [promptReadyResource] = createResource(
|
||||
() => prompt.ready.promise ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.setPromptDockRef}
|
||||
ref={controller.setDockRef}
|
||||
data-component="session-prompt-dock"
|
||||
classList={{
|
||||
"w-full flex flex-col justify-center items-center pointer-events-none": true,
|
||||
"shrink-0 pb-3 bg-background-stronger": props.placement !== "inline",
|
||||
}}
|
||||
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 bg-background-stronger pointer-events-none"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-full pointer-events-auto": true,
|
||||
"px-3": props.placement !== "inline",
|
||||
[NEW_SESSION_CONTENT_WIDTH]: props.placement === "inline",
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
"w-full px-3 pointer-events-auto": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": controller.centered(),
|
||||
}}
|
||||
>
|
||||
<Show when={props.state.questionRequest()} keyed>
|
||||
<Show when={controller.state.questionRequest()} keyed>
|
||||
{(request) => (
|
||||
<div>
|
||||
<SessionQuestionDock request={request} onSubmit={props.onResponseSubmit} />
|
||||
<SessionQuestionDock request={request} onSubmit={controller.onResponseSubmit} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={props.state.permissionRequest()} keyed>
|
||||
<Show when={controller.state.permissionRequest()} keyed>
|
||||
{(request) => (
|
||||
<div>
|
||||
<SessionPermissionDock
|
||||
request={request}
|
||||
responding={props.state.permissionResponding()}
|
||||
responding={controller.state.permissionResponding()}
|
||||
onDecide={(response) => {
|
||||
props.onResponseSubmit()
|
||||
props.state.decide(response)
|
||||
controller.onResponseSubmit()
|
||||
controller.state.decide(response)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={showComposer()}>
|
||||
<Show when={dock()}>
|
||||
<Show when={controller.showComposer()}>
|
||||
<Show when={controller.dock()}>
|
||||
<div
|
||||
classList={{
|
||||
"overflow-hidden": true,
|
||||
"pointer-events-none": value() < 0.98,
|
||||
"pointer-events-none": controller.dockProgress() < 0.98,
|
||||
}}
|
||||
style={{
|
||||
"max-height": `${full() * value()}px`,
|
||||
"max-height": `${controller.dockHeight() * controller.dockProgress()}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={(el) => setStore("body", el)}>
|
||||
<div ref={controller.setDockBodyRef}>
|
||||
<SessionTodoDock
|
||||
todos={props.state.todos()}
|
||||
collapsed={props.todo.collapsed}
|
||||
onToggle={props.todo.onToggle}
|
||||
todos={controller.state.todos()}
|
||||
collapsed={controller.todo.collapsed()}
|
||||
onToggle={controller.todo.onToggle}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={value()}
|
||||
dockProgress={controller.dockProgress()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={prompt.ready() || promptReadyResource()}
|
||||
when={controller.promptReady()}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={rolled()} keyed>
|
||||
@@ -227,9 +94,9 @@ export function SessionComposerRegion(props: {
|
||||
</Show>
|
||||
<div
|
||||
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
|
||||
style={{ "margin-top": `${-36 * value()}px` }}
|
||||
style={{ "margin-top": `${-36 * controller.dockProgress()}px` }}
|
||||
>
|
||||
{handoffPrompt() || language.t("prompt.loading")}
|
||||
{controller.handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
@@ -238,7 +105,7 @@ export function SessionComposerRegion(props: {
|
||||
{(revert) => (
|
||||
<div
|
||||
style={{
|
||||
"margin-top": `${-36 * value()}px`,
|
||||
"margin-top": `${-36 * controller.dockProgress()}px`,
|
||||
}}
|
||||
>
|
||||
<SessionRevertDock
|
||||
@@ -252,47 +119,34 @@ export function SessionComposerRegion(props: {
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-10": true,
|
||||
"relative z-30": true,
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${-lift()}px`,
|
||||
"margin-top": `${-controller.lift()}px`,
|
||||
}}
|
||||
>
|
||||
<Show when={props.followup?.items.length}>
|
||||
<Show when={controller.followup()?.items.length}>
|
||||
<SessionFollowupDock
|
||||
items={props.followup!.items}
|
||||
sending={props.followup!.sending}
|
||||
onSend={props.followup!.onSend}
|
||||
onEdit={props.followup!.onEdit}
|
||||
items={controller.followup()!.items}
|
||||
sending={controller.followup()!.sending}
|
||||
onSend={controller.followup()!.onSend}
|
||||
onEdit={controller.followup()!.onEdit}
|
||||
/>
|
||||
</Show>
|
||||
<Show
|
||||
when={child()}
|
||||
fallback={
|
||||
<Show when={!props.state.blocked()}>
|
||||
<PromptInput
|
||||
{...props.promptInput}
|
||||
controls={props.controls}
|
||||
variant={props.placement === "inline" ? "new-session" : undefined}
|
||||
edit={props.followup?.edit}
|
||||
onEditLoaded={props.followup?.onEditLoaded}
|
||||
shouldQueue={props.followup?.queue}
|
||||
onQueue={props.followup?.onQueue}
|
||||
onAbort={props.followup?.onAbort}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
when={controller.child()}
|
||||
fallback={<Show when={!controller.state.blocked()}>{props.promptInput}</Show>}
|
||||
>
|
||||
<div
|
||||
ref={props.promptInput.ref}
|
||||
ref={controller.setPromptRef}
|
||||
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
||||
>
|
||||
<span>{language.t("session.child.promptDisabled")} </span>
|
||||
<Show when={parentID() && props.openParent}>
|
||||
<Show when={controller.parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-base transition-colors hover:text-text-strong"
|
||||
onClick={props.openParent}
|
||||
onClick={controller.openParent}
|
||||
>
|
||||
{language.t("session.child.backToParent")}
|
||||
</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
||||
export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
@@ -201,4 +201,4 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionComposerState = ReturnType<typeof createSessionComposerState>
|
||||
export type SessionComposerController = ReturnType<typeof createSessionComposerController>
|
||||
|
||||
@@ -432,21 +432,23 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={total() > 1}>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
|
||||
@@ -3,7 +3,13 @@ import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { useServerSync } from "@/context/global-sync"
|
||||
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import {
|
||||
SessionComposerRegion,
|
||||
createSessionComposerController,
|
||||
createSessionComposerRegionController,
|
||||
} from "@/pages/session/composer"
|
||||
|
||||
export default {
|
||||
title: "UI/Todo Panel Motion",
|
||||
@@ -60,7 +66,6 @@ const controls = {
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
|
||||
@@ -149,6 +154,7 @@ const css = `
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const global = useServerSync()
|
||||
const prompt = usePrompt()
|
||||
const [cfg, setCfg] = createStore({
|
||||
open: true,
|
||||
collapsed: false,
|
||||
@@ -188,7 +194,7 @@ export const Playground = {
|
||||
const countMask = () => cfg.countMask
|
||||
const countMaskHeight = () => cfg.countMaskHeight
|
||||
const countWidthDuration = () => cfg.countWidthDuration
|
||||
const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
||||
const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
||||
let frame
|
||||
let scrollRef
|
||||
|
||||
@@ -217,7 +223,6 @@ export const Playground = {
|
||||
|
||||
const collapsed = () => cfg.collapsed
|
||||
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
|
||||
|
||||
const openDock = () => {
|
||||
clear()
|
||||
setCfg("open", true)
|
||||
@@ -281,36 +286,30 @@ export const Playground = {
|
||||
|
||||
<div>
|
||||
<SessionComposerRegion
|
||||
state={state}
|
||||
sessionKey="story-session"
|
||||
sessionID="story-session"
|
||||
controls={controls}
|
||||
promptInput={{
|
||||
submission: { abort: () => {}, handleSubmit: (event) => event.preventDefault() },
|
||||
ref: () => {},
|
||||
newSessionWorktree: "",
|
||||
onNewSessionWorktreeReset: () => {},
|
||||
}}
|
||||
todo={{ collapsed: collapsed(), onToggle: () => setCollapsed(!collapsed()) }}
|
||||
ready
|
||||
centered={false}
|
||||
onResponseSubmit={pin}
|
||||
setPromptDockRef={() => {}}
|
||||
dockOpenVisualDuration={dockOpenDuration()}
|
||||
dockOpenBounce={dockOpenBounce()}
|
||||
dockCloseVisualDuration={dockCloseDuration()}
|
||||
dockCloseBounce={dockCloseBounce()}
|
||||
drawerExpandVisualDuration={drawerExpandDuration()}
|
||||
drawerExpandBounce={drawerExpandBounce()}
|
||||
drawerCollapseVisualDuration={drawerCollapseDuration()}
|
||||
drawerCollapseBounce={drawerCollapseBounce()}
|
||||
subtitleDuration={subtitleDuration()}
|
||||
subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()}
|
||||
subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()}
|
||||
countDuration={countDuration()}
|
||||
countMask={countMask()}
|
||||
countMaskHeight={countMaskHeight()}
|
||||
countWidthDuration={countWidthDuration()}
|
||||
controller={createSessionComposerRegionController({
|
||||
state,
|
||||
sessionKey: () => "story-session",
|
||||
sessionID: () => "story-session",
|
||||
prompt,
|
||||
ready: () => true,
|
||||
centered: () => false,
|
||||
todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) },
|
||||
followup: () => undefined,
|
||||
revert: () => undefined,
|
||||
onResponseSubmit: pin,
|
||||
openParent: () => {},
|
||||
setPromptRef: () => {},
|
||||
setDockRef: () => {},
|
||||
})}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={controls}
|
||||
submission={{ abort: () => {}, handleSubmit: (event) => event.preventDefault() }}
|
||||
ref={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -293,7 +293,7 @@ export function TerminalPanel() {
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={terminal.active()} keyed>
|
||||
<Show when={opened() && terminal.active()} keyed>
|
||||
{(id) => {
|
||||
const ops = terminal.bind()
|
||||
return (
|
||||
|
||||
@@ -34,7 +34,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
@@ -66,9 +65,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { makeTimer } from "@solid-primitives/timer"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
@@ -277,25 +274,7 @@ export function MessageTimeline(props: {
|
||||
if (!id) return idle
|
||||
return sync().data.session_status[id] ?? idle
|
||||
})
|
||||
const working = createMemo(() => sessionStatus().type !== "idle")
|
||||
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
|
||||
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync().data.agent))
|
||||
|
||||
const [timeoutDone, setTimeoutDone] = createSignal(true)
|
||||
|
||||
const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
|
||||
if (working()) return "showing"
|
||||
if (prev === "showing" || !timeoutDone()) return "hiding"
|
||||
return "hidden"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (workingStatus() !== "hiding") return
|
||||
|
||||
setTimeoutDone(false)
|
||||
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
|
||||
})
|
||||
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
@@ -1239,26 +1218,56 @@ export function MessageTimeline(props: {
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"bottom-8": settings.general.newLayoutDesigns(),
|
||||
"bottom-6": !settings.general.newLayoutDesigns(),
|
||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump,
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
|
||||
"opacity-0 translate-y-2 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
|
||||
"scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && settings.general.newLayoutDesigns(),
|
||||
"scale-95": (!props.scroll.overflow || !props.scroll.jump) && !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
|
||||
onClick={props.onResumeScroll}
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("session.messages.jumpToLatest")}
|
||||
class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||
style={{
|
||||
"box-shadow":
|
||||
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||
}}
|
||||
>
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("session.messages.jumpToLatest")}
|
||||
class="pointer-events-auto flex items-center justify-center w-8 h-7 px-2 py-1.5 rounded-lg border-none cursor-pointer text-v2-text-text-base backdrop-blur-[2px]"
|
||||
style={{
|
||||
"box-shadow":
|
||||
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||
background: "color-mix(in srgb, var(--v2-background-bg-base) 92%, transparent)",
|
||||
"box-shadow": "var(--v2-elevation-raised), 0px 2px 8px var(--v2-background-bg-base)",
|
||||
}}
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
</div>
|
||||
</button>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12.3333 8.66665L8 13L3.66667 8.66665M8 12.6667V2.83332"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<ScrollView
|
||||
viewportRef={bindListRoot}
|
||||
@@ -1308,23 +1317,6 @@ export function MessageTimeline(props: {
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{
|
||||
width: working() ? "16px" : "0px",
|
||||
"margin-right": working() ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Show when={workingStatus() !== "hidden"}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{ "opacity-0": workingStatus() === "hiding" }}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useSessionLayout } from "./session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
|
||||
const withCategory = (category: string) => {
|
||||
return (option: Omit<CommandOption, "category">): CommandOption => ({
|
||||
...option,
|
||||
category,
|
||||
})
|
||||
}
|
||||
|
||||
export const useComposerCommands = () => {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
const settings = useSettings()
|
||||
const { sessionKey } = useSessionLayout()
|
||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||
const modelCommand = withCategory(language.t("command.category.model"))
|
||||
const agentCommand = withCategory(language.t("command.category.agent"))
|
||||
|
||||
const chooseModel = async () => {
|
||||
const owner = sessionOwnership.capture()
|
||||
const { DialogSelectModel } = await import("@/components/dialog-select-model")
|
||||
owner.run(() => {
|
||||
void dialog.show(() => <DialogSelectModel model={local.model} />)
|
||||
})
|
||||
}
|
||||
|
||||
command.register("composer", () => [
|
||||
modelCommand({
|
||||
id: "model.choose",
|
||||
title: language.t("command.model.choose"),
|
||||
description: language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: chooseModel,
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.variant.cycle",
|
||||
title: language.t("command.model.variant.cycle"),
|
||||
description: language.t("command.model.variant.cycle.description"),
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => local.model.variant.cycle(),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: language.t("command.agent.cycle"),
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
disabled: !settings.visibility.customAgents(),
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle.reverse",
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
disabled: !settings.visibility.customAgents(),
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
])
|
||||
}
|
||||
@@ -136,9 +136,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const contextCommand = withCategory(language.t("command.category.context"))
|
||||
const viewCommand = withCategory(language.t("command.category.view"))
|
||||
const terminalCommand = withCategory(language.t("command.category.terminal"))
|
||||
const modelCommand = withCategory(language.t("command.category.model"))
|
||||
const mcpCommand = withCategory(language.t("command.category.mcp"))
|
||||
const agentCommand = withCategory(language.t("command.category.agent"))
|
||||
const permissionsCommand = withCategory(language.t("command.category.permissions"))
|
||||
|
||||
const isAutoAcceptActive = () => {
|
||||
@@ -271,13 +269,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
view().terminal.open()
|
||||
}
|
||||
|
||||
const chooseModel = () => {
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-model"),
|
||||
(x) => dialog.show(() => <x.DialogSelectModel model={local.model} />),
|
||||
)
|
||||
}
|
||||
|
||||
const chooseMcp = () => {
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-mcp"),
|
||||
@@ -555,24 +546,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}),
|
||||
]
|
||||
|
||||
const modelCmds = () => [
|
||||
modelCommand({
|
||||
id: "model.choose",
|
||||
title: language.t("command.model.choose"),
|
||||
description: language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: chooseModel,
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.variant.cycle",
|
||||
title: language.t("command.model.variant.cycle"),
|
||||
description: language.t("command.model.variant.cycle.description"),
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => local.model.variant.cycle(),
|
||||
}),
|
||||
]
|
||||
|
||||
const mcpCmds = () => [
|
||||
mcpCommand({
|
||||
id: "mcp.toggle",
|
||||
@@ -584,26 +557,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}),
|
||||
]
|
||||
|
||||
const agentCmds = () => [
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: language.t("command.agent.cycle"),
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
disabled: !settings.visibility.customAgents(),
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle.reverse",
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
disabled: !settings.visibility.customAgents(),
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
]
|
||||
|
||||
const permissionsCmds = () => [
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
@@ -624,9 +577,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
...viewCmds(),
|
||||
...terminalCmds(),
|
||||
...messageCmds(),
|
||||
...modelCmds(),
|
||||
...mcpCmds(),
|
||||
...agentCmds(),
|
||||
...permissionsCmds(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotFoundError } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors"
|
||||
import { formatServerError, parseReadableConfigInvalidError } from "./server-errors"
|
||||
import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./server-errors"
|
||||
|
||||
function fill(text: string, vars?: Record<string, string | number>) {
|
||||
if (!vars) return text
|
||||
@@ -142,3 +143,33 @@ describe("formatServerError", () => {
|
||||
expect(formatServerError(wrapped, language.t)).toBe("Arquivo de config em config invalido: Missing host")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSessionNotFoundError", () => {
|
||||
test("matches an SDK-wrapped error for the requested session", () => {
|
||||
const body = {
|
||||
_tag: "SessionNotFoundError",
|
||||
sessionID: "ses_missing",
|
||||
message: "Session not found",
|
||||
} satisfies SessionNotFoundError
|
||||
|
||||
expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), body.sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects errors for other sessions and other 404 responses", () => {
|
||||
const body = {
|
||||
_tag: "SessionNotFoundError",
|
||||
sessionID: "ses_parent",
|
||||
message: "Session not found",
|
||||
} satisfies SessionNotFoundError
|
||||
|
||||
expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), "ses_tab")).toBe(false)
|
||||
expect(
|
||||
isSessionNotFoundError(
|
||||
new Error("Provider not found", {
|
||||
cause: { body: { _tag: "ProviderNotFoundError", providerID: "missing" }, status: 404 },
|
||||
}),
|
||||
"ses_tab",
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,13 @@ function unwrapNamedError(error: unknown): unknown {
|
||||
return error
|
||||
}
|
||||
|
||||
export function isSessionNotFoundError(error: unknown, sessionID: string) {
|
||||
const unwrapped = unwrapNamedError(error)
|
||||
if (typeof unwrapped !== "object" || unwrapped === null) return false
|
||||
const value = unwrapped as Record<string, unknown>
|
||||
return value._tag === "SessionNotFoundError" && value.sessionID === sessionID
|
||||
}
|
||||
|
||||
function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
|
||||
if (typeof error !== "object" || error === null) return false
|
||||
const o = error as Record<string, unknown>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
["client-error.ts", "client.ts", "index.ts"]
|
||||
[
|
||||
"client-error.ts",
|
||||
"client.ts",
|
||||
"index.ts"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Stream, Schema } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
||||
@@ -55,43 +55,49 @@ const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1In
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] }
|
||||
const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) =>
|
||||
const Endpoint0_2 = (raw: RawClient["server.session"]) => () =>
|
||||
raw["session.active"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
type Endpoint0_3Input = { readonly sessionID: Endpoint0_3Request["params"]["sessionID"] }
|
||||
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
|
||||
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
type Endpoint0_3Input = {
|
||||
readonly sessionID: Endpoint0_3Request["params"]["sessionID"]
|
||||
readonly agent: Endpoint0_3Request["payload"]["agent"]
|
||||
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
type Endpoint0_4Input = {
|
||||
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
||||
readonly agent: Endpoint0_4Request["payload"]["agent"]
|
||||
}
|
||||
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
|
||||
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
|
||||
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
type Endpoint0_4Input = {
|
||||
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
||||
readonly model: Endpoint0_4Request["payload"]["model"]
|
||||
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
type Endpoint0_5Input = {
|
||||
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
||||
readonly model: Endpoint0_5Request["payload"]["model"]
|
||||
}
|
||||
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
|
||||
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
|
||||
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint0_5Input = {
|
||||
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint0_5Request["payload"]["id"]
|
||||
readonly prompt: Endpoint0_5Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint0_5Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint0_5Request["payload"]["resume"]
|
||||
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint0_6Input = {
|
||||
readonly sessionID: Endpoint0_6Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint0_6Request["payload"]["id"]
|
||||
readonly prompt: Endpoint0_6Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint0_6Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint0_6Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
|
||||
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
||||
@@ -100,23 +106,23 @@ const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Inp
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] }
|
||||
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
|
||||
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
|
||||
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint0_8Input = {
|
||||
readonly sessionID: Endpoint0_8Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_8Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint0_8Request["payload"]["files"]
|
||||
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint0_9Input = {
|
||||
readonly sessionID: Endpoint0_9Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_9Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint0_9Request["payload"]["files"]
|
||||
}
|
||||
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
|
||||
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
payload: { messageID: input.messageID, files: input.files },
|
||||
@@ -125,37 +131,70 @@ const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Inp
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] }
|
||||
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
|
||||
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
|
||||
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] }
|
||||
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint0_13Input = {
|
||||
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint0_13Request["query"]["after"]
|
||||
}
|
||||
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
||||
Stream.unwrap(
|
||||
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||
),
|
||||
)
|
||||
|
||||
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
|
||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint0_15Input = {
|
||||
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_15Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
||||
list: Endpoint0_0(raw),
|
||||
create: Endpoint0_1(raw),
|
||||
get: Endpoint0_2(raw),
|
||||
switchAgent: Endpoint0_3(raw),
|
||||
switchModel: Endpoint0_4(raw),
|
||||
prompt: Endpoint0_5(raw),
|
||||
compact: Endpoint0_6(raw),
|
||||
wait: Endpoint0_7(raw),
|
||||
stage: Endpoint0_8(raw),
|
||||
clear: Endpoint0_9(raw),
|
||||
commit: Endpoint0_10(raw),
|
||||
context: Endpoint0_11(raw),
|
||||
active: Endpoint0_2(raw),
|
||||
get: Endpoint0_3(raw),
|
||||
switchAgent: Endpoint0_4(raw),
|
||||
switchModel: Endpoint0_5(raw),
|
||||
prompt: Endpoint0_6(raw),
|
||||
compact: Endpoint0_7(raw),
|
||||
wait: Endpoint0_8(raw),
|
||||
stage: Endpoint0_9(raw),
|
||||
clear: Endpoint0_10(raw),
|
||||
commit: Endpoint0_11(raw),
|
||||
context: Endpoint0_12(raw),
|
||||
events: Endpoint0_13(raw),
|
||||
interrupt: Endpoint0_14(raw),
|
||||
message: Endpoint0_15(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
["client-error.ts", "client.ts", "index.ts", "types.ts"]
|
||||
[
|
||||
"client-error.ts",
|
||||
"client.ts",
|
||||
"index.ts",
|
||||
"types.ts"
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
SessionsListOutput,
|
||||
SessionsCreateInput,
|
||||
SessionsCreateOutput,
|
||||
SessionsActiveOutput,
|
||||
SessionsGetInput,
|
||||
SessionsGetOutput,
|
||||
SessionsSwitchAgentInput,
|
||||
@@ -23,6 +24,12 @@ import type {
|
||||
SessionsCommitOutput,
|
||||
SessionsContextInput,
|
||||
SessionsContextOutput,
|
||||
SessionsEventsInput,
|
||||
SessionsEventsOutput,
|
||||
SessionsInterruptInput,
|
||||
SessionsInterruptOutput,
|
||||
SessionsMessageInput,
|
||||
SessionsMessageOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -192,6 +199,17 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
active: (requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsActiveOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/active`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsGetOutput }>(
|
||||
{
|
||||
@@ -306,6 +324,40 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
|
||||
sse<SessionsEventsOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
|
||||
query: { after: input.after },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
interrupt: (input: SessionsInterruptInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsInterruptOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
message: (input: SessionsMessageInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsMessageOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session } from "../src/effect"
|
||||
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
|
||||
|
||||
test("sessions.get returns the decoded Effect projection", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
@@ -18,12 +18,30 @@ test("sessions.get returns the decoded Effect projection", async () => {
|
||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
const url = request.url
|
||||
if (url.includes("/event")) {
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (url.includes("/prompt")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
||||
}
|
||||
if (url.includes("/context")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
|
||||
}
|
||||
if (url.includes("/message/")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage })))
|
||||
}
|
||||
if (url.endsWith("/api/session/active")) {
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })),
|
||||
)
|
||||
}
|
||||
if (request.method === "POST" && url.endsWith("/api/session")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
|
||||
}
|
||||
@@ -37,6 +55,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
const page = yield* client.sessions.list({ limit: 10 })
|
||||
const active = yield* client.sessions.active()
|
||||
const created = yield* client.sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
||||
})
|
||||
@@ -53,10 +72,19 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
|
||||
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
|
||||
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
|
||||
return { page, created, admitted, context }
|
||||
const events = yield* client.sessions
|
||||
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||
.pipe(Stream.runCollect)
|
||||
yield* client.sessions.interrupt({ sessionID: Session.ID.make("ses_test") })
|
||||
const message = yield* client.sessions.message({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_model"),
|
||||
})
|
||||
return { page, active, created, admitted, context, events, message }
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||
expect(result.active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
||||
expect(result.created.id).toBe("ses_test")
|
||||
@@ -64,6 +92,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||
expect(result.context).toEqual([])
|
||||
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||
})
|
||||
|
||||
const session = {
|
||||
@@ -96,3 +126,22 @@ const admission = {
|
||||
timeCreated: 1_717_171_717_000,
|
||||
},
|
||||
}
|
||||
|
||||
const modelSwitchedMessage = {
|
||||
id: "msg_model",
|
||||
type: "model-switched",
|
||||
time: { created: 1_717_171_717_000 },
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
}
|
||||
|
||||
const modelSwitchedEvent = {
|
||||
id: "evt_model",
|
||||
type: "session.next.model.switched",
|
||||
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
|
||||
data: {
|
||||
timestamp: 1_717_171_717_000,
|
||||
sessionID: "ses_test",
|
||||
messageID: "msg_model",
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -24,8 +24,15 @@ test("session methods use the public HTTP contract", async () => {
|
||||
fetch: async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
requests.push({ url, init })
|
||||
if (url.includes("/event")) {
|
||||
return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
if (url.includes("/prompt")) return Response.json(admission)
|
||||
if (url.includes("/context")) return Response.json({ data: [] })
|
||||
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
||||
if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
|
||||
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
|
||||
if (init?.method === "POST") return new Response(null, { status: 204 })
|
||||
return Response.json({ data: [session.data], cursor: { next: "next" } })
|
||||
@@ -33,6 +40,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
})
|
||||
|
||||
const page = await client.sessions.list({ limit: "10", order: "desc" })
|
||||
const active = await client.sessions.active()
|
||||
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
||||
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||
await client.sessions.switchModel({
|
||||
@@ -47,13 +55,21 @@ test("session methods use the public HTTP contract", async () => {
|
||||
await client.sessions.compact({ sessionID: "ses_test" })
|
||||
await client.sessions.wait({ sessionID: "ses_test" })
|
||||
const context = await client.sessions.context({ sessionID: "ses_test" })
|
||||
const events = []
|
||||
for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event)
|
||||
await client.sessions.interrupt({ sessionID: "ses_test" })
|
||||
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
expect(active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
expect(context).toEqual([])
|
||||
expect(events).toEqual([modelSwitchedEvent])
|
||||
expect(message).toEqual(modelSwitchedMessage)
|
||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
|
||||
["GET", "http://localhost:3000/api/session/active"],
|
||||
["POST", "http://localhost:3000/api/session"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/agent"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/model"],
|
||||
@@ -61,8 +77,11 @@ test("session methods use the public HTTP contract", async () => {
|
||||
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||
])
|
||||
const body = requests[4]?.init?.body
|
||||
const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
|
||||
if (typeof body !== "string") throw new Error("Expected JSON request body")
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
prompt: { text: "Hello" },
|
||||
@@ -115,3 +134,22 @@ const admission = {
|
||||
timeCreated: 1_717_171_717_000,
|
||||
},
|
||||
}
|
||||
|
||||
const modelSwitchedMessage = {
|
||||
id: "msg_model",
|
||||
type: "model-switched",
|
||||
time: { created: 1_717_171_717_000 },
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
}
|
||||
|
||||
const modelSwitchedEvent = {
|
||||
id: "evt_model",
|
||||
type: "session.next.model.switched",
|
||||
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
|
||||
data: {
|
||||
timestamp: 1_717_171_717_000,
|
||||
sessionID: "ses_test",
|
||||
messageID: "msg_model",
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -196,11 +196,12 @@ export async function handler(
|
||||
providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId)
|
||||
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
|
||||
if (v === "$ip") return headers.set(k, ip)
|
||||
if (v === "$caller") return headers.set(k, `caller:${ip}`)
|
||||
if (v === "$caller") return headers.set(k, stickyId)
|
||||
if (v === "$session") return headers.set(k, sessionId)
|
||||
if (v === "$model") return headers.set(k, model)
|
||||
if (v === "$request") return headers.set(k, requestId)
|
||||
if (v === "$project") return headers.set(k, projectId)
|
||||
if (v === "$workspace" && authInfo?.workspaceID) return headers.set(k, authInfo.workspaceID)
|
||||
headers.set(k, v)
|
||||
})
|
||||
headers.delete("host")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.11",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -235,6 +235,11 @@ export const layer = Layer.effect(
|
||||
if (!record) return
|
||||
const provider = record.provider
|
||||
|
||||
// TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments.
|
||||
if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) {
|
||||
return
|
||||
}
|
||||
|
||||
if (providerID === ProviderV2.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
|
||||
|
||||
@@ -3,6 +3,15 @@ export * as ConfigMCP from "./mcp"
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Timeout extends Schema.Class<Timeout>("ConfigV2.MCP.Timeout")({
|
||||
startup: PositiveInt.pipe(Schema.optional).annotate({
|
||||
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
|
||||
}),
|
||||
request: PositiveInt.pipe(Schema.optional).annotate({
|
||||
description: "Maximum time in milliseconds to wait for each MCP request after initialization.",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
|
||||
type: Schema.Literal("local"),
|
||||
command: Schema.String.pipe(Schema.Array),
|
||||
@@ -11,7 +20,7 @@ export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
|
||||
}),
|
||||
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
timeout: PositiveInt.pipe(Schema.optional),
|
||||
timeout: Timeout.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class OAuth extends Schema.Class<OAuth>("ConfigV2.MCP.OAuth")({
|
||||
@@ -28,12 +37,12 @@ export class Remote extends Schema.Class<Remote>("ConfigV2.MCP.Remote")({
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
timeout: PositiveInt.pipe(Schema.optional),
|
||||
timeout: Timeout.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.MCP")({
|
||||
timeout: PositiveInt.pipe(Schema.optional),
|
||||
timeout: Timeout.pipe(Schema.optional),
|
||||
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -25,6 +25,8 @@ export const Plugin = define({
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
const description = typeof entry === "string" ? undefined : entry.description
|
||||
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
@@ -33,15 +35,15 @@ export const Plugin = define({
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
),
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
})
|
||||
: Reference.GitSource.make({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
...(entry.branch === undefined ? {} : { branch: entry.branch }),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionExecution } from "../session/execution"
|
||||
import { SessionEvent } from "../session/event"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { SessionStore } from "../session/store"
|
||||
import { AbsolutePath, RelativePath } from "../schema"
|
||||
import path from "path"
|
||||
|
||||
@@ -71,10 +71,11 @@ export const layer = Layer.effect(
|
||||
const git = yield* Git.Service
|
||||
const events = yield* EventV2.Service
|
||||
const project = yield* ProjectV2.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
|
||||
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
||||
const current = yield* session.get(input.sessionID)
|
||||
const current = yield* sessions.get(input.sessionID)
|
||||
if (!current) return yield* new SessionV2.NotFoundError({ sessionID: input.sessionID })
|
||||
const directory = AbsolutePath.make(input.destination.directory)
|
||||
if (current.location.directory === directory) return
|
||||
|
||||
@@ -143,6 +144,5 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionV2.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -503,6 +503,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
export const node = LayerNode.make(layer, [filesystem, path])
|
||||
export const node = LayerNode.make({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
|
||||
|
||||
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
|
||||
|
||||
@@ -60,4 +60,4 @@ export const defaultLayer = Layer.unwrap(
|
||||
}),
|
||||
).pipe(Layer.provide(Global.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layerFromPath(path()), [])
|
||||
export const node = LayerNode.make({ service: Service, layer: layerFromPath(path()), deps: [] })
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import { FileSystem, Path } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { LayerNode } from "./layer-node"
|
||||
|
||||
export const filesystem = LayerNode.make(NodeFileSystem.layer, [])
|
||||
export const path = LayerNode.make(NodePath.layer, [])
|
||||
export const httpClient = LayerNode.make(FetchHttpClient.layer, [])
|
||||
export const requestExecutor = LayerNode.make(RequestExecutor.layer, [httpClient])
|
||||
export const llmClient = LayerNode.make(LLMClient.layer, [requestExecutor])
|
||||
export const filesystem = LayerNode.make({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
||||
export const path = LayerNode.make({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
||||
export const httpClient = LayerNode.make({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
||||
export const requestExecutor = LayerNode.make({
|
||||
service: RequestExecutor.Service,
|
||||
layer: RequestExecutor.layer,
|
||||
deps: [httpClient],
|
||||
})
|
||||
export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||
|
||||
export * as LayerNodePlatform from "./layer-node-platform"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Layer } from "effect"
|
||||
import { Brand, Context, Layer } from "effect"
|
||||
|
||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||
type AnyNode = Node<unknown, unknown>
|
||||
type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]]
|
||||
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown> ? A : never
|
||||
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E> ? E : never
|
||||
type AnyNode = Node<unknown, unknown, any>
|
||||
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
||||
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
||||
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
|
||||
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
|
||||
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
|
||||
Missing<Layer.Services<Implementation>, Dependencies>,
|
||||
@@ -14,89 +14,235 @@ type CheckDependencies<Implementation extends Layer.Any, Dependencies extends No
|
||||
declare const $OutputType: unique symbol
|
||||
declare const $ErrorType: unique symbol
|
||||
|
||||
export type Node<A, E = never> = {
|
||||
export type Tier<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tier">
|
||||
|
||||
const makeTier = Brand.nominal<Tier>()
|
||||
|
||||
export type Node<A, E = never, T extends Tier | undefined = undefined> = {
|
||||
readonly kind: "layer" | "group"
|
||||
readonly name: string
|
||||
readonly service?: Context.Service.Any
|
||||
readonly implementation?: Layer.Any
|
||||
readonly dependencies: readonly AnyNode[]
|
||||
readonly tier?: T
|
||||
readonly [$OutputType]?: () => A
|
||||
readonly [$ErrorType]?: () => E
|
||||
}
|
||||
|
||||
export function make<const Implementation extends Layer.Any, const Items extends NodeList>(
|
||||
implementation: Implementation,
|
||||
dependencies: Items & CheckDependencies<Implementation, NoInfer<Items>>,
|
||||
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>> {
|
||||
return { kind: "layer", implementation: implementation as Layer.Any, dependencies }
|
||||
type NodeIdentity =
|
||||
| { readonly service: Context.Service.Any; readonly name?: never }
|
||||
| { readonly name: string; readonly service?: never }
|
||||
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
|
||||
|
||||
type NodeInput<
|
||||
Implementation extends Layer.Any,
|
||||
Items extends NodeList,
|
||||
T extends Tier | undefined = undefined,
|
||||
> = NodeIdentity & {
|
||||
readonly layer: Implementation
|
||||
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
|
||||
readonly tier?: T
|
||||
}
|
||||
|
||||
export function make<
|
||||
const Implementation extends Layer.Any,
|
||||
const Items extends NodeList,
|
||||
const T extends Tier | undefined = undefined,
|
||||
>(
|
||||
input: NodeInput<Implementation, Items, T>,
|
||||
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
|
||||
return {
|
||||
kind: "layer",
|
||||
name: input.service !== undefined ? input.service.key : input.name,
|
||||
service: input.service,
|
||||
implementation: input.layer,
|
||||
dependencies: input.deps,
|
||||
tier: input.tier,
|
||||
}
|
||||
}
|
||||
|
||||
export function group<const Items extends NodeList>(
|
||||
dependencies: Items,
|
||||
): Node<Output<Items[number]>, Error<Items[number]>> {
|
||||
return { kind: "group", dependencies }
|
||||
return { kind: "group", name: "group", dependencies }
|
||||
}
|
||||
|
||||
export type Replacement<A = unknown> = {
|
||||
readonly source: Node<A, unknown>
|
||||
readonly replacement: Node<A, unknown>
|
||||
type AllowedTierNames<Names extends readonly string[], Name extends Names[number]> = Names extends readonly [
|
||||
infer Head extends string,
|
||||
...infer Tail extends readonly string[],
|
||||
]
|
||||
? Head extends Name
|
||||
? Head | Tail[number]
|
||||
: AllowedTierNames<Tail, Name>
|
||||
: never
|
||||
|
||||
type NodeInTiers<Names extends string> = Node<unknown, unknown, Tier<Names>>
|
||||
|
||||
export interface Tiers<Names extends readonly [string, ...string[]]> {
|
||||
readonly names: Names
|
||||
readonly values: { readonly [K in Names[number]]: Tier<K> }
|
||||
readonly make: <Name extends Names[number]>(
|
||||
name: Name,
|
||||
) => <
|
||||
const Implementation extends Layer.Any,
|
||||
const Items extends NodeList<NodeInTiers<AllowedTierNames<Names, Name>>>,
|
||||
>(
|
||||
input: DistributiveOmit<NodeInput<Implementation, Items, Tier<Name>>, "tier">,
|
||||
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tier<Name>>
|
||||
}
|
||||
|
||||
export function tiers<const Names extends readonly [string, ...string[]]>(names: Names): Tiers<Names> {
|
||||
const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers<Names>["values"]
|
||||
return {
|
||||
names,
|
||||
values,
|
||||
make: ((name: Names[number]) => (input: DistributiveOmit<NodeInput<Layer.Any, NodeList, Tier>, "tier">) =>
|
||||
make({ ...input, tier: values[name] })) as Tiers<Names>["make"],
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTiers = tiers(["untiered"])
|
||||
const untiered = defaultTiers.values.untiered
|
||||
|
||||
export type Replacement = {
|
||||
readonly source: Layer.Any
|
||||
readonly replacement: Layer.Any
|
||||
}
|
||||
|
||||
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
||||
? unknown
|
||||
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
||||
|
||||
export function replaceWithNode<A, E, E2>(
|
||||
source: Node<A, E>,
|
||||
replacement: Node<NoInfer<A>, E2> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
): Replacement<A> {
|
||||
export function replace<A, E, R, E2>(
|
||||
source: Layer.Layer<A, E, R>,
|
||||
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
): Replacement {
|
||||
return { source, replacement }
|
||||
}
|
||||
|
||||
export function replace<A, E, E2>(
|
||||
source: Node<A, E>,
|
||||
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
): Replacement<A> {
|
||||
return { source, replacement: make(replacement as Layer.Layer<A, E2>, []) }
|
||||
export function buildLayer<
|
||||
A,
|
||||
E,
|
||||
const Names extends readonly [string, ...string[]] = readonly ["untiered"],
|
||||
const Built extends Layer.Any = Layer.Layer<never, never, never>,
|
||||
>(
|
||||
node: Node<A, E, any>,
|
||||
options?: {
|
||||
readonly tiers?: Tiers<Names>
|
||||
readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built
|
||||
readonly replacements?: readonly Replacement[]
|
||||
},
|
||||
): Layer.Layer<A | Layer.Success<Built>, E | Layer.Error<Built>, never> {
|
||||
const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers<Names>)
|
||||
const replacementMap = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
|
||||
const plans = plan(node, tiers, replacementMap)
|
||||
const layers: RuntimeLayer[] = tiers.names.map((name) => {
|
||||
const tier = tiers.values[name as Names[number]]
|
||||
const layers = plans.get(tier) ?? []
|
||||
return (options?.buildTier?.(name, layers) ?? combine(layers)) as RuntimeLayer
|
||||
})
|
||||
if (layers.length === 0) return Layer.empty as never
|
||||
return layers.slice(1).reduce((result, layer) => result.pipe(Layer.provideMerge(layer)), layers[0]) as never
|
||||
}
|
||||
|
||||
export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacements?: readonly Replacement[] }) {
|
||||
const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
|
||||
const cache = new Map<AnyNode, RuntimeLayer>()
|
||||
export function combine(layers: readonly Layer.Any[]): RuntimeLayer {
|
||||
return layers.reduce<RuntimeLayer>(
|
||||
(result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)),
|
||||
Layer.empty as RuntimeLayer,
|
||||
)
|
||||
}
|
||||
|
||||
function plan(
|
||||
root: AnyNode,
|
||||
tiers: Tiers<readonly [string, ...string[]]>,
|
||||
replacements: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||
) {
|
||||
const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index]))
|
||||
const plans = new Map<Tier, Layer.Any[]>()
|
||||
const activeImplementations = new Map<Tier, Map<string, AnyNode>>()
|
||||
const serviceTiers = new Map<string, Tier>()
|
||||
const visiting = new Set<AnyNode>()
|
||||
const stack: AnyNode[] = []
|
||||
const ids = new Map<AnyNode, number>()
|
||||
const boundaryVisited = new Map<AnyNode, Set<Tier>>()
|
||||
const boundaryServices = new Map<Tier, Map<string, AnyNode>>()
|
||||
|
||||
const validateBoundary = (node: AnyNode, origin: Tier) => {
|
||||
const checked = boundaryVisited.get(node) ?? new Set<Tier>()
|
||||
boundaryVisited.set(node, checked)
|
||||
if (checked.has(origin)) return false
|
||||
checked.add(origin)
|
||||
const services = boundaryServices.get(origin) ?? new Map<string, AnyNode>()
|
||||
boundaryServices.set(origin, services)
|
||||
const key = node.name
|
||||
const existing = services.get(key)
|
||||
if (existing && existing !== node) {
|
||||
throw new Error(`Tier ${origin} has conflicting implementations for ${key}`)
|
||||
}
|
||||
services.set(key, node)
|
||||
return true
|
||||
}
|
||||
|
||||
const visit = (node: AnyNode, currentTier?: Tier, origins: readonly Tier[] = []) => {
|
||||
if (node.kind === "group") {
|
||||
node.dependencies.forEach((dependency) => visit(dependency, currentTier, origins))
|
||||
return
|
||||
}
|
||||
|
||||
const tier = node.tier ?? untiered
|
||||
if (!indexes.has(tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
||||
const key = node.name
|
||||
const serviceTier = serviceTiers.get(key)
|
||||
if (serviceTier && serviceTier !== tier) {
|
||||
throw new Error(`Service ${key} belongs to both tier ${serviceTier} and tier ${tier}`)
|
||||
}
|
||||
serviceTiers.set(key, tier)
|
||||
const nextOrigins = [...origins]
|
||||
if (currentTier) {
|
||||
const current = indexes.get(currentTier)!
|
||||
const required = indexes.get(tier)!
|
||||
if (required < current) {
|
||||
throw new Error(`Tier ${currentTier} cannot depend on lower tier ${tier}`)
|
||||
}
|
||||
if (required > current) nextOrigins.push(currentTier)
|
||||
}
|
||||
const unseenOrigins = nextOrigins.filter((origin) => validateBoundary(node, origin))
|
||||
|
||||
// A node may need to be emitted more than once because the final output is a
|
||||
// flat list of layers applied with Layer.provideMerge. If another node for
|
||||
// the same service was emitted afterward, this node is no longer the active
|
||||
// implementation for subsequent consumers. Re-emitting restores the intended
|
||||
// implementation ordering while Effect memoization avoids reacquiring the layer.
|
||||
const implementations = activeImplementations.get(tier) ?? new Map<string, AnyNode>()
|
||||
activeImplementations.set(tier, implementations)
|
||||
if (implementations.get(key) === node && unseenOrigins.length === 0) return
|
||||
|
||||
const visit = (input: AnyNode): RuntimeLayer => {
|
||||
const node = replacements.get(input) ?? input
|
||||
const cached = cache.get(node)
|
||||
if (cached) return cached
|
||||
if (visiting.has(node)) {
|
||||
const start = stack.indexOf(node)
|
||||
const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ")
|
||||
throw new Error(`Cycle detected in app graph: ${cycle}`)
|
||||
throw new Error(
|
||||
`Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
|
||||
)
|
||||
}
|
||||
if (!ids.has(node)) ids.set(node, ids.size + 1)
|
||||
|
||||
visiting.add(node)
|
||||
stack.push(node)
|
||||
try {
|
||||
const dependencies = node.dependencies.map(visit)
|
||||
const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]]
|
||||
const result =
|
||||
node.kind === "group"
|
||||
? dependencies.length === 0
|
||||
? Layer.empty
|
||||
: Layer.mergeAll(...nonEmpty)
|
||||
: dependencies.length === 0
|
||||
? (node.implementation as RuntimeLayer)
|
||||
: Layer.provide(node.implementation as RuntimeLayer, nonEmpty)
|
||||
cache.set(node, result)
|
||||
return result
|
||||
node.dependencies.forEach((dependency) => visit(dependency, tier, unseenOrigins))
|
||||
const layers = plans.get(tier) ?? []
|
||||
plans.set(tier, layers)
|
||||
layers.push(replacements.get(node.implementation!) ?? node.implementation!)
|
||||
implementations.set(key, node)
|
||||
} finally {
|
||||
stack.pop()
|
||||
visiting.delete(node)
|
||||
}
|
||||
}
|
||||
|
||||
return visit(node) as unknown as Layer.Layer<A, E, never>
|
||||
visit(root)
|
||||
return plans
|
||||
}
|
||||
|
||||
function requireTier(node: AnyNode, indexes: ReadonlyMap<Tier, number>) {
|
||||
if (!node.tier || !indexes.has(node.tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
||||
}
|
||||
|
||||
export * as LayerNode from "./layer-node"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { LayerNode } from "./layer-node"
|
||||
|
||||
export const tiers = LayerNode.tiers(["location", "global"])
|
||||
|
||||
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["global"]>
|
||||
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["location"]>
|
||||
|
||||
export const makeGlobalNode = tiers.make("global")
|
||||
export const makeLocationNode = tiers.make("location")
|
||||
|
||||
export * as ScopedNode from "./scoped-node"
|
||||
@@ -569,6 +569,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
)
|
||||
|
||||
export const layer = layerWith()
|
||||
export const node = LayerNode.make(layer, [Database.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
@@ -201,7 +201,7 @@ export namespace FSUtil {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||
export const node = LayerNode.make(layer, [filesystem])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] })
|
||||
|
||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||
export function mimeType(p: string): string {
|
||||
|
||||
@@ -944,7 +944,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
|
||||
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
|
||||
@@ -77,7 +77,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
export const node = LayerNode.make(layer, [])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
||||
|
||||
export const layerWith = (input: Partial<Interface>) =>
|
||||
Layer.effect(
|
||||
|
||||
@@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, EventV2.node, httpClient])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
||||
|
||||
export * as ModelsDev from "./models-dev"
|
||||
|
||||
@@ -253,7 +253,11 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, Global.node, filesystem, EffectFlock.node])
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node],
|
||||
})
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as PluginV2 from "./plugin"
|
||||
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { PluginEvent, PluginID } from "@opencode-ai/schema/plugin"
|
||||
import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
@@ -15,13 +15,12 @@ import { Reference } from "./reference"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = PluginID
|
||||
export const ID = Plugin.ID
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Event = PluginEvent
|
||||
export const Event = Plugin.Event
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void>
|
||||
readonly add: (id: ID, effect: PluginRuntime["effect"]) => Effect.Effect<void>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
readonly wait: (id: ID) => Effect.Effect<void>
|
||||
}
|
||||
@@ -38,9 +37,9 @@ export const layer = Layer.effect(
|
||||
const loading = new Set<ID>()
|
||||
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>()
|
||||
const failures = new Map<ID, Exit.Exit<void, never>>()
|
||||
let host: Parameters<Plugin["effect"]>[0]
|
||||
let host: Parameters<PluginRuntime["effect"]>[0]
|
||||
|
||||
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) {
|
||||
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginRuntime["effect"]) {
|
||||
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
|
||||
|
||||
yield* locks.withLock(id)(
|
||||
|
||||
@@ -12,8 +12,11 @@ import { ModelV2 } from "../model"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import { Reference } from "../reference"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
|
||||
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
|
||||
const agents = yield* AgentV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
@@ -30,8 +33,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
transform: (callback) =>
|
||||
agents.transform((draft) =>
|
||||
callback({
|
||||
list: draft.list,
|
||||
get: (id) => draft.get(AgentV2.ID.make(id)),
|
||||
list: () => mutable(draft.list()),
|
||||
get: (id) => mutable(draft.get(AgentV2.ID.make(id))),
|
||||
default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
|
||||
update: (id, update) => draft.update(AgentV2.ID.make(id), update),
|
||||
remove: (id) => draft.remove(AgentV2.ID.make(id)),
|
||||
@@ -42,7 +45,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
sdk: (callback) =>
|
||||
aisdk.hook.sdk((event) => {
|
||||
const output = {
|
||||
model: event.model,
|
||||
model: mutable(event.model),
|
||||
package: event.package,
|
||||
options: event.options,
|
||||
sdk: event.sdk,
|
||||
@@ -55,7 +58,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
language: (callback) =>
|
||||
aisdk.hook.language((event) => {
|
||||
const output = {
|
||||
model: event.model,
|
||||
model: mutable(event.model),
|
||||
sdk: event.sdk,
|
||||
options: event.options,
|
||||
language: event.language,
|
||||
@@ -72,13 +75,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
catalog.transform((draft) =>
|
||||
callback({
|
||||
provider: {
|
||||
list: draft.provider.list,
|
||||
get: (id) => draft.provider.get(ProviderV2.ID.make(id)),
|
||||
list: () => mutable(draft.provider.list()),
|
||||
get: (id) => mutable(draft.provider.get(ProviderV2.ID.make(id))),
|
||||
update: (id, update) => draft.provider.update(ProviderV2.ID.make(id), update),
|
||||
remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)),
|
||||
},
|
||||
model: {
|
||||
get: (providerID, modelID) => draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
get: (providerID, modelID) =>
|
||||
mutable(draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID))),
|
||||
update: (providerID, modelID, update) =>
|
||||
draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), update),
|
||||
remove: (providerID, modelID) =>
|
||||
@@ -108,12 +112,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
transform: (callback) =>
|
||||
integration.transform((draft) =>
|
||||
callback({
|
||||
list: draft.list,
|
||||
get: (id) => draft.get(Integration.ID.make(id)),
|
||||
list: () => mutable(draft.list()),
|
||||
get: (id) => mutable(draft.get(Integration.ID.make(id))),
|
||||
update: (id, update) => draft.update(Integration.ID.make(id), update),
|
||||
remove: (id) => draft.remove(Integration.ID.make(id)),
|
||||
method: {
|
||||
list: (id) => draft.method.list(Integration.ID.make(id)),
|
||||
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
|
||||
update: (input) => {
|
||||
if ("authorize" in input) {
|
||||
const methodID = Integration.MethodID.make(input.method.id)
|
||||
|
||||
@@ -238,6 +238,6 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||
export const node = LayerNode.make(layer, [CrossSpawnSpawner.node])
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
|
||||
|
||||
export * as AppProcess from "./process"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user