Compare commits

..

3 Commits

Author SHA1 Message Date
Kit Langton 603b334b7f fix(core): preserve event routing semantics 2026-06-25 12:41:44 -04:00
Kit Langton 45071a8d8e refactor(core): prove event publication branches 2026-06-25 12:04:05 -04:00
Kit Langton 95f264e04e refactor(schema): distinguish published event durability 2026-06-25 11:51:54 -04:00
725 changed files with 14893 additions and 31409 deletions
-38
View File
@@ -34,48 +34,10 @@ jobs:
const now = Date.now(); const now = Date.now();
const twoHours = 2 * 60 * 60 * 1000; const twoHours = 2 * 60 * 60 * 1000;
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
const agentLogin = 'opencode-agent[bot]';
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev',
});
const teamMembers = new Set(
Buffer.from(file.content, 'base64')
.toString()
.split('\n')
.map((line) => line.trim().toLowerCase())
.filter(Boolean)
);
function isExempt(item) {
const login = item.user?.login?.toLowerCase();
return (
login === agentLogin ||
orgMemberAssociations.has(item.author_association) ||
(login && teamMembers.has(login))
);
}
for (const item of items) { for (const item of items) {
const isPR = !!item.pull_request; const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue'; const kind = isPR ? 'PR' : 'issue';
const login = item.user?.login;
if (isExempt(item)) {
core.info(`Skipping ${kind} #${item.number}; author ${login || 'unknown'} is exempt`);
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {}
continue;
}
const { data: comments } = await github.rest.issues.listComments({ const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner, owner: context.repo.owner,
+1 -45
View File
@@ -17,31 +17,12 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Check exempt issue author
id: author
run: |
LOGIN="${{ github.event.issue.user.login }}"
ASSOCIATION="${{ github.event.issue.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
[ "$ASSOCIATION" = "OWNER" ] ||
[ "$ASSOCIATION" = "MEMBER" ] ||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
if: steps.author.outputs.skip != 'true'
- name: Install opencode - name: Install opencode
if: steps.author.outputs.skip != 'true'
run: curl -fsSL https://opencode.ai/install | bash run: curl -fsSL https://opencode.ai/install | bash
- name: Check duplicates and compliance - name: Check duplicates and compliance
if: steps.author.outputs.skip != 'true'
env: env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -57,7 +38,6 @@ jobs:
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created: opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
Issue number: ${{ github.event.issue.number }} Issue number: ${{ github.event.issue.number }}
Issue author association: ${{ github.event.issue.author_association }}
Lookup this issue with gh issue view ${{ github.event.issue.number }}. Lookup this issue with gh issue view ${{ github.event.issue.number }}.
@@ -69,8 +49,6 @@ jobs:
Check whether the issue follows our contributing guidelines and issue templates. Check whether the issue follows our contributing guidelines and issue templates.
If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues.
This project has three issue templates that every issue MUST use one of: This project has three issue templates that every issue MUST use one of:
1. Bug Report - requires a Description field with real content 1. Bug Report - requires a Description field with real content
@@ -105,7 +83,7 @@ jobs:
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with: If the issue is NOT compliant, start the comment with:
<!-- issue-compliance --> <!-- issue-compliance -->
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
@@ -151,31 +129,12 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Check exempt issue author
id: author
run: |
LOGIN="${{ github.event.issue.user.login }}"
ASSOCIATION="${{ github.event.issue.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
[ "$ASSOCIATION" = "OWNER" ] ||
[ "$ASSOCIATION" = "MEMBER" ] ||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
if: steps.author.outputs.skip != 'true'
- name: Install opencode - name: Install opencode
if: steps.author.outputs.skip != 'true'
run: curl -fsSL https://opencode.ai/install | bash run: curl -fsSL https://opencode.ai/install | bash
- name: Recheck compliance - name: Recheck compliance
if: steps.author.outputs.skip != 'true'
env: env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -189,12 +148,9 @@ jobs:
} }
run: | run: |
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited. opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
Issue author association: ${{ github.event.issue.author_association }}
Lookup this issue with gh issue view ${{ github.event.issue.number }}. Lookup this issue with gh issue view ${{ github.event.issue.number }}.
If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment.
Re-check whether the issue now follows our contributing guidelines and issue templates. Re-check whether the issue now follows our contributing guidelines and issue templates.
This project has three issue templates that every issue MUST use one of: This project has three issue templates that every issue MUST use one of:
-19
View File
@@ -16,32 +16,13 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Check exempt issue author
id: author
run: |
LOGIN="${{ github.event.issue.user.login }}"
ASSOCIATION="${{ github.event.issue.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
[ "$ASSOCIATION" = "OWNER" ] ||
[ "$ASSOCIATION" = "MEMBER" ] ||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun - name: Setup Bun
if: steps.author.outputs.skip != 'true'
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
- name: Install opencode - name: Install opencode
if: steps.author.outputs.skip != 'true'
run: curl -fsSL https://opencode.ai/install | bash run: curl -fsSL https://opencode.ai/install | bash
- name: Triage issue - name: Triage issue
if: steps.author.outputs.skip != 'true'
env: env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -3
View File
@@ -1,6 +1,4 @@
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - To regenerate the 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`. - The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
+3 -16
View File
@@ -57,21 +57,11 @@ The bounded projection of a Core-executed tool result persisted in Session histo
**Managed Tool Output File**: **Managed Tool Output File**:
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history. A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
**Model Request Options**:
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
_Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**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**: **PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
**OpenCode Client**: **OpenCode Client**:
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. The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers.
_Avoid_: Remote client _Avoid_: Remote client
**SDK Contract IR**: **SDK Contract IR**:
@@ -132,9 +122,6 @@ _Avoid_: Response envelope
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. - Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. - A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
- **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`. - 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. - 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. - 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.
@@ -154,6 +141,7 @@ _Avoid_: Response envelope
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly. - Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction. - Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client. - Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
- Event definition durability is authoritative for published payloads. Durable definitions publish and decode only with commit metadata (`aggregateID`, `seq`, and `version`); live definitions forbid that metadata. Core's pre-commit payload without an assigned sequence is a separate internal type and never reaches subscribers or projectors.
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides. - Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation. - Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently. - Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
@@ -172,9 +160,8 @@ _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. - 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. - 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.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 `MessageNotFoundError` 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 `SessionMessageNotFoundError` 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.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. - `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? - **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. - `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
+2 -28
View File
@@ -31,10 +31,6 @@
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.11", "version": "1.17.11",
"dependencies": { "dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -63,7 +59,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", "ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
@@ -83,7 +79,6 @@
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-icons-spritesheet": "3.0.1",
@@ -317,7 +312,6 @@
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
@@ -992,7 +986,6 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"solid-js": "catalog:", "solid-js": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-icons-spritesheet": "3.0.1",
@@ -1051,7 +1044,6 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
@@ -1459,20 +1451,6 @@
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="],
"@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="],
"@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="],
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="],
"@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="],
"@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="],
"@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="],
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="], "@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
@@ -2309,8 +2287,6 @@
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
"@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="],
"@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="], "@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="],
"@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="], "@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="],
@@ -3805,7 +3781,7 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="], "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
@@ -5319,8 +5295,6 @@
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="], "tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-JXQ9PAqqRlJtHa8T3ZxqdRRyxC+0ip+2wSnehvKXUbI=", "x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
"aarch64-linux": "sha256-nI+RaxDXmAcjhSjCtIvyi292xBEg0E5NXaoyGrJE69s=", "aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
"aarch64-darwin": "sha256-UleKpm7Khf3kibm4BqZJ9bmu0N2kOjNd77g1Bd2tmfw=", "aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
"x86_64-darwin": "sha256-V4AQH868dOfVhEj1mKTnZlhpZwFqBYsHS28Tx9VXHkE=" "x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
} }
} }
+1 -2
View File
@@ -153,7 +153,6 @@
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
} }
} }
@@ -1,79 +0,0 @@
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
View File
@@ -38,17 +38,12 @@
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-solid": "catalog:" "vite-plugin-solid": "catalog:"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -77,7 +72,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", "ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
+41 -93
View File
@@ -16,7 +16,6 @@ import {
type Component, type Component,
createEffect, createEffect,
createMemo, createMemo,
createRenderEffect,
createResource, createResource,
createSignal, createSignal,
ErrorBoundary, ErrorBoundary,
@@ -28,19 +27,18 @@ import {
Show, Show,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider, useServerSync } from "@/context/server-sync" import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global" import { GlobalProvider } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
import { NotificationProvider, useNotification } from "@/context/notification" import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission" import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings" import { SettingsProvider, useSettings } from "@/context/settings"
@@ -105,10 +103,10 @@ const SessionRoute = () => {
const TargetSessionRoute = () => { const TargetSessionRoute = () => {
const params = useParams<{ serverKey: string; id: string }>() const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal() const server = useServer()
const conn = createMemo(() => { const conn = createMemo(() => {
const key = requireServerKey(params.serverKey) const key = requireServerKey(params.serverKey)
return global.servers.list().find((item) => ServerConnection.key(item) === key) return server.list.find((item) => ServerConnection.key(item) === key)
}) })
return ( return (
@@ -223,27 +221,25 @@ function DraftRoute() {
} }
function ResolvedDraftRoute(props: { draft: DraftTab }) { function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal() const server = useServer()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server)) const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server))
const directory = () => props.draft.directory const directory = () => props.draft.directory
const serverKey = () => props.draft.server const serverKey = () => props.draft.server
return ( return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed> <ServerSDKProvider server={conn}>
<ServerSDKProvider server={conn}> <ServerSyncProvider server={conn}>
<ServerSyncProvider server={conn}> <TargetServerScopedProviders directory={directory}>
<TargetServerScopedProviders directory={directory}> <SDKProvider directory={directory}>
<SDKProvider directory={directory}> <DirectoryDataProvider directory={directory} server={serverKey}>
<DirectoryDataProvider directory={directory} server={serverKey}> <DraftProviders>
<DraftProviders> <NewSession />
<NewSession /> </DraftProviders>
</DraftProviders> </DirectoryDataProvider>
</DirectoryDataProvider> </SDKProvider>
</SDKProvider> </TargetServerScopedProviders>
</TargetServerScopedProviders> </ServerSyncProvider>
</ServerSyncProvider> </ServerSDKProvider>
</ServerSDKProvider>
</Show>
) )
} }
@@ -280,11 +276,10 @@ function QueryProvider(props: ParentProps) {
function BodyDesignClass() { function BodyDesignClass() {
const settings = useSettings() const settings = useSettings()
createRenderEffect(() => { createEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns() const enabled = settings.general.newLayoutDesigns()
document.body.toggleAttribute("data-new-layout", enabled)
document.body.classList.toggle("text-12-regular", !enabled) document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled) document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled) document.body.classList.toggle("text-[13px]", enabled)
@@ -301,36 +296,12 @@ function SharedProviders(props: ParentProps) {
<> <>
<BodyDesignClass /> <BodyDesignClass />
<CommandProvider> <CommandProvider>
<DesktopCommands />
<HighlightsProvider>{props.children}</HighlightsProvider> <HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider> </CommandProvider>
</> </>
) )
} }
function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
command.register("desktop", () => {
const commands: CommandOption[] = []
if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({
id: "logs.export",
title: "Export logs",
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
})
}
return commands
})
return null
}
// Server-scoped providers shared by the legacy shell and the top-level new shell. // Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{ type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined directory?: () => string | undefined
@@ -341,7 +312,9 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider directory={props.directory}> <PermissionProvider directory={props.directory}>
<LayoutProvider> <LayoutProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider>
</LayoutProvider> </LayoutProvider>
</PermissionProvider> </PermissionProvider>
) )
@@ -368,23 +341,13 @@ function NewAppLayout(props: ParentProps) {
function TargetServerScopedProviders(props: ServerScopedShellProps) { function TargetServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider directory={props.directory}> <PermissionProvider directory={props.directory}>
<MarkSessionNotificationsViewed sessionID={props.sessionID} /> <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider>
</PermissionProvider> </PermissionProvider>
) )
} }
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
const sessionID = props.sessionID?.()
if (!notification.ready() || !sessionID) return
if (notification.session.unseenCount(sessionID) === 0) return
notification.session.markViewed(sessionID)
})
return null
}
function SessionProviders(props: ParentProps) { function SessionProviders(props: ParentProps) {
return ( return (
<TerminalProvider> <TerminalProvider>
@@ -593,13 +556,11 @@ export function AppInterface(props: {
component={props.router ?? Router} component={props.router ?? Router}
root={(routerProps) => ( root={(routerProps) => (
<TabsProvider> <TabsProvider>
<NotificationProvider> <ServerShell>
<ServerShell> <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}> <NewAppLayout>{routerProps.children}</NewAppLayout>
<NewAppLayout>{routerProps.children}</NewAppLayout> </Show>
</Show> </ServerShell>
</ServerShell>
</NotificationProvider>
</TabsProvider> </TabsProvider>
)} )}
> >
@@ -627,31 +588,18 @@ function Routes() {
</Route> </Route>
<Show when={settings.general.newLayoutDesigns()}> <Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} /> <Route path="/" component={NewHome} />
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} /> <Route
path="/:dir/session/:id"
component={() => {
const server = useServer()
const { id } = useParams()
return <Navigate href={`/server/${server.key}/session/${id}`} />
}}
/>
</Show> </Show>
<Route path="/new-session" component={DraftRoute} /> <Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} /> <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>
)
}
+22 -77
View File
@@ -3,7 +3,6 @@ import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
type Mem = Performance & { type Mem = Performance & {
@@ -52,62 +51,31 @@ const bad = (n: number | undefined, limit: number, low = false) => {
const session = (path: string) => path.includes("/session") const session = (path: string) => path.includes("/session")
function Cell(props: { function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
bad?: boolean
dim?: boolean
inline?: boolean
label: string
tip: string
value: string
wide?: boolean
}) {
const content = () => (
<div
classList={{
"flex min-w-0 items-center": true,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
"justify-center text-center": !props.inline,
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
"col-span-2": !!props.wide && !props.inline,
}}
>
<div
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
>
{props.label}
</div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
)
if (props.inline) {
return (
<TooltipV2 value={props.tip} placement="top">
{content()}
</TooltipV2>
)
}
return ( return (
<Tooltip value={props.tip} placement="top"> <Tooltip value={props.tip} placement="top">
{content()} <div
classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide,
}}
>
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
<div
classList={{
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
</Tooltip> </Tooltip>
) )
} }
export function DebugBar(props: { inline?: boolean } = {}) { export function DebugBar() {
const language = useLanguage() const language = useLanguage()
const location = useLocation() const location = useLocation()
const routing = useIsRouting() const routing = useIsRouting()
@@ -133,7 +101,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
}, },
}) })
const na = () => language.t("debugBar.na").toUpperCase() const na = () => language.t("debugBar.na")
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined) const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
const heapv = () => { const heapv = () => {
const value = heap() const value = heap()
@@ -395,30 +363,15 @@ export function DebugBar(props: { inline?: boolean } = {}) {
return ( return (
<aside <aside
aria-label={language.t("debugBar.ariaLabel")} aria-label={language.t("debugBar.ariaLabel")}
classList={{ class="pointer-events-auto fixed bottom-3 right-3 z-50 hidden w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] md:block sm:bottom-4 sm:right-4 sm:w-[324px]"
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
"fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]":
!props.inline,
}}
> >
<div <div class="grid grid-cols-5 gap-px font-mono">
classList={{
"font-mono": true,
"gap-[9px]": !!props.inline,
"gap-px": !props.inline,
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
"grid-cols-5": !props.inline,
grid: !props.inline,
}}
>
<Cell <Cell
label={language.t("debugBar.nav.label")} label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")} tip={language.t("debugBar.nav.tip")}
value={navv()} value={navv()}
bad={bad(state.nav.dur, 400)} bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending} dim={state.nav.dur === undefined && !state.nav.pending}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.fps.label")} label={language.t("debugBar.fps.label")}
@@ -426,7 +379,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`} value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)} bad={bad(state.fps, 50, true)}
dim={state.fps === undefined} dim={state.fps === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.frame.label")} label={language.t("debugBar.frame.label")}
@@ -434,7 +386,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={time(state.gap) ?? na()} value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)} bad={bad(state.gap, 50)}
dim={state.gap === undefined} dim={state.gap === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.jank.label")} label={language.t("debugBar.jank.label")}
@@ -442,7 +393,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={state.jank === undefined ? na() : `${state.jank}`} value={state.jank === undefined ? na() : `${state.jank}`}
bad={bad(state.jank, 8)} bad={bad(state.jank, 8)}
dim={state.jank === undefined} dim={state.jank === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.long.label")} label={language.t("debugBar.long.label")}
@@ -450,7 +400,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={longv()} value={longv()}
bad={bad(state.long.block, 200)} bad={bad(state.long.block, 200)}
dim={state.long.count === undefined} dim={state.long.count === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.delay.label")} label={language.t("debugBar.delay.label")}
@@ -458,7 +407,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={time(state.delay) ?? na()} value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)} bad={bad(state.delay, 100)}
dim={state.delay === undefined} dim={state.delay === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.inp.label")} label={language.t("debugBar.inp.label")}
@@ -466,7 +414,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={time(state.inp) ?? na()} value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)} bad={bad(state.inp, 200)}
dim={state.inp === undefined} dim={state.inp === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.cls.label")} label={language.t("debugBar.cls.label")}
@@ -474,7 +421,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={state.cls === undefined ? na() : state.cls.toFixed(2)} value={state.cls === undefined ? na() : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)} bad={bad(state.cls, 0.1)}
dim={state.cls === undefined} dim={state.cls === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.mem.label")} label={language.t("debugBar.mem.label")}
@@ -489,7 +435,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={heapv()} value={heapv()}
bad={bad(heap(), 0.8)} bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined} dim={state.heap.used === undefined}
inline={props.inline}
wide wide
/> />
</div> </div>
@@ -65,7 +65,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | ProviderAuthAuthorization,
promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
@@ -74,7 +73,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
| { type: "method.select"; index: number } | { type: "method.select"; index: number }
| { type: "method.reset" } | { type: "method.reset" }
| { type: "auth.prompt" } | { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" } | { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string } | { type: "auth.error"; error: string }
@@ -85,7 +83,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
if (action.type === "method.select") { if (action.type === "method.select") {
draft.methodIndex = action.index draft.methodIndex = action.index
draft.authorization = undefined draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined draft.state = undefined
draft.error = undefined draft.error = undefined
return return
@@ -93,7 +90,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
if (action.type === "method.reset") { if (action.type === "method.reset") {
draft.methodIndex = undefined draft.methodIndex = undefined
draft.authorization = undefined draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined draft.state = undefined
draft.error = undefined draft.error = undefined
return return
@@ -103,12 +99,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
draft.error = undefined draft.error = undefined
return return
} }
if (action.type === "auth.inputs") {
draft.promptInputs = action.inputs
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.pending") { if (action.type === "auth.pending") {
draft.state = "pending" draft.state = "pending"
draft.error = undefined draft.error = undefined
@@ -161,15 +151,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
const method = methods()[index] const method = methods()[index]
dispatch({ type: "method.select", index }) dispatch({ type: "method.select", index })
if (method.type === "api" && method.prompts?.length) {
if (!inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.inputs", inputs })
return
}
if (method.type === "oauth") { if (method.type === "oauth") {
if (method.prompts?.length && !inputs) { if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" }) dispatch({ type: "auth.prompt" })
@@ -209,7 +190,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
} }
} }
function AuthPromptsView() { function OAuthPromptsView() {
const [formStore, setFormStore] = createStore({ const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>, value: {} as Record<string, string>,
index: 0, index: 0,
@@ -217,7 +198,8 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => { const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method() const value = method()
return value?.prompts ?? [] if (value?.type !== "oauth") return []
return value.prompts ?? []
}) })
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => { const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true if (!prompt.when) return true
@@ -248,10 +230,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
setFormStore("index", next) setFormStore("index", next)
return return
} }
if (method()?.type === "api") {
dispatch({ type: "auth.inputs", inputs: value })
return
}
await selectMethod(store.methodIndex, value) await selectMethod(store.methodIndex, value)
} }
@@ -436,7 +414,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
auth: { auth: {
type: "api", type: "api",
key: apiKey, key: apiKey,
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
}, },
}) })
await complete() await complete()
@@ -645,7 +622,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
</div> </div>
</Match> </Match>
<Match when={store.state === "prompt"}> <Match when={store.state === "prompt"}>
<AuthPromptsView /> <OAuthPromptsView />
</Match> </Match>
<Match when={store.state === "error"}> <Match when={store.state === "error"}>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
@@ -1,6 +1,6 @@
import "@pierre/trees/web-components" import "@pierre/trees/web-components"
import { FileTree } from "@pierre/trees" import { FileTree } from "@pierre/trees"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -27,7 +27,6 @@ import {
pickerRoot, pickerRoot,
} from "./directory-picker-domain" } from "./directory-picker-domain"
import "./dialog-select-directory-v2.css" import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
interface DialogSelectDirectoryV2Props { interface DialogSelectDirectoryV2Props {
title?: string title?: string
@@ -267,12 +266,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
onCleanup(() => tree?.cleanUp()) onCleanup(() => tree?.cleanUp())
return ( return (
<Dialog size="large" class="directory-picker-v2"> <Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
<DialogHeader> <div class="directory-picker-v2-body">
<DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="directory-picker-v2-body pt-4!">
<div class="directory-picker-v2-path" ref={pathArea}> <div class="directory-picker-v2-path" ref={pathArea}>
<TextInputV2 <TextInputV2
value={input()} value={input()}
@@ -354,7 +349,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
</Show> </Show>
</div> </div>
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div> <div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
</DialogBody> </div>
<DialogFooter> <DialogFooter>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}> <ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
@@ -15,6 +15,7 @@ import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
@@ -271,6 +272,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const server = useServer()
const settings = useSettings() const settings = useSettings()
const layout = useLayout() const layout = useLayout()
const file = useFile() const file = useFile()
@@ -391,10 +393,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
state.cleanup?.() state.cleanup?.()
}) })
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) {
return ( return (
<DialogSelectFileV2 <DialogSelectFileV2
server={serverSDK().server} server={server.current}
mode="file" mode="file"
start={projectDirectory()} start={projectDirectory()}
title={language.t("session.header.searchFiles")} title={language.t("session.header.searchFiles")}
@@ -59,7 +59,6 @@ const ModelList: Component<{
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={12} gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />} value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
> >
{node} {node}
@@ -2,7 +2,7 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { Todo } from "@opencode-ai/sdk/v2" import type { Todo } from "@opencode-ai/sdk/v2"
import { createPromptState } from "@/context/prompt" import { createPromptState } from "@/context/prompt"
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer" import { SessionComposerRegion } from "@/pages/session/composer"
import { createPromptInputHistory, PromptInput } from "./prompt-input" import { createPromptInputHistory, PromptInput } from "./prompt-input"
function createPromptInputStoryRuntime() { function createPromptInputStoryRuntime() {
@@ -54,6 +54,12 @@ function PromptInputExample() {
paid: true, paid: true,
loading: false, loading: false,
}, },
projects: {
available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
directory: "/tmp/story",
select() {},
add() {},
},
session: { session: {
id: "story-session", id: "story-session",
tabs: { tabs: {
@@ -136,6 +142,7 @@ function PromptInputWithOpenDock() {
paid: true, paid: true,
loading: false, loading: false,
}, },
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
session: { session: {
id: "story-session", id: "story-session",
tabs: { tabs: {
@@ -159,35 +166,22 @@ function PromptInputWithOpenDock() {
closing: () => false, closing: () => false,
opening: () => false, opening: () => false,
} }
return ( return (
<SessionComposerRegion <SessionComposerRegion
controller={createSessionComposerRegionController({ state={state}
state, sessionKey="story-session"
sessionKey: () => "story-session", sessionID="story-session"
sessionID: () => "story-session", controls={inputControls}
prompt: input.state, promptInput={{ ...input, ref: () => {}, newSessionWorktree: "", onNewSessionWorktreeReset: () => {} }}
ready: () => true, todo={{
centered: () => false, collapsed: controls.todoCollapsed,
todo: { onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
collapsed: () => controls.todoCollapsed, }}
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed), ready
}, centered={false}
followup: () => undefined, onResponseSubmit={() => {}}
revert: () => undefined, setPromptDockRef={() => {}}
onResponseSubmit: () => {},
openParent: () => {},
setPromptRef: () => {},
setDockRef: () => {},
})}
promptInput={
<PromptInput
controls={inputControls}
{...input}
ref={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
/>
}
/> />
) )
} }
+262 -206
View File
@@ -4,6 +4,8 @@ import {
createEffect, createEffect,
on, on,
Component, Component,
splitProps,
For,
Show, Show,
onCleanup, onCleanup,
createMemo, createMemo,
@@ -11,8 +13,10 @@ import {
createResource, createResource,
Switch, Switch,
Match, Match,
type ComponentProps,
type JSX, type JSX,
} from "solid-js" } from "solid-js"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { useLocal } from "@/context/local" import type { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -32,11 +36,9 @@ import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon" import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -68,7 +70,8 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state" import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" import { pathKey } from "@/utils/path-key"
import { displayName } from "@/pages/layout/helpers"
export type PromptInputState = ReturnType<typeof usePrompt> export type PromptInputState = ReturnType<typeof usePrompt>
@@ -96,6 +99,12 @@ export type PromptInputControls = {
paid: boolean paid: boolean
loading: boolean loading: boolean
} }
projects: {
available: { name?: string; worktree: string; sandboxes?: string[] }[]
directory: string
select: (worktree: string) => void
add: (title: string) => void
}
session: { session: {
id?: string id?: string
tabs: { tabs: {
@@ -166,7 +175,6 @@ export interface PromptInputProps {
onQueue?: (draft: FollowupDraft) => void onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void onAbort?: () => void
onSubmit?: () => void onSubmit?: () => void
toolbar?: JSX.Element
} }
const EXAMPLES = [ const EXAMPLES = [
@@ -215,7 +223,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let fileInputRef: HTMLInputElement | undefined let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement
let restoreEndOnFocus = true let projectSearchRef: HTMLInputElement | undefined
const mirror = { input: false } const mirror = { input: false }
const inset = 56 const inset = 56
@@ -343,6 +351,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
() => prompt.capture(), () => prompt.capture(),
Math.floor(Math.random() * EXAMPLES.length), 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 buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({ const motion = (value: number) => ({
opacity: value, opacity: value,
@@ -595,16 +608,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
} }
const handleFocus = () => {
if (!restoreEndOnFocus) return
restoreEndOnFocus = false
requestAnimationFrame(() => {
if (document.activeElement !== editorRef) return
setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current()))
queueScroll()
})
}
const renderEditorWithCursor = (parts: Prompt) => { const renderEditorWithCursor = (parts: Prompt) => {
const cursor = currentCursor() const cursor = currentCursor()
renderEditor(parts) renderEditor(parts)
@@ -641,89 +644,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
} }
const referenceDescription = (reference: ReferenceInfo) =>
reference.source.type === "git" ? reference.source.repository : reference.source.path
const referenceList = createMemo(() =>
sync()
.data.reference.filter((reference) => !reference.hidden)
.map(
(reference): AtOption => ({
type: "reference",
name: reference.name,
path: reference.path,
display: reference.name,
description: reference.description ?? referenceDescription(reference),
}),
),
)
const agentList = createMemo(() => const agentList = createMemo(() =>
props.controls.agents.available props.controls.agents.available
.filter((agent) => !agent.hidden && agent.mode !== "primary") .filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
) )
const mcpResourceList = createMemo(() =>
Object.values(sync().data.mcp_resource).map(
(resource): AtOption => ({
type: "resource",
name: resource.name,
uri: resource.uri,
client: resource.client,
display: resource.name,
description: resource.description,
mime: resource.mimeType,
}),
),
)
const handleAtSelect = (option: AtOption | undefined) => { const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return if (!option) return
if (option.type === "agent") { if (option.type === "agent") {
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
return } else {
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
} }
if (option.type === "reference") {
addPart({
type: "file",
path: option.path,
content: "@" + option.name,
start: 0,
end: 0,
mime: "application/x-directory",
filename: option.name,
})
return
}
if (option.type === "resource") {
addPart({
type: "file",
path: option.uri,
content: "@" + option.name,
start: 0,
end: 0,
mime: option.mime ?? "text/plain",
filename: option.name,
url: option.uri,
source: {
type: "resource",
text: { value: "@" + option.name, start: 0, end: 0 },
clientName: option.client,
uri: option.uri,
},
})
return
}
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
} }
const atKey = (x: AtOption | undefined) => { const atKey = (x: AtOption | undefined) => {
if (!x) return "" if (!x) return ""
if (x.type === "agent") return `agent:${x.name}` return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}`
if (x.type === "reference") return `reference:${x.name}`
if (x.type === "resource") return `resource:${x.client}:${x.uri}`
return `file:${x.path}`
} }
const { const {
@@ -734,36 +672,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown: atOnKeyDown, onKeyDown: atOnKeyDown,
} = useFilteredList<AtOption>({ } = useFilteredList<AtOption>({
items: async (query) => { items: async (query) => {
const references = referenceList()
const agents = agentList() const agents = agentList()
const mcpResources = mcpResourceList()
const open = recent() const open = recent()
const seen = new Set(open) const seen = new Set(open)
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned] if (!query.trim()) return [...agents, ...pinned]
const paths = await files.searchFilesAndDirectories(query) const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path)) .filter((path) => !seen.has(path))
.map((path) => ({ type: "file", path, display: path })) .map((path) => ({ type: "file", path, display: path }))
return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions] return [...agents, ...pinned, ...fileOptions]
}, },
key: atKey, key: atKey,
filterKeys: ["display"], filterKeys: ["display"],
skipFilter: (item) => item.type === "file" && !item.recent, skipFilter: (item) => item.type === "file" && !item.recent,
groupBy: (item) => { groupBy: (item) => {
if (item.type === "reference") return "reference"
if (item.type === "agent") return "agent" if (item.type === "agent") return "agent"
if (item.type === "resource") return "resource"
if (item.recent) return "recent" if (item.recent) return "recent"
return "file" return "file"
}, },
sortGroupsBy: (a, b) => { sortGroupsBy: (a, b) => {
const rank = (category: string) => { const rank = (category: string) => {
if (category === "reference") return 0 if (category === "agent") return 0
if (category === "agent") return 1 if (category === "recent") return 1
if (category === "resource") return 2 return 2
if (category === "recent") return 3
return 4
} }
return rank(a.category) - rank(b.category) return rank(a.category) - rank(b.category)
}, },
@@ -829,17 +761,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const pill = document.createElement("span") const pill = document.createElement("span")
pill.textContent = part.content pill.textContent = part.content
pill.setAttribute("data-type", part.type) pill.setAttribute("data-type", part.type)
if (part.type === "file") { if (part.type === "file") pill.setAttribute("data-path", part.path)
pill.setAttribute("data-path", part.path)
if (part.mime) pill.setAttribute("data-mime", part.mime)
if (part.filename) pill.setAttribute("data-filename", part.filename)
if (part.url) pill.setAttribute("data-url", part.url)
if (part.source?.type === "resource") {
pill.setAttribute("data-source-type", part.source.type)
pill.setAttribute("data-source-client-name", part.source.clientName)
pill.setAttribute("data-source-uri", part.source.uri)
}
}
if (part.type === "agent") pill.setAttribute("data-name", part.name) if (part.type === "agent") pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false") pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text" pill.style.userSelect = "text"
@@ -884,7 +806,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const scrollSlashActiveIntoView = () => { // Auto-scroll active command into view when navigating with keyboard
createEffect(() => {
const activeId = slashActive() const activeId = slashActive()
if (!activeId || !slashPopoverRef) return if (!activeId || !slashPopoverRef) return
@@ -892,7 +815,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
}) })
} })
const selectPopoverActive = () => { const selectPopoverActive = () => {
if (store.popover === "at") { if (store.popover === "at") {
const items = atFlat() const items = atFlat()
@@ -954,29 +877,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const pushFile = (file: HTMLElement) => { const pushFile = (file: HTMLElement) => {
const content = file.textContent ?? "" const content = file.textContent ?? ""
const source =
file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri
? {
type: "resource" as const,
text: {
value: content,
start: position,
end: position + content.length,
},
clientName: file.dataset.sourceClientName,
uri: file.dataset.sourceUri,
}
: undefined
parts.push({ parts.push({
type: "file", type: "file",
path: file.dataset.path!, path: file.dataset.path!,
content, content,
start: position, start: position,
end: position + content.length, end: position + content.length,
...(file.dataset.mime ? { mime: file.dataset.mime } : {}),
...(file.dataset.filename ? { filename: file.dataset.filename } : {}),
...(file.dataset.url ? { url: file.dataset.url } : {}),
...(source ? { source } : {}),
}) })
position += content.length position += content.length
} }
@@ -1396,9 +1302,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
if (store.popover === "slash") { if (store.popover === "slash") {
slashOnKeyDown(event) slashOnKeyDown(event)
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) {
scrollSlashActiveIntoView()
}
} }
event.preventDefault() event.preventDefault()
return return
@@ -1457,9 +1360,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const agentsLoading = () => props.controls.agents.loading const agentsLoading = () => props.controls.agents.loading
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading()) const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
const providersLoading = () => props.controls.model.loading const providersLoading = () => props.controls.model.loading
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading()) const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource( const [promptReady] = createResource(
() => prompt.ready.promise, () => prompt.ready.promise,
@@ -1473,10 +1376,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const modelControlState = createMemo<ComposerModelControlState>(() => ({ const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(), loading: providersLoading(),
shouldAnimate: providersShouldFadeIn(),
paid: props.controls.model.paid, paid: props.controls.model.paid,
title: language.t("command.model.choose"), title: language.t("command.model.choose"),
keybind: command.keybindParts("model.choose"), keybind: command.keybind("model.choose"),
model: props.controls.model.selection, model: props.controls.model.selection,
providerID: props.controls.model.selection.current()?.provider?.id, providerID: props.controls.model.selection.current()?.provider?.id,
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"), modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
@@ -1490,15 +1392,75 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})) }))
const newSession = () => props.variant === "new-session" const newSession = () => props.variant === "new-session"
const bindEditorRef = (el: HTMLDivElement) => { const projects = createMemo(() => props.controls.projects.available)
editorRef = el const projectForDirectory = (directory: string | undefined) => {
restoreEndOnFocus = true if (!directory) return
props.ref?.(el) 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 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>(() => ({ const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"), title: language.t("command.agent.cycle"),
keybind: command.keybindParts("agent.cycle"), keybind: command.keybind("agent.cycle"),
options: props.controls.agents.options, options: props.controls.agents.options,
current: props.controls.agents.current, current: props.controls.agents.current,
style: control(), style: control(),
@@ -1507,6 +1469,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
restoreFocus() 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 ( return (
<div class="relative size-full flex flex-col gap-0"> <div class="relative size-full flex flex-col gap-0">
{(promptReady(), null)} {(promptReady(), null)}
@@ -1523,8 +1494,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setSlashActive={setSlashActive} setSlashActive={setSlashActive}
onSlashSelect={handleSlashSelect} onSlashSelect={handleSlashSelect}
commandKeybind={command.keybind} commandKeybind={command.keybind}
commandKeybindParts={command.keybindParts}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<Switch> <Switch>
@@ -1578,7 +1547,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}> <div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div <div
data-component="prompt-input" data-component="prompt-input"
ref={bindEditorRef} ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox" role="textbox"
aria-multiline="true" aria-multiline="true"
aria-label={designPlaceholder()} aria-label={designPlaceholder()}
@@ -1593,7 +1565,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onPaste={handlePaste} onPaste={handlePaste}
onCompositionStart={handleCompositionStart} onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd} onCompositionEnd={handleCompositionEnd}
onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
classList={{ classList={{
@@ -1614,16 +1585,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div> </div>
</div> </div>
<div class="flex h-11 items-center px-2"> <div class="flex h-11 items-center px-2">
<div class="flex min-w-0 flex-1 items-center gap-1"> <div class="flex min-w-0 flex-1 items-center gap-0">
{fileAttachmentInput()} {fileAttachmentInput()}
<TooltipV2 <TooltipKeybind
placement="top" placement="top"
value={ title={language.t("prompt.action.attachFile")}
<> keybind={command.keybind("file.attach")}
{language.t("prompt.action.attachFile")}
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
</>
}
> >
<IconButton <IconButton
data-action="prompt-attach" data-action="prompt-attach"
@@ -1637,30 +1604,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
tabIndex={store.mode === "normal" ? undefined : -1} tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")} aria-label={language.t("prompt.action.attachFile")}
/> />
</TooltipV2> </TooltipKeybind>
<Show when={showAgentControl()}> <Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} /> <ComposerAgentControl state={agentControlState()} />
</Show> </Show>
{props.toolbar} <Show when={newSession() && !selectedProject()}>
<ComposerPickerTrigger state={newProjectTriggerState()} />
</Show>
<ComposerModelControl state={modelControlState()} /> <ComposerModelControl state={modelControlState()} />
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}> <Show when={store.mode !== "shell" && showVariantControl()}>
<div <div
data-component="prompt-variant-control" data-component="prompt-variant-control"
classList={{ classList={{
"animate-in fade-in": providersShouldFadeIn(),
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block": "hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
!props.controls.model.selection.variant.current() && !store.variantOpen, !props.controls.model.selection.variant.current() && !store.variantOpen,
}} }}
> >
<TooltipV2 <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
value={ title={language.t("command.model.variant.cycle")}
<> keybind={command.keybind("model.variant.cycle")}
{language.t("command.model.variant.cycle")}
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
</>
}
> >
<Select <Select
size="normal" size="normal"
@@ -1678,11 +1642,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{ "data-action": "prompt-model-variant" }} triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost" variant="ghost"
/> />
</TooltipV2> </TooltipKeybind>
</div> </div>
</Show> </Show>
</div> </div>
<TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}> <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton <IconButton
data-action="prompt-submit" data-action="prompt-submit"
type="submit" type="submit"
@@ -1697,9 +1661,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}} }}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/> />
</TooltipV2> </Tooltip>
</div> </div>
</DockShellForm> </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> </div>
</Match> </Match>
<Match when> <Match when>
@@ -1757,7 +1726,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<div <div
data-component="prompt-input" data-component="prompt-input"
ref={bindEditorRef} ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox" role="textbox"
aria-multiline="true" aria-multiline="true"
aria-label={placeholder()} aria-label={placeholder()}
@@ -1772,7 +1744,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onPaste={handlePaste} onPaste={handlePaste}
onCompositionStart={handleCompositionStart} onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd} onCompositionEnd={handleCompositionEnd}
onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
classList={{ classList={{
@@ -1892,7 +1863,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={!agentsLoading()}> <Show when={!agentsLoading()}>
<div <div
data-component="prompt-agent-control" data-component="prompt-agent-control"
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }} style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
> >
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
@@ -1921,7 +1892,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={store.mode !== "shell"}> <Show when={store.mode !== "shell"}>
<div <div
data-component="prompt-model-control" data-component="prompt-model-control"
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }} style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
> >
<Show <Show
when={props.controls.model.paid} when={props.controls.model.paid}
@@ -2000,7 +1971,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={showVariantControl()}> <Show when={showVariantControl()}>
<div <div
data-component="prompt-variant-control" data-component="prompt-variant-control"
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }} style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
> >
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
@@ -2039,9 +2010,40 @@ 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 = { type ComposerAgentControlState = {
title: string title: string
keybind: string[] keybind: string
options: string[] options: string[]
current: string current: string
style: JSX.CSSProperties | undefined style: JSX.CSSProperties | undefined
@@ -2050,10 +2052,9 @@ type ComposerAgentControlState = {
type ComposerModelControlState = { type ComposerModelControlState = {
loading: boolean loading: boolean
shouldAnimate: boolean
paid: boolean paid: boolean
title: string title: string
keybind: string[] keybind: string
model: ReturnType<typeof useLocal>["model"] model: ReturnType<typeof useLocal>["model"]
providerID?: string providerID?: string
modelName: string modelName: string
@@ -2062,22 +2063,97 @@ type ComposerModelControlState = {
onUnpaidClick: () => void 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 }) { function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
return ( return (
<div class="relative"> <div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted"> <div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
<Icon name="sliders" size="small" /> <Icon name="sliders" size="small" />
</div> </div>
<TooltipV2 <TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<Select <Select
size="normal" size="normal"
options={props.state.options} options={props.state.options}
@@ -2089,7 +2165,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
triggerProps={{ "data-action": "prompt-agent" }} triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost" variant="ghost"
/> />
</TooltipV2> </TooltipKeybind>
</div> </div>
) )
} }
@@ -2100,23 +2176,13 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Show <Show
when={props.state.paid} when={props.state.paid}
fallback={ fallback={
<TooltipV2 <TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<Button <Button
data-action="prompt-model" data-action="prompt-model"
as="div" as="div"
variant="ghost" variant="ghost"
size="normal" size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group" class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style} style={props.state.style}
onClick={props.state.onUnpaidClick} onClick={props.state.onUnpaidClick}
> >
@@ -2134,19 +2200,10 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" /> <Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span> </span>
</Button> </Button>
</TooltipV2> </TooltipKeybind>
} }
> >
<TooltipV2 <TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<ModelSelectorPopover <ModelSelectorPopover
model={props.state.model} model={props.state.model}
triggerAs={Button} triggerAs={Button}
@@ -2156,7 +2213,6 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
style: props.state.style, style: props.state.style,
class: class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group", "min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
classList: { "animate-in fade-in": props.state.shouldAnimate },
"data-action": "prompt-model", "data-action": "prompt-model",
}} }}
onClose={props.state.onClose} onClose={props.state.onClose}
@@ -2175,7 +2231,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" /> <Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span> </span>
</ModelSelectorPopover> </ModelSelectorPopover>
</TooltipV2> </TooltipKeybind>
</Show> </Show>
</Show> </Show>
) )
@@ -100,41 +100,6 @@ describe("buildRequestParts", () => {
) )
}) })
test("preserves reference aliases as directory file parts", () => {
const result = buildRequestParts({
prompt: [
{
type: "file",
path: "/repo/../docs",
content: "@docs",
start: 0,
end: 5,
mime: "application/x-directory",
filename: "docs",
},
],
context: [],
images: [],
text: "@docs",
messageID: "msg_reference",
sessionID: "ses_reference",
sessionDirectory: "/repo/app",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
expect(filePart.mime).toBe("application/x-directory")
expect(filePart.filename).toBe("docs")
expect(filePart.url).toBe("file:///repo/../docs")
expect(filePart.source?.type).toBe("file")
if (filePart.source?.type === "file") {
expect(filePart.source.path).toBe("/repo/../docs")
expect(filePart.source.text.value).toBe("@docs")
}
}
})
test("deduplicates context files when prompt already includes same path", () => { test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
@@ -99,31 +99,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
const files = input.prompt.filter(isFileAttachment).map((attachment) => { const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path) const path = absolute(input.sessionDirectory, attachment.path)
const source = attachment.source
? {
...attachment.source,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
}
: {
type: "file" as const,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
path,
}
return { return {
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
type: "file", type: "file",
mime: attachment.mime ?? "text/plain", mime: "text/plain",
url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
filename: attachment.filename ?? getFilename(attachment.path), filename: getFilename(attachment.path),
source, source: {
type: "file",
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
path,
},
} satisfies PromptRequestPart } satisfies PromptRequestPart
}) })
@@ -1,22 +1,10 @@
import { Component, For, Match, Show, Switch } from "solid-js" import { Component, For, Match, Show, Switch } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
export type AtOption = export type AtOption =
| { type: "agent"; name: string; display: string } | { type: "agent"; name: string; display: string }
| {
type: "resource"
name: string
uri: string
client: string
display: string
description?: string
mime?: string
}
| { type: "reference"; name: string; path: string; display: string; description: string }
| { type: "file"; path: string; display: string; recent?: boolean } | { type: "file"; path: string; display: string; recent?: boolean }
export interface SlashCommand { export interface SlashCommand {
@@ -42,8 +30,6 @@ type PromptPopoverProps = {
setSlashActive: (id: string) => void setSlashActive: (id: string) => void
onSlashSelect: (item: SlashCommand) => void onSlashSelect: (item: SlashCommand) => void
commandKeybind: (id: string) => string | undefined commandKeybind: (id: string) => string | undefined
commandKeybindParts: (id: string) => string[]
newLayoutDesigns: boolean
t: (key: string) => string t: (key: string) => string
} }
@@ -55,29 +41,15 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
if (props.popover === "slash") props.setSlashPopoverRef(el) if (props.popover === "slash") props.setSlashPopoverRef(el)
}} }}
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2" overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
classList={{ bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
"z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": props.newLayoutDesigns,
"rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]":
!props.newLayoutDesigns,
}}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
> >
<Switch> <Switch>
<Match when={props.popover === "at"}> <Match when={props.popover === "at"}>
<Show <Show
when={props.atFlat.length > 0} when={props.atFlat.length > 0}
fallback={ fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
<div
class="px-2 py-1"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{props.t("prompt.popover.emptyResults")}
</div>
}
> >
<For each={props.atFlat.slice(0, 10)}> <For each={props.atFlat.slice(0, 10)}>
{(item) => { {(item) => {
@@ -86,117 +58,13 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
if (item.type === "agent") { if (item.type === "agent") {
return ( return (
<button <button
class="w-full flex items-center gap-x-2 px-2 py-0.5" class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)} onClick={() => props.onAtSelect(item)}
onPointerMove={() => props.setAtActive(key)} onMouseEnter={() => props.setAtActive(key)}
> >
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span <span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
class="whitespace-nowrap"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-v2-text-text-base": props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
"text-text-strong": !props.newLayoutDesigns,
}}
>
@{item.name}
</span>
</button>
)
}
if (item.type === "resource") {
return (
<button
class="w-full flex items-center gap-x-2 px-2 py-0.5"
classList={{
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)}
onPointerMove={() => props.setAtActive(key)}
>
<FileIcon node={{ path: item.uri, type: "file" }} class="shrink-0 size-4" />
<div
class="flex items-center min-w-0"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
}}
>
<span
class="text-text-strong whitespace-nowrap"
classList={{ "text-v2-text-text-base": props.newLayoutDesigns }}
>
@{item.name}
</span>
<Show when={item.description}>
{(description) => (
<span
class="whitespace-nowrap truncate min-w-0 ml-2"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{description()}
</span>
)}
</Show>
</div>
</button>
)
}
if (item.type === "reference") {
return (
<button
class="w-full flex items-center gap-x-2 px-2 py-0.5"
classList={{
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)}
onPointerMove={() => props.setAtActive(key)}
>
<FileIcon node={{ path: item.path, type: "directory" }} class="shrink-0 size-4" />
<div
class="flex items-center min-w-0"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
}}
>
<span
class="text-text-strong whitespace-nowrap"
classList={{ "text-v2-text-text-base": props.newLayoutDesigns }}
>
@{item.name}
</span>
<span
class="whitespace-nowrap truncate min-w-0 ml-2"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{item.description}
</span>
</div>
</button> </button>
) )
} }
@@ -207,44 +75,16 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
return ( return (
<button <button
class="w-full flex items-center gap-x-2 px-2 py-0.5" class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)} onClick={() => props.onAtSelect(item)}
onPointerMove={() => props.setAtActive(key)} onMouseEnter={() => props.setAtActive(key)}
> >
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" /> <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<div <div class="flex items-center text-14-regular min-w-0">
class="flex items-center min-w-0" <span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
}}
>
<span
class="whitespace-nowrap truncate min-w-0"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{directory}
</span>
<Show when={!isDirectory}> <Show when={!isDirectory}>
<span <span class="text-text-strong whitespace-nowrap">{filename}</span>
class="whitespace-nowrap"
classList={{
"text-v2-text-text-base": props.newLayoutDesigns,
"text-text-strong": !props.newLayoutDesigns,
}}
>
{filename}
</span>
</Show> </Show>
</div> </div>
</button> </button>
@@ -256,98 +96,41 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
<Match when={props.popover === "slash"}> <Match when={props.popover === "slash"}>
<Show <Show
when={props.slashFlat.length > 0} when={props.slashFlat.length > 0}
fallback={ fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
<div
class="px-2 py-1"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{props.t("prompt.popover.emptyCommands")}
</div>
}
> >
<For each={props.slashFlat}> <For each={props.slashFlat}>
{(cmd) => { {(cmd) => (
const keybind = () => props.commandKeybind(cmd.id) <button
const keybindParts = () => props.commandKeybindParts(cmd.id) data-slash-id={cmd.id}
return ( classList={{
<button "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
data-slash-id={cmd.id} "bg-surface-raised-base-hover": props.slashActive === cmd.id,
classList={{ }}
"w-full flex items-center justify-between gap-4 px-2 py-1": true, onClick={() => props.onSlashSelect(cmd)}
"rounded-[4px] scroll-my-2": props.newLayoutDesigns, onMouseEnter={() => props.setSlashActive(cmd.id)}
"rounded-md": !props.newLayoutDesigns, >
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.slashActive === cmd.id, <div class="flex items-center gap-2 min-w-0">
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.slashActive === cmd.id, <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
}} <Show when={cmd.description}>
onClick={() => props.onSlashSelect(cmd)} <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
onPointerMove={() => props.setSlashActive(cmd.id)} </Show>
> </div>
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 shrink-0">
<span <Show when={cmd.type === "custom" && cmd.source !== "command"}>
class="whitespace-nowrap" <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
classList={{ {cmd.source === "skill"
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]": ? props.t("prompt.slash.badge.skill")
props.newLayoutDesigns, : cmd.source === "mcp"
"text-v2-text-text-base": props.newLayoutDesigns, ? props.t("prompt.slash.badge.mcp")
"text-14-regular": !props.newLayoutDesigns, : props.t("prompt.slash.badge.custom")}
"text-text-strong": !props.newLayoutDesigns,
}}
>
/{cmd.trigger}
</span> </span>
<Show when={cmd.description}> </Show>
<span <Show when={props.commandKeybind(cmd.id)}>
class="truncate" <span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
classList={{ </Show>
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]": </div>
props.newLayoutDesigns, </button>
"text-v2-text-text-muted": props.newLayoutDesigns, )}
"text-14-regular": !props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{cmd.description}
</span>
</Show>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
<Show
when={props.newLayoutDesigns}
fallback={
<span class="text-11-regular px-1.5 py-0.5 rounded bg-surface-base text-text-subtle">
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
</span>
}
>
<Tag>
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
</Tag>
</Show>
</Show>
<Show when={props.newLayoutDesigns ? keybindParts().length > 0 : keybind()}>
<Show
when={props.newLayoutDesigns}
fallback={<span class="text-12-regular text-text-subtle">{keybind()}</span>}
>
<KeybindV2 keys={keybindParts()} variant="neutral" />
</Show>
</Show>
</div>
</button>
)
}}
</For> </For>
</Show> </Show>
</Match> </Match>
@@ -1,551 +0,0 @@
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/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
}
const actionPrefix = "action:"
const projectPrefix = "project:"
function projectKey(project: PromptProject) {
return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}`
}
function actionKey(server?: string) {
return `${actionPrefix}${encodeURIComponent(server ?? "")}`
}
export function createPromptProjectController(input: {
controls: Accessor<PromptProjectControls>
onDone: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
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 keys = () => {
if (servers().length <= 1) {
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
}
return [
...servers().flatMap((server) =>
projects()
.filter((project) => project.server?.key === server!.key)
.map(projectKey),
),
actionKey(),
]
}
const initialActive = () => {
const selectedKey = selected() ? projectKey(selected()!) : undefined
const options = keys()
if (selectedKey && options.includes(selectedKey)) return selectedKey
return options[0] ?? ""
}
const close = () => {
setStore({ open: false, search: "", active: "" })
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, search: "", active: "" })
input.controls().add(language.t("command.project.open"), server)
}
return {
selected,
projects,
servers,
projectKey,
actionKey,
open: () => store.open,
search: () => store.search,
active: () => store.active,
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) {
if (open) {
setStore({ open: true, active: initialActive() })
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
return
}
setStore({ open: false, search: "", active: "" })
},
setSearch(value: string) {
const search = value.trim().toLowerCase()
const first = input
.controls()
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
setStore({
search: value,
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
})
},
clearSearch() {
setStore({ search: "", active: initialActive() })
setTimeout(() => searchRef?.focus())
},
setActive(key: string) {
setStore("active", key)
},
moveActive(delta: number) {
const options = keys()
if (options.length === 0) return
const index = options.indexOf(store.active)
const start = index === -1 ? 0 : index
setStore("active", options[(start + delta + options.length) % options.length])
},
activeProject() {
return store.active.startsWith(projectPrefix)
? projects().find((project) => projectKey(project) === store.active)
: undefined
},
activeServer() {
return store.active.startsWith(actionPrefix)
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
: undefined
},
activeAction() {
return store.active.startsWith(actionPrefix)
},
setSearchRef(el: HTMLInputElement) {
searchRef = el
},
focusSearch() {
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
},
}
}
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
export function PromptProjectSelector(props: {
controller: PromptProjectController
placement?: "bottom" | "bottom-start"
}) {
let contentRef: HTMLDivElement | undefined
let restoreTrigger = true
const activeItem = () =>
props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
: undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const selectProject = (project: PromptProject) => {
restoreTrigger = false
props.controller.setOpen(false)
afterClose(() => props.controller.select(project))
}
const selectAction = (server?: string) => {
restoreTrigger = false
props.controller.setOpen(false)
afterClose(() => props.controller.add(server))
}
const selectActive = () => {
const project = props.controller.activeProject()
if (project) {
selectProject(project)
return
}
if (props.controller.activeAction() && props.controller.servers().length > 1) {
const item = activeItem()
item?.focus()
item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
return
}
selectAction(props.controller.activeServer())
}
const moveActive = (delta: number) => {
props.controller.moveActive(delta)
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const focusPreviousControl = () => {
const target = Array.from(
document.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
)
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
.findLast((element) => element.offsetParent !== null)
restoreTrigger = false
target?.focus()
queueMicrotask(() => {
if (props.controller.open()) props.controller.setOpen(false)
})
}
const selectedValue = () => {
const project = props.controller.selected()
return project ? props.controller.projectKey(project) : undefined
}
return (
<DropdownMenu
open={props.controller.open()}
placement={props.placement ?? "bottom"}
gutter={4}
modal={false}
onOpenChange={(open) => props.controller.setOpen(open)}
>
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
<DropdownMenu.Portal>
<DropdownMenu.Content
ref={contentRef}
id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
>
<div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 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()}
aria-autocomplete="list"
aria-controls="prompt-project-menu"
aria-activedescendant={props.controller.active() || undefined}
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)}
onKeyDown={(event) => {
if (event.key === "Tab") {
event.preventDefault()
event.stopPropagation()
if (event.shiftKey) {
focusPreviousControl()
return
}
activeItem()?.focus()
return
}
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
props.controller.setOpen(false)
return
}
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
/>
<Show when={props.controller.search().trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onPointerDown={(event) => event.preventDefault()}
onClick={() => props.controller.clearSearch()}
aria-label={props.controller.labels.clear()}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<Show
when={props.controller.servers().length > 1}
fallback={
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects()}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
}
>
<For
each={props.controller
.servers()
.filter((server) =>
props.controller.projects().some((project) => project.server?.key === server!.key),
)}
>
{(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>
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
</div>
)}
</For>
</Show>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<Show
when={props.controller.servers().length > 1}
fallback={
<ProjectAction
server={props.controller.servers()[0]?.key}
controller={props.controller}
onSelect={selectAction}
/>
}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
id={props.controller.actionKey()}
data-option-key={props.controller.actionKey()}
class={projectActionClass}
classList={{
"!bg-v2-overlay-simple-overlay-hover": props.controller.active() === props.controller.actionKey(),
}}
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
>
<Icon name="plus" size="small" />
<span data-slot="dropdown-menu-item-label" class="min-w-0 flex-1 truncate leading-5">
{props.controller.labels.add()}
</span>
<Icon name="chevron-right" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<For each={props.controller.servers()}>
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
</For>
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
</Show>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)
}
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", "classList", "onClick", "onKeyDown"])
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-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{
...local.classList,
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
"text-v2-text-text-muted": local.controller.open(),
}}
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
onKeyDown={(event) => {
if (!local.controller.open() && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault()
event.stopPropagation()
return
}
if (typeof local.onKeyDown === "function") local.onKeyDown(event)
}}
>
<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
controller: PromptProjectController
onSelect: (project: PromptProject) => void
}) {
const key = () => props.controller.projectKey(props.project)
return (
<DropdownMenu.RadioItem
id={key()}
value={key()}
data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
style={{
"font-family": "var(--v2-font-family-sans)",
"font-size": "13px",
"font-weight": 440,
"line-height": "20px",
"letter-spacing": "-0.04px",
color: "var(--v2-text-text-base)",
padding: "0 12px",
}}
closeOnSelect
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
}}
onSelect={() => props.onSelect(props.project)}
>
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
<IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)
}
const projectActionClass =
"h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
function ProjectAction(props: {
server?: string
controller: PromptProjectController
onSelect: (server?: string) => void
}) {
const key = () => props.controller.actionKey(props.server)
return (
<DropdownMenu.Item
id={key()}
data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
style={{
"font-family": "var(--v2-font-family-sans)",
"font-size": "13px",
"font-weight": 440,
"line-height": "20px",
"letter-spacing": "-0.04px",
color: "var(--v2-text-text-base)",
padding: "0 12px",
}}
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
}}
onSelect={() => props.onSelect(props.server)}
>
<Icon name="plus" size="small" />
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
{props.controller.labels.add()}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
)
}
function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) {
return (
<DropdownMenu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
<DropdownMenu.ItemLabel class="min-w-0 flex-1 truncate leading-5">{props.server.name}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
)
}
@@ -1,102 +0,0 @@
import { For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language"
export function PromptWorkspaceSelector(props: {
value: string
projectRoot: string
workspaces: string[]
branch?: string
onChange: (value: string) => void
onDone: () => void
}) {
const language = useLanguage()
let pending: string | undefined
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "workspace-new"
return "workspace"
}
const select = (value: string) => {
pending = value
}
const onOpenChange = (open: boolean) => {
if (open) return
const value = pending
pending = undefined
if (value) props.onChange(value)
props.onDone()
}
const label = () => {
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
if (props.value === "create") return language.t("workspace.new")
return getFilename(props.value)
}
return (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[180px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<IconV2 name="monitor" />
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
<MenuV2.Item onSelect={() => select("create")}>
<IconV2 name="workspace-new" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show when={props.workspaces.length > 0}>
<MenuV2.Separator />
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<IconV2 name="workspace" />
{language.t("session.new.workspace.existing")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-w-[200px]">
<For each={props.workspaces}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<IconV2 name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{props.branch || "main"}</span>
</div>
</>
)
}
@@ -1,9 +1,7 @@
import { Match, Show, Switch, createMemo } from "solid-js" import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
@@ -11,13 +9,12 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics" import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps { interface SessionContextUsageProps {
variant?: "button" | "indicator" variant?: "button" | "indicator"
buttonAppearance?: "default" | "v2"
placement?: TooltipProps["placement"] placement?: TooltipProps["placement"]
} }
@@ -26,7 +23,7 @@ function openSessionContext(args: {
layout: ReturnType<typeof useLayout> layout: ReturnType<typeof useLayout>
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]> tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
}) { }) {
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button") if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
void args.tabs.open("context") void args.tabs.open("context")
args.tabs.setActive("context") args.tabs.setActive("context")
@@ -42,14 +39,12 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button") const variant = createMemo(() => props.variant ?? "button")
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
const tabState = createSessionTabs({ const tabState = createSessionTabs({
tabs, tabs,
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}) })
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const usd = createMemo( const usd = createMemo(
() => () =>
@@ -59,30 +54,21 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}), }),
) )
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens) const context = createMemo(() => metrics().context)
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0) return usd().format(metrics().totalCost)
}) })
const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context")
const hasOtherTabs = createMemo(() =>
tabs()
.all()
.some((tab) => tab !== "context" && tab !== "review"),
)
const openContext = () => { const openContext = () => {
if (!params.id) return if (!params.id) return
const sessionView = view() if (tabState.activeTab() === "context") {
if (contextVisible()) {
tabs().close("context") tabs().close("context")
if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close()
return return
} }
openSessionContext({ openSessionContext({
view: sessionView, view: view(),
layout, layout,
tabs: tabs(), tabs: tabs(),
}) })
@@ -90,46 +76,24 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const circle = () => ( const circle = () => (
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<ProgressCircle <ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
size={16}
strokeWidth={2}
percentage={context()?.usage ?? 0}
style={
variant() === "indicator"
? {
"--progress-circle-background": "var(--v2-background-bg-layer-04, var(--border-weak-base))",
"--progress-circle-background-overlay": "var(--v2-overlay-simple-overlay-pressed, transparent)",
"--progress-circle-progress": "var(--v2-icon-icon-base, var(--icon-base))",
}
: undefined
}
/>
</div>
)
const circleV2 = () => (
<div class="flex items-center justify-center">
<ProgressCircleV2 percentage={context()?.usage ?? 0} />
</div> </div>
) )
const tooltipValue = () => ( const tooltipValue = () => (
<div> <div>
<Show when={tokens()}>
{(value) => (
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">
{getSessionTokenTotal(value())?.toLocaleString(language.intl())}
</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div>
)}
</Show>
<Show when={context()}> <Show when={context()}>
{(ctx) => ( {(ctx) => (
<div class="flex items-center gap-2"> <>
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span> <div class="flex items-center gap-2">
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span> <span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
</div> <span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
</div>
</>
)} )}
</Show> </Show>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -141,22 +105,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
return ( return (
<Show when={params.id}> <Show when={params.id}>
<Switch> <Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<Match when={variant() === "indicator"}>{circle()}</Match> <Switch>
<Match when={buttonAppearance() === "v2"}> <Match when={variant() === "indicator"}>{circle()}</Match>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}> <Match when={true}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
icon={circleV2()}
onClick={openContext}
aria-label={language.t("context.usage.view")}
/>
</Tooltip>
</Match>
<Match when={true}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -166,9 +118,9 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
> >
{circle()} {circle()}
</Button> </Button>
</Tooltip> </Match>
</Match> </Switch>
</Switch> </Tooltip>
</Show> </Show>
) )
} }
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client" import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" import { getSessionContextMetrics } from "./session-context-metrics"
const assistant = ( const assistant = (
id: string, id: string,
@@ -37,8 +37,8 @@ const user = (id: string) => {
} as unknown as Message } as unknown as Message
} }
describe("getSessionContext", () => { describe("getSessionContextMetrics", () => {
test("computes usage from latest assistant with tokens", () => { test("computes totals and usage from latest assistant with tokens", () => {
const messages = [ const messages = [
user("u1"), user("u1"),
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
@@ -57,52 +57,45 @@ describe("getSessionContext", () => {
}, },
] ]
const ctx = getSessionContext(messages, providers) const metrics = getSessionContextMetrics(messages, providers)
expect(ctx?.message.id).toBe("a2") expect(metrics.totalCost).toBe(1.75)
expect(ctx?.usage).toBe(50) expect(metrics.context?.message.id).toBe("a2")
expect(ctx?.providerLabel).toBe("OpenAI") expect(metrics.context?.total).toBe(500)
expect(ctx?.modelLabel).toBe("GPT-4.1") expect(metrics.context?.usage).toBe(50)
expect(metrics.context?.providerLabel).toBe("OpenAI")
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
}) })
test("preserves fallback labels and null usage when model metadata is missing", () => { test("preserves fallback labels and null usage when model metadata is missing", () => {
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")] const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
const providers = [{ id: "p-1", models: {} }] const providers = [{ id: "p-1", models: {} }]
const ctx = getSessionContext(messages, providers) const metrics = getSessionContextMetrics(messages, providers)
expect(ctx?.providerLabel).toBe("p-1") expect(metrics.context?.providerLabel).toBe("p-1")
expect(ctx?.modelLabel).toBe("m-1") expect(metrics.context?.modelLabel).toBe("m-1")
expect(ctx?.limit).toBeUndefined() expect(metrics.context?.limit).toBeUndefined()
expect(ctx?.usage).toBeNull() expect(metrics.context?.usage).toBeNull()
}) })
test("recomputes when message array is mutated in place", () => { test("recomputes when message array is mutated in place", () => {
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
const providers = [{ id: "openai", models: {} }] const providers = [{ id: "openai", models: {} }]
const one = getSessionContext(messages, providers) const one = getSessionContextMetrics(messages, providers)
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75)) messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
const two = getSessionContext(messages, providers) const two = getSessionContextMetrics(messages, providers)
expect(one?.message.id).toBe("a1") expect(one.context?.message.id).toBe("a1")
expect(two?.message.id).toBe("a2") expect(two.context?.message.id).toBe("a2")
expect(two.totalCost).toBe(1)
}) })
test("returns undefined when inputs are undefined", () => { test("returns empty metrics when inputs are undefined", () => {
const ctx = getSessionContext(undefined, undefined) const metrics = getSessionContextMetrics(undefined, undefined)
expect(ctx).toBeUndefined() expect(metrics.totalCost).toBe(0)
}) expect(metrics.context).toBeUndefined()
test("computes stored session token totals", () => {
expect(
getSessionTokenTotal({
input: 10,
output: 20,
reasoning: 30,
cache: { read: 40, write: 50 },
}),
).toBe(150)
}) })
}) })
@@ -1,4 +1,4 @@
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client" import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
type Provider = { type Provider = {
id: string id: string
@@ -21,9 +21,19 @@ type Context = {
modelLabel: string modelLabel: string
limit: number | undefined limit: number | undefined
input: number input: number
output: number
reasoning: number
cacheRead: number
cacheWrite: number
total: number
usage: number | null usage: number | null
} }
type Metrics = {
totalCost: number
context: Context | undefined
}
const tokenTotal = (msg: AssistantMessage) => { const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
} }
@@ -37,9 +47,10 @@ const lastAssistantWithTokens = (messages: Message[]) => {
} }
} }
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => { const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
const message = lastAssistantWithTokens(messages) const message = lastAssistantWithTokens(messages)
if (!message) return undefined if (!message) return { totalCost, context: undefined }
const provider = providers.find((item) => item.id === message.providerID) const provider = providers.find((item) => item.id === message.providerID)
const model = provider?.models[message.modelID] const model = provider?.models[message.modelID]
@@ -47,22 +58,25 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
const total = tokenTotal(message) const total = tokenTotal(message)
return { return {
message, totalCost,
provider, context: {
model, message,
providerLabel: provider?.name ?? message.providerID, provider,
modelLabel: model?.name ?? message.modelID, model,
limit, providerLabel: provider?.name ?? message.providerID,
input: message.tokens.input, modelLabel: model?.name ?? message.modelID,
usage: limit ? Math.round((total / limit) * 100) : null, limit,
input: message.tokens.input,
output: message.tokens.output,
reasoning: message.tokens.reasoning,
cacheRead: message.tokens.cache.read,
cacheWrite: message.tokens.cache.write,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
},
} }
} }
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) { export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers) return build(messages, providers)
} }
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
if (!tokens) return undefined
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
@@ -134,12 +134,12 @@ export function SessionContextTab() {
}), }),
) )
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens) const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0) return usd().format(metrics().totalCost)
}) })
const counts = createMemo(() => { const counts = createMemo(() => {
@@ -204,14 +204,14 @@ export function SessionContextTab() {
{ label: "context.stats.provider", value: providerLabel }, { label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel }, { label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) }, { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) }, { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) }, { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) }, { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
{ {
label: "context.stats.cacheTokens", label: "context.stats.cacheTokens",
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`, value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
}, },
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -1,43 +0,0 @@
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
}
@@ -1,6 +1,5 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
@@ -53,12 +52,8 @@ export const DialogServerV2: Component<{
} }
return ( return (
<Dialog fit class="settings-v2-server-dialog"> <Dialog title={title()} fit class="settings-v2-server-dialog">
<DialogHeader hideClose={true}> <div class="flex w-full min-w-0 flex-1 flex-col px-4">
<DialogTitle>{title()}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<div class="flex w-full min-w-0 flex-col gap-6"> <div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2"> <div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label> <label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
@@ -120,7 +115,7 @@ export const DialogServerV2: Component<{
</div> </div>
</div> </div>
</div> </div>
</DialogBody> </div>
<DialogFooter> <DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}> <ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
@@ -11,9 +11,7 @@ import { SettingsModelsV2 } from "./models"
import "./settings-v2.css" import "./settings-v2.css"
import { SettingsServersV2 } from "./servers" import { SettingsServersV2 } from "./servers"
export const DialogSettings: Component<{ export const DialogSettings: Component = () => {
sessionID?: string
}> = (props) => {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
@@ -64,7 +62,7 @@ export const DialogSettings: Component<{
</div> </div>
</TabsV2.List> </TabsV2.List>
<TabsV2.Content value="general" class="settings-v2-panel"> <TabsV2.Content value="general" class="settings-v2-panel">
<SettingsGeneralV2 sessionID={props.sessionID} /> <SettingsGeneralV2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="shortcuts" class="settings-v2-panel"> <TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 /> <SettingsKeybinds v2 />
@@ -6,6 +6,7 @@ import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -24,6 +25,7 @@ import {
terminalInput, terminalInput,
useSettings, useSettings,
} from "@/context/settings" } from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "../link" import { Link } from "../link"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
@@ -80,46 +82,50 @@ const playDemoSound = (id: string | undefined) => {
}, 100) }, 100)
} }
export const SettingsGeneralV2: Component<{ export const SettingsGeneralV2: Component = () => {
sessionID?: string
}> = (props) => {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const permission = usePermission() const permission = usePermission()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const params = useParams()
const settings = useSettings() const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction() const updater = useUpdaterAction()
const dir = createMemo(() => { const dir = createMemo(() => decode64(params.dir))
if (!props.sessionID) return undefined
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
})
const accepting = createMemo(() => { const accepting = createMemo(() => {
const value = dir() const value = dir()
if (!value || !props.sessionID) return false if (!value) return false
return permission.isAutoAccepting(props.sessionID, value) if (!params.id) return permission.isAutoAcceptingDirectory(value)
return permission.isAutoAccepting(params.id, value)
}) })
const toggleAccept = (checked: boolean) => { const toggleAccept = (checked: boolean) => {
const value = dir() const value = dir()
if (!value || !props.sessionID) return if (!value) return
if (checked) { if (!params.id) {
permission.enableAutoAccept(props.sessionID, value) if (permission.isAutoAcceptingDirectory(value) === checked) return
permission.toggleAutoAcceptDirectory(value)
return return
} }
permission.disableAutoAccept(props.sessionID, value) if (checked) {
permission.enableAutoAccept(params.id, value)
return
}
permission.disableAutoAccept(params.id, value)
} }
const desktop = createMemo(() => platform.platform === "desktop") const desktop = createMemo(() => platform.platform === "desktop")
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const [shells] = createResource( const [shells] = createResource(
() => () =>
serverSdk() serverSdk()
@@ -633,7 +633,7 @@
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] { [data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
align-items: center; align-items: center;
padding: 24px 24px 16px; padding: 24px 24px 0;
} }
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] { [data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
@@ -5,8 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover" import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js" import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
@@ -82,11 +81,11 @@ export function StatusPopoverV2(props: { scope?: "server" }) {
function DirectoryStatusPopover() { function DirectoryStatusPopover() {
const language = useLanguage() const language = useLanguage()
const server = useServerSDK() const server = useServer()
const global = useGlobal() const global = useGlobal()
const sync = useSync() const sync = useSync()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready) const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => { const mcpIssue = createMemo(() => {
const mcp = Object.values(sync().data.mcp ?? {}) const mcp = Object.values(sync().data.mcp ?? {})
+4 -6
View File
@@ -10,7 +10,7 @@ import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk" import { useServer } from "@/context/server"
import { terminalFontFamily, useSettings } from "@/context/settings" import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal" import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
@@ -160,15 +160,13 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session. const server = useServer()
const connection = useServerSDK()().server
const directory = sdk().directory const directory = sdk().directory
const client = sdk().client const client = sdk().client
const url = sdk().url const url = sdk().url
const auth = connection.http const auth = server.current?.http
const username = auth?.username ?? "opencode" const username = auth?.username ?? "opencode"
const password = auth?.password ?? "" const password = auth?.password ?? ""
const authToken = connection.type === "http" ? connection.authToken : false
const sameOrigin = new URL(url, location.href).origin === location.origin const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
@@ -542,7 +540,7 @@ export const Terminal = (props: TerminalProps) => {
sameOrigin, sameOrigin,
username, username,
password, password,
authToken, authToken: server.current?.type === "http" ? server.current.authToken : false,
}), }),
) )
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test"
import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
import {
canOpenTabRename,
captureTabPointerDown,
canStartTabDrag,
createTabDragPreview,
forwardTabRef,
isPrimaryPointerPressed,
isTabCloseTarget,
} from "./titlebar-tab-gesture"
describe("titlebar tab drag", () => {
const layout = {
listLeft: 100,
dividerWidth: 13,
tabWidthById: new Map([
["a", 40],
["b", 40],
["c", 40],
["d", 40],
]),
}
test("moves across multiple tabs from one pointer update", () => {
expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
})
test("keeps the current index inside the left hysteresis deadband", () => {
expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
})
test("includes slot margins in captured 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"
second.style.marginLeft = "6px"
firstTab.dataset.titlebarTab = ""
secondTab.dataset.titlebarTab = ""
first.append(firstTab)
second.append(secondTab)
list.append(first, second)
document.body.append(list)
firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
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", () => {
test("excludes close controls from tab gestures", () => {
const close = document.createElement("div")
const button = document.createElement("button")
const link = document.createElement("a")
close.dataset.slot = "tab-close"
close.append(button)
expect(isTabCloseTarget(close)).toBe(true)
expect(isTabCloseTarget(button)).toBe(true)
expect(isTabCloseTarget(link)).toBe(false)
})
test("forwards component refs", () => {
const element = document.createElement("div")
let received: HTMLDivElement | undefined
forwardTabRef((value) => (received = value), element)
expect(received).toBe(element)
})
test("does not reopen rename while a save is pending", () => {
expect(canOpenTabRename(false, false, false)).toBe(true)
expect(canOpenTabRename(false, false, true)).toBe(false)
})
test("keeps the rendered tab content in the drag preview", () => {
const tab = document.createElement("div")
tab.innerHTML = '<span data-slot="project-avatar-slot"></span><span data-slot="tab-title">Session</span>'
const preview = createTabDragPreview(tab)
expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull()
expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session")
})
test("captures the grab offset before navigation scrolls the tab", () => {
const tab = document.createElement("div")
tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect
expect(captureTabPointerDown(tab, 100, 20)).toEqual({
startX: 100,
startY: 20,
grabOffsetX: 20,
grabOffsetY: 10,
width: 120,
element: tab,
})
})
test("detects when the primary pointer button was released outside the window", () => {
expect(isPrimaryPointerPressed(1)).toBe(true)
expect(isPrimaryPointerPressed(3)).toBe(true)
expect(isPrimaryPointerPressed(0)).toBe(false)
expect(isPrimaryPointerPressed(2)).toBe(false)
})
test("preserves native panning for touch pointers", () => {
expect(canStartTabDrag("mouse")).toBe(true)
expect(canStartTabDrag("pen")).toBe(true)
expect(canStartTabDrag("touch")).toBe(false)
})
})
@@ -0,0 +1,137 @@
export type TabDragLayout = {
tabWidthById: Map<string, number>
dividerWidth: number
listLeft: number
}
export const ACTIVATION_DISTANCE = 4
export const HYSTERESIS_DEADBAND = 8
export const AUTOSCROLL_EDGE = 24
export const AUTOSCROLL_MAX_SPEED = 8
export const FLOATER_OVERSHOOT_MAX = 8
export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
const dx = x2 - x1
const dy = y2 - y1
return Math.sqrt(dx * dx + dy * dy)
}
export function captureTabDragLayout(list: HTMLElement, order: string[]) {
const tabWidthById = new Map<string, number>()
const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
for (const slot of slots) {
const id = slot.dataset.tabKey
if (!id) continue
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.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)
break
}
}
return {
tabWidthById,
dividerWidth,
listLeft: list.getBoundingClientRect().left,
}
}
export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
layout.listLeft = list.getBoundingClientRect().left
}
function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
const id = order[index]
if (!id) return 0
const tabWidth = layout.tabWidthById.get(id) ?? 0
return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
}
function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
let left = layout.listLeft
for (let i = 0; i < index; i++) {
left += slotWidthAt(order, i, layout)
}
return left
}
export function insertIndexFromVirtualLayout(
pointerX: number,
order: readonly string[],
draggedId: string,
currentIndex: number,
layout: TabDragLayout,
deadband = HYSTERESIS_DEADBAND,
) {
if (order.length === 0) return 0
const others = order.filter((id) => id !== draggedId)
let target = currentIndex
while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
return target
}
export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
const fromIndex = order.indexOf(draggedId)
if (fromIndex === -1 || fromIndex === toIndex) return [...order]
const next = [...order]
next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
return next
}
export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
return final.some((key, index) => key !== initial[index])
}
function easeOvershoot(overshoot: number) {
return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX)
}
export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
const stripWidth = stripRight - stripLeft
if (width >= stripWidth) return stripLeft
const maxLeft = stripRight - width
if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)
return left
}
export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
const leftEdge = containerLeft + AUTOSCROLL_EDGE
const rightEdge = containerRight - AUTOSCROLL_EDGE
if (pointerX < leftEdge) {
const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
}
if (pointerX > rightEdge) {
const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
}
return 0
}
@@ -1,33 +0,0 @@
import { describe, expect, test } from "bun:test"
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./titlebar-tab-gesture"
describe("titlebar tab gestures", () => {
test("excludes close controls from tab gestures", () => {
const close = document.createElement("div")
const button = document.createElement("button")
const link = document.createElement("a")
close.dataset.slot = "tab-close"
close.append(button)
expect(isTabCloseTarget(close)).toBe(true)
expect(isTabCloseTarget(button)).toBe(true)
expect(isTabCloseTarget(link)).toBe(false)
})
test("forwards component refs", () => {
const element = document.createElement("div")
let received: HTMLDivElement | undefined
forwardTabRef((value) => (received = value), element)
expect(received).toBe(element)
})
test("does not reopen rename while a save is pending", () => {
expect(canOpenTabRename(false, false, false)).toBe(true)
expect(canOpenTabRename(false, false, true)).toBe(false)
})
test("preserves native panning for touch pointers", () => {
expect(canStartTabDrag("mouse")).toBe(true)
expect(canStartTabDrag("pen")).toBe(true)
expect(canStartTabDrag("touch")).toBe(false)
})
})
@@ -8,6 +8,22 @@ export function canStartTabDrag(pointerType: string) {
return pointerType !== "touch" return pointerType !== "touch"
} }
export function isPrimaryPointerPressed(buttons: number) {
return (buttons & 1) !== 0
}
export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) {
const rect = element.getBoundingClientRect()
return {
startX: clientX,
startY: clientY,
grabOffsetX: clientX - rect.left,
grabOffsetY: clientY - rect.top,
width: rect.width,
element,
}
}
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) { export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
if (typeof ref === "function") ref(element) if (typeof ref === "function") ref(element)
} }
@@ -15,3 +31,7 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) { export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
return !dragging && !editing && !committing return !dragging && !editing && !committing
} }
export function createTabDragPreview(element: HTMLDivElement) {
return element.cloneNode(true) as HTMLDivElement
}
@@ -9,10 +9,6 @@
justify-content: center; justify-content: center;
} }
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none;
}
[data-titlebar-tab-list] { [data-titlebar-tab-list] {
gap: 13.5px; gap: 13.5px;
} }
@@ -21,7 +17,7 @@
position: relative; position: relative;
} }
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before { [data-titlebar-tab-slot]:not(:first-child)::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 8px; top: 8px;
@@ -32,10 +28,6 @@
background: var(--v2-background-bg-layer-02); background: var(--v2-background-bg-layer-02);
} }
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
display: none;
}
[data-titlebar-tab] [data-slot="tab-close"]::before { [data-titlebar-tab] [data-slot="tab-close"]::before {
content: ""; content: "";
position: absolute; position: absolute;
+163 -156
View File
@@ -13,9 +13,6 @@ import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import "./titlebar-tab-nav.css" import "./titlebar-tab-nav.css"
// MouseEvent.button uses 1 for the middle/wheel button.
const MIDDLE_MOUSE_BUTTON = 1
export function TabNavItem(props: { export function TabNavItem(props: {
ref?: Ref<HTMLDivElement> ref?: Ref<HTMLDivElement>
href: string href: string
@@ -32,6 +29,9 @@ export function TabNavItem(props: {
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) { }) {
const language = useLanguage() const language = useLanguage()
const [editing, setEditing] = createSignal(false) const [editing, setEditing] = createSignal(false)
@@ -173,110 +173,112 @@ export function TabNavItem(props: {
return ( return (
<div <div
ref={(el) => { data-titlebar-tab-slot
tabRoot = el data-tab-key={props.tabKey}
forwardTabRef(props.ref, el) class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
}} classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
data-titlebar-tab onPointerDown={props.onPointerDown}
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)]"
classList={{ invisible: props.hidden }}
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
event.stopPropagation()
}}
onAuxClick={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
closeTab(event)
}}
> >
<Show when={props.session()}> <div
{(session) => { ref={(el) => {
return ( tabRoot = el
<a forwardTabRef(props.ref, el)
data-slot="tab-link" }}
data-titlebar-tab-link data-titlebar-tab
href={props.href} data-slot="titlebar-tab-item"
draggable={false} data-title-overflow={titleOverflowing()}
onDragStart={(event) => { data-editing={editing()}
event.preventDefault() 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)]"
event.stopPropagation() data-active={props.active}
}} data-dragging={props.dragging}
onClick={(event) => { data-pressed={props.pressed}
event.preventDefault() onMouseDown={(event) => {
if (editing()) return if (event.button !== 1) return
if (props.suppressNavigation?.()) return closeTab(event)
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]" <Show when={props.session()}>
> {(session) => {
<span data-slot="project-avatar-slot"> return (
<SessionTabAvatar <a
project={project()} data-slot="tab-link"
directory={session().directory} data-titlebar-tab-link
sessionId={session().id} href={props.href}
activeServer={props.activeServer} draggable={false}
/> onDragStart={(event) => {
</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() event.preventDefault()
titleEl.textContent = session().title
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation() event.stopPropagation()
}} }}
onClick={(event) => { onClick={(event) => {
if (!editing()) return
event.preventDefault() 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]"
</a> >
) <span data-slot="project-avatar-slot">
}} <SessionTabAvatar
</Show> project={project()}
directory={session().directory}
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]"> sessionId={session().id}
<IconButtonV2 activeServer={props.activeServer}
size="small" />
variant="ghost-muted" </span>
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100" <span
onPointerDown={(event) => { ref={(el) => {
event.preventDefault() titleEl = el
event.stopPropagation() 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} </Show>
icon={<IconV2 name="xmark-small" />}
/> <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>
</div> </div>
) )
@@ -293,6 +295,9 @@ export function DraftTabItem(props: {
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) { }) {
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
@@ -301,67 +306,69 @@ export function DraftTabItem(props: {
} }
return ( return (
<div <div
ref={(el) => forwardTabRef(props.ref, el)} data-titlebar-tab-slot
data-titlebar-tab data-tab-key={props.tabKey}
data-slot="titlebar-tab-item" class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
data-active={props.active} classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
data-dragging={props.dragging} onPointerDown={props.onPointerDown}
data-pressed={props.pressed}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--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)]"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
event.stopPropagation()
}}
onAuxClick={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
closeTab(event)
}}
> >
<a <div
data-slot="tab-link" ref={(el) => forwardTabRef(props.ref, el)}
data-titlebar-tab-link data-titlebar-tab
href={props.href} data-slot="titlebar-tab-item"
draggable={false} data-active={props.active}
onDragStart={(event) => { data-dragging={props.dragging}
event.preventDefault() data-pressed={props.pressed}
event.stopPropagation() 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 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"> <a
<IconV2 name="edit" /> data-slot="tab-link"
</span> data-titlebar-tab-link
<span href={props.href}
data-titlebar-tab-title draggable={false}
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4" onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
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 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
> >
{props.title} <span class="flex size-4 shrink-0 items-center justify-center">
</span> <IconV2 name="edit" />
</a> </span>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]"> <span
<IconButtonV2 data-titlebar-tab-title
size="small" class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
variant="ghost-muted" >
onPointerDown={(event) => { {props.title}
event.preventDefault() </span>
event.stopPropagation() </a>
}} <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
onMouseDown={(event) => { <IconButtonV2
event.preventDefault() size="small"
event.stopPropagation() variant="ghost-muted"
}} onPointerDown={(event) => {
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100" event.preventDefault()
onClick={closeTab} event.stopPropagation()
icon={<IconV2 name="xmark-small" />} }}
aria-label="Close tab" 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>
</div> </div>
) )
+393 -162
View File
@@ -1,11 +1,18 @@
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js" import {
createEffect,
createMemo,
createResource,
createRoot,
createSignal,
For,
onCleanup,
onMount,
Show,
} from "solid-js"
import { Portal } from "solid-js/web"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs" import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav" import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
@@ -15,30 +22,42 @@ import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt" import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" import {
captureTabPointerDown,
const sortableTransition = { duration: 0 } canStartTabDrag,
createTabDragPreview,
isPrimaryPointerPressed,
isTabCloseTarget,
} from "./titlebar-tab-gesture"
import {
ACTIVATION_DISTANCE,
autoscrollSpeed,
captureTabDragLayout,
clampFloaterLeft,
draftOrderChanged,
insertIndexFromVirtualLayout,
movePlaceholder,
pointerDistance,
syncLayoutScroll,
type TabDragLayout,
} from "@/components/titlebar-tab-drag"
function SessionTabSlot(props: { function SessionTabSlot(props: {
tab: SessionTab tab: SessionTab
id: string id: string
index: () => number
active: () => boolean active: () => boolean
activeServerKey: ServerConnection.Key activeServerKey: ServerConnection.Key
forceTruncate: boolean forceTruncate: boolean
dragActive: boolean
dragged: () => boolean
pressed: () => boolean
serverCtx: () => ServerCtx | undefined serverCtx: () => ServerCtx | undefined
suppressNavigation: () => boolean
onPointerDown: (event: PointerEvent) => void
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
onClose: () => void onClose: () => void
}) { }) {
const tabs = useTabs() const tabs = useTabs()
const sortable = useSortable({
get id() {
return props.id
},
get index() {
return props.index()
},
})
let ref!: HTMLDivElement let ref!: HTMLDivElement
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
@@ -81,81 +100,33 @@ function SessionTabSlot(props: {
}) })
return ( return (
<div <TabNavItem
ref={sortable.ref} tabKey={props.id}
data-titlebar-tab-slot dragActive={props.dragActive}
data-tab-key={props.id} onPointerDown={props.onPointerDown}
data-active={props.active()} ref={ref}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink" href={tabHref(props.tab)}
classList={{ hidden: !session() }} server={props.tab.server}
> session={session}
<TabNavItem onTitleChange={(title) => {
ref={(el) => { const value = session()
ref = el const ctx = props.serverCtx()
}} if (value && ctx) ctx.sync.session.remember({ ...value, title })
href={tabHref(props.tab)} }}
server={props.tab.server} onTitleChangeFailed={(title) => {
session={session} const value = session()
onTitleChange={(title) => { const ctx = props.serverCtx()
const value = session() if (value && ctx) ctx.sync.session.remember({ ...value, title })
const ctx = props.serverCtx() }}
if (value && ctx) ctx.sync.session.remember({ ...value, title }) onNavigate={() => props.onNavigate(ref)}
}} onClose={props.onClose}
onTitleChangeFailed={(title) => { active={props.active()}
const value = session() activeServer={props.tab.server === props.activeServerKey}
const ctx = props.serverCtx() forceTruncate={props.forceTruncate}
if (value && ctx) ctx.sync.session.remember({ ...value, title }) suppressNavigation={props.suppressNavigation}
}} pressed={props.pressed()}
onNavigate={() => props.onNavigate(ref)} hidden={props.dragged() || !session()}
onClose={props.onClose} />
active={props.active()}
activeServer={props.tab.server === props.activeServerKey}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/>
</div>
)
}
function DraftTabSlot(props: {
tab: Extract<Tab, { type: "draft" }>
id: string
index: () => number
active: () => boolean
title: string
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const sortable = useSortable({
get id() {
return props.id
},
get index() {
return props.index()
},
})
let ref!: HTMLDivElement
return (
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<DraftTabItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
title={props.title}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
dragging={sortable.isDragSource()}
/>
</div>
) )
} }
@@ -171,12 +142,52 @@ export function TitlebarTabStrip(props: {
}) { }) {
const global = useGlobal() const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const [drag, setDrag] = createStore({
active: false,
draggedId: undefined as string | undefined,
placeholderIndex: 0,
draftOrder: [] as string[],
initialOrder: [] as string[],
draggedWidth: 0,
pointerX: 0,
grabOffsetX: 0,
floaterTop: 0,
})
const [gesture, setGesture] = createStore({
pending: undefined as
| {
id: string
startX: number
startY: number
grabOffsetX: number
grabOffsetY: number
pointerId: number
width: number
element: HTMLDivElement
}
| undefined,
})
const [suppressNavigation, setSuppressNavigation] = createSignal(false)
const [pressedId, setPressedId] = createSignal<string | undefined>()
const [stripScrollLeft, setStripScrollLeft] = createSignal(0)
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement let listRef!: HTMLDivElement
let dragLayout: TabDragLayout | undefined
let dragPointerId: number | undefined
let autoscrollFrame: number | undefined
let resizeFrame: number | undefined let resizeFrame: number | undefined
let dragPreview: HTMLDivElement | undefined
const tabIds = () => props.tabs.map(tabKey) const tabIds = () => props.tabs.map(tabKey)
const displayTabs = createMemo(() => {
if (!drag.active || drag.draftOrder.length === 0) return props.tabs
const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab]))
return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
})
function refreshOverflow() { function refreshOverflow() {
if (!scrollRef) return if (!scrollRef) return
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth) props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
@@ -189,14 +200,220 @@ export function TitlebarTabStrip(props: {
resizeFrame = requestAnimationFrame(() => { resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined resizeFrame = undefined
refreshOverflow() refreshOverflow()
if (!drag.active || !listRef) return
dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
updateInsertIndex()
}) })
}, },
) )
onMount(() => { function syncScroll() {
if (!scrollRef || !listRef || !dragLayout) return
syncLayoutScroll(listRef, dragLayout)
setStripScrollLeft(scrollRef.scrollLeft)
updateInsertIndex()
}
function stopAutoscroll() {
if (autoscrollFrame === undefined) return
cancelAnimationFrame(autoscrollFrame)
autoscrollFrame = undefined
}
function tickAutoscroll() {
if (!drag.active || !scrollRef) return
const strip = scrollRef.getBoundingClientRect()
const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right)
if (speed !== 0) {
scrollRef.scrollLeft += speed
syncScroll()
}
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
}
function startAutoscroll() {
stopAutoscroll()
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
}
function applyPlaceholderIndex(nextIndex: number) {
const id = drag.draggedId
if (!id) return
const next = movePlaceholder(drag.draftOrder, id, nextIndex)
setDrag({
draftOrder: next,
placeholderIndex: nextIndex,
})
}
function updateInsertIndex() {
if (!drag.active || !dragLayout) return
const draggedId = drag.draggedId
if (!draggedId) return
const nextIndex = insertIndexFromVirtualLayout(
drag.pointerX,
drag.draftOrder,
draggedId,
drag.placeholderIndex,
dragLayout,
)
if (nextIndex === drag.placeholderIndex) return
applyPlaceholderIndex(nextIndex)
}
function startDrag(id: string) {
const order = tabIds()
const index = order.indexOf(id)
const pending = gesture.pending
if (index === -1 || !pending || !listRef || !scrollRef) return
dragLayout = captureTabDragLayout(listRef, order)
dragPreview = createTabDragPreview(pending.element)
dragPointerId = pending.pointerId
setGesture("pending", undefined)
setDrag({
active: true,
draggedId: id,
placeholderIndex: index,
draftOrder: order,
initialOrder: order,
draggedWidth: pending.width,
pointerX: pending.startX,
grabOffsetX: pending.grabOffsetX,
floaterTop: pending.startY - pending.grabOffsetY,
})
setPressedId(undefined)
setStripScrollLeft(scrollRef.scrollLeft)
startAutoscroll()
}
function endDrag(commit: boolean) {
const initial = drag.initialOrder
const final = drag.draftOrder
const moved = drag.active
if (commit && moved && draftOrderChanged(initial, final)) {
props.onReorder(final)
}
if (moved) setSuppressNavigation(true)
setDrag({
active: false,
draggedId: undefined,
placeholderIndex: 0,
draftOrder: [],
initialOrder: [],
draggedWidth: 0,
pointerX: 0,
grabOffsetX: 0,
floaterTop: 0,
})
dragLayout = undefined
dragPreview = undefined
dragPointerId = undefined
setGesture("pending", undefined)
setPressedId(undefined)
stopAutoscroll()
refreshOverflow() refreshOverflow()
requestAnimationFrame(() => setSuppressNavigation(false))
}
function onPointerDown(id: string, event: PointerEvent) {
if (event.button !== 0 || drag.active) return
if (!canStartTabDrag(event.pointerType)) return
if (isTabCloseTarget(event.target)) return
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)
if (!tab) return
const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
setSuppressNavigation(true)
props.onNavigate(tab, tabEl)
setPressedId(id)
setGesture("pending", {
id,
pointerId: event.pointerId,
...pointer,
})
}
function onPointerMove(event: PointerEvent) {
const pending = gesture.pending
if (pending && event.pointerId !== pending.pointerId) return
if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
if (!isPrimaryPointerPressed(event.buttons)) {
if (drag.active) endDrag(true)
if (pending) {
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
return
}
if (pending && !drag.active) {
if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
startDrag(pending.id)
}
if (!drag.active) return
setDrag("pointerX", event.clientX)
syncScroll()
}
function onPointerUp(event: PointerEvent) {
if (drag.active) {
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
setDrag("pointerX", event.clientX)
syncScroll()
endDrag(true)
return
}
const pending = gesture.pending
if (pending && event.pointerId !== pending.pointerId) return
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
function onPointerCancel(event: PointerEvent) {
if (drag.active) {
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
endDrag(false)
return
}
if (!gesture.pending) return
if (gesture.pending.pointerId !== event.pointerId) return
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
onMount(() => {
const cleanups = [
makeEventListener(window, "pointermove", onPointerMove),
makeEventListener(window, "pointerup", onPointerUp),
makeEventListener(window, "pointercancel", onPointerCancel),
]
refreshOverflow()
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
}) })
onCleanup(stopAutoscroll)
onCleanup(() => { onCleanup(() => {
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
}) })
@@ -207,54 +424,50 @@ export function TitlebarTabStrip(props: {
refreshOverflow() refreshOverflow()
}) })
return ( createEffect(() => {
<div data-slot="titlebar-tabs" class="relative min-w-0"> if (!drag.active || !scrollRef) return
<div onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
data-slot="titlebar-tabs-scroll" })
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={scrollRef}
>
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
!canStartTabDrag(event.pointerType) ||
isTabCloseTarget(event.target) ||
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
}),
]}
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragStart={(event) => {
const source = event.operation.source
if (!source) return
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
if (!tab) return
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
props.onNavigate(tab, tabEl ?? undefined)
}}
onDragEnd={(event) => {
const current = tabIds()
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
const { initialIndex, index } = source const floaterStyle = () => {
if (initialIndex !== index) { stripScrollLeft()
props.onReorder(arrayMove(current, source.initialIndex, source.index)) const strip = scrollRef?.getBoundingClientRect()
} const left = strip
}} ? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right)
: drag.pointerX - drag.grabOffsetX
return {
position: "fixed" as const,
top: `${drag.floaterTop}px`,
left: `${left}px`,
width: `${drag.draggedWidth}px`,
"z-index": "10000",
"pointer-events": "none" as const,
}
}
const draggedTab = createMemo(() => {
const id = drag.draggedId
if (!id) return
return props.tabs.find((tab) => tabKey(tab) === id)
})
return (
<>
<div data-slot="titlebar-tabs" class="relative min-w-0">
<div
data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={scrollRef}
> >
<div data-titlebar-tab-list class="flex w-full 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={props.tabs}> <For each={displayTabs()}>
{(tab, index) => { {(tab, index) => {
const id = tabKey(tab) const id = tabKey(tab)
let ref!: HTMLDivElement let ref!: HTMLDivElement
useTabShortcut(index, () => props.onNavigate(tab, ref)) useTabShortcut(index, () => props.onNavigate(tab, ref))
const dragged = () => drag.active && drag.draggedId === id
const serverCtx = createMemo(() => { const serverCtx = createMemo(() => {
if (tab.type !== "session") return if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
@@ -266,50 +479,68 @@ export function TitlebarTabStrip(props: {
<SessionTabSlot <SessionTabSlot
tab={tab} tab={tab}
id={id} id={id}
index={index}
active={() => props.currentTab() === tab} active={() => props.currentTab() === tab}
activeServerKey={props.activeServerKey} activeServerKey={props.activeServerKey}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
dragActive={drag.active}
dragged={dragged}
pressed={() => pressedId() === id}
serverCtx={serverCtx} serverCtx={serverCtx}
onNavigate={(element) => { suppressNavigation={() => suppressNavigation()}
ref = element onPointerDown={(event) => {
props.onNavigate(tab, element) if (dragged()) return
onPointerDown(id, event)
}} }}
onNavigate={(element) => props.onNavigate(tab, element)}
onClose={() => props.onClose(tab)} onClose={() => props.onClose(tab)}
/> />
) )
} }
return ( return (
<DraftTabSlot <DraftTabItem
tab={tab} tabKey={id}
id={id} dragActive={drag.active}
index={index} onPointerDown={(event) => {
active={() => props.currentTab() === tab} if (dragged()) return
title={language.t("command.session.new")} onPointerDown(id, event)
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}} }}
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")}
onNavigate={() => props.onNavigate(tab, ref)}
onClose={() => props.onClose(tab)} onClose={() => props.onClose(tab)}
suppressNavigation={() => suppressNavigation()}
active={props.currentTab() === tab}
pressed={pressedId() === id}
hidden={dragged()}
/> />
) )
}} }}
</For> </For>
</div> </div>
</DragDropProvider> </div>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
</div> </div>
<div <Show when={drag.active && draggedTab() && dragPreview}>
data-slot="titlebar-tabs-fade-left" {(_) => (
aria-hidden="true" <Portal>
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]" <div data-titlebar-tab-preview style={floaterStyle()}>
/> {dragPreview}
<div </div>
data-slot="titlebar-tabs-fade-right" </Portal>
aria-hidden="true" )}
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]" </Show>
/> </>
</div>
) )
} }
+2 -11
View File
@@ -99,8 +99,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const path = () => `${location.pathname}${location.search}${location.hash}` const path = () => `${location.pathname}${location.search}${location.hash}`
const creating = createMemo(() => { const creating = createMemo(() => {
const route = layout.route()
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
if (!params.dir) return false if (!params.dir) return false
if (params.id) return false if (params.id) return false
const parts = location.pathname.replace(/\/+$/, "").split("/") const parts = location.pathname.replace(/\/+$/, "").split("/")
@@ -229,8 +227,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}} }}
style={{ style={{
"min-height": minHeight(), "min-height": minHeight(),
// Keep native macOS traffic lights clear even when the desktop window is narrow. "padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0,
"padding-left": mac() ? `${84 / zoom()}px` : 0,
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": electronWindows() "max-width": electronWindows()
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
@@ -322,12 +319,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return return
} }
const activeTab = currentTab()
if (activeTab?.type === "draft") {
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
return
}
const current = layout.projects.list()[0] const current = layout.projects.list()[0]
if (current) { if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "") tabs.newDraft({ server: server.key, directory: current.worktree }, "")
@@ -469,7 +460,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}} }}
onReorder={(keys) => tabsStoreActions.reorder(keys)} onReorder={(keys) => tabsStoreActions.reorder(keys)}
/> />
<Show when={!creating()}> <Show when={!(creating() && params.dir)}>
<TooltipV2 <TooltipV2
placement="bottom" placement="bottom"
value={ value={
@@ -16,7 +16,6 @@ describe("bootstrapDirectory", () => {
status: "loading", status: "loading",
agent: [], agent: [],
command: [], command: [],
reference: [],
project: "", project: "",
projectMeta: undefined, projectMeta: undefined,
icon: undefined, icon: undefined,
@@ -36,7 +35,6 @@ describe("bootstrapDirectory", () => {
question: {}, question: {},
mcp_ready: true, mcp_ready: true,
mcp: {}, mcp: {},
mcp_resource: {},
lsp_ready: true, lsp_ready: true,
lsp: [], lsp: [],
vcs: undefined, vcs: undefined,
@@ -69,7 +67,6 @@ describe("bootstrapDirectory", () => {
}, },
permission: { list: async () => ({ data: [] }) }, permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) }, question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: { mcp: {
status: async () => { status: async () => {
mcpReads.push("status") mcpReads.push("status")
@@ -6,7 +6,6 @@ import type {
Project, Project,
ProviderAuthResponse, ProviderAuthResponse,
QuestionRequest, QuestionRequest,
ReferenceInfo,
Session, Session,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
@@ -19,7 +18,7 @@ import type { ServerSession } from "../server-session"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { loadMcpQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { ScopedKey, type ServerScope } from "@/utils/server-scope"
@@ -196,13 +195,6 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk:
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
}) })
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions<ReferenceInfo[]>({
queryKey: [scope, directory, "references"] as const,
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
placeholderData: [],
})
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
scope: ServerScope scope: ServerScope
@@ -285,7 +277,6 @@ export async function bootstrapDirectory(input: {
}), }),
), ),
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
() => () =>
retry(() => retry(() =>
input.sdk.permission.list().then((x) => { input.sdk.permission.list().then((x) => {
@@ -348,7 +339,6 @@ export async function bootstrapDirectory(input: {
), ),
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
() => () =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory) const project = getFilename(input.directory)
@@ -34,9 +34,7 @@ const queryOptionsApi = {
}), }),
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }), agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }), mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
mcpResources: (directory: string) => ({ queryKey: [directory, "mcpResources"], queryFn: async () => ({}) }),
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }), lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
references: (directory: string) => ({ queryKey: [directory, "references"], queryFn: async () => [] }),
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }), sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
} as unknown as QueryOptionsApi } as unknown as QueryOptionsApi
@@ -199,18 +197,14 @@ describe("createChildStoreManager", () => {
try { try {
if (!manager) throw new Error("manager required") if (!manager) throw new Error("manager required")
const [store, setStore] = manager.child("/project", { bootstrap: false }) const [store, setStore] = manager.child("/project", { bootstrap: false })
expect(querySingles.length - offset).toBe(6) expect(querySingles.length - offset).toBe(4)
const query = querySingles[offset + 1] const query = querySingles[offset + 1]
const resourceQuery = querySingles[offset + 2]
if (!query) throw new Error("query required") if (!query) throw new Error("query required")
if (!resourceQuery) throw new Error("resource query required")
expect(query().enabled).toBe(false) expect(query().enabled).toBe(false)
expect(resourceQuery().enabled).toBe(false)
setStore("status", "complete") setStore("status", "complete")
manager.child("/project", { bootstrap: false, mcp: true }) manager.child("/project", { bootstrap: false, mcp: true })
expect(query().enabled).toBe(true) expect(query().enabled).toBe(true)
expect(resourceQuery().enabled).toBe(true)
expect(store.mcp).toEqual({ demo: { status: "disabled" } }) expect(store.mcp).toEqual({ demo: { status: "disabled" } })
expect(mcpLoads).toEqual(["/project"]) expect(mcpLoads).toEqual(["/project"])
@@ -185,10 +185,8 @@ export function createChildStoreManager(input: {
const pathQuery = useQuery(() => input.queryOptions.path(key)) const pathQuery = useQuery(() => input.queryOptions.path(key))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => input.queryOptions.lsp(key)) const lspQuery = useQuery(() => input.queryOptions.lsp(key))
const providerQuery = useQuery(() => input.queryOptions.providers(key)) const providerQuery = useQuery(() => input.queryOptions.providers(key))
const referenceQuery = useQuery(() => input.queryOptions.references(key))
const child = createStore<State>({ const child = createStore<State>({
project: "", project: "",
@@ -212,9 +210,6 @@ export function createChildStoreManager(input: {
status: "loading" as const, status: "loading" as const,
agent: [], agent: [],
command: [], command: [],
get reference() {
return referenceQuery.isLoading ? [] : (referenceQuery.data ?? [])
},
session: [], session: [],
sessionTotal: 0, sessionTotal: 0,
session_status: {}, session_status: {},
@@ -232,9 +227,6 @@ export function createChildStoreManager(input: {
get mcp() { get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
}, },
get mcp_resource() {
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
},
get lsp_ready() { get lsp_ready() {
return !lspQuery.isLoading return !lspQuery.isLoading
}, },
@@ -112,7 +112,6 @@ export function applyDirectoryEvent(input: {
push: (directory: string) => void push: (directory: string) => void
directory: string directory: string
loadLsp: () => void loadLsp: () => void
loadReferences?: () => void
vcsCache?: VcsCache vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number retainedLimit?: number
@@ -405,9 +404,5 @@ export function applyDirectoryEvent(input: {
input.loadLsp() input.loadLsp()
break break
} }
case "reference.updated": {
input.loadReferences?.()
break
}
} }
} }
@@ -3,14 +3,12 @@ import type {
Command, Command,
Config, Config,
LspStatus, LspStatus,
McpResource,
McpStatus, McpStatus,
Message, Message,
Part, Part,
Path, Path,
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
ReferenceInfo,
Session, Session,
SessionStatus, SessionStatus,
SnapshotFileDiff, SnapshotFileDiff,
@@ -36,7 +34,6 @@ export type State = {
status: "loading" | "partial" | "complete" status: "loading" | "partial" | "complete"
agent: Agent[] agent: Agent[]
command: Command[] command: Command[]
reference: ReferenceInfo[]
project: string project: string
projectMeta: ProjectMeta | undefined projectMeta: ProjectMeta | undefined
icon: string | undefined icon: string | undefined
@@ -66,9 +63,6 @@ export type State = {
mcp: { mcp: {
[name: string]: McpStatus [name: string]: McpStatus
} }
mcp_resource: {
[key: string]: McpResource
}
lsp_ready: boolean lsp_ready: boolean
lsp: LspStatus[] lsp: LspStatus[]
vcs: VcsInfo | undefined vcs: VcsInfo | undefined
+14 -48
View File
@@ -1,4 +1,4 @@
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { useLocation } from "@solidjs/router" import { useLocation } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -17,7 +17,6 @@ import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route" import { requireServerKey } from "@/utils/session-route"
import { type DraftTab, useTabs } from "./tabs"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -28,7 +27,6 @@ const DEFAULT_SIDEBAR_WIDTH = 344
const DEFAULT_FILE_TREE_WIDTH = 200 const DEFAULT_FILE_TREE_WIDTH = 200
const DEFAULT_SESSION_WIDTH = 600 const DEFAULT_SESSION_WIDTH = 600
const DEFAULT_TERMINAL_HEIGHT = 280 const DEFAULT_TERMINAL_HEIGHT = 280
const DEFAULT_REVIEW_PANEL_OPENED = false
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
export function getAvatarColors(key?: string) { export function getAvatarColors(key?: string) {
@@ -75,10 +73,8 @@ type TabHandoff = {
} }
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean } export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type ReviewDiffStyle = "unified" | "split" export type ReviewDiffStyle = "unified" | "split"
export type ReviewPanelSource = "context-button" | "other"
export type LayoutRoute = export type LayoutRoute =
| { type: "home" } | { type: "home" }
@@ -162,17 +158,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const server = useServer() const server = useServer()
const tabs = useTabs()
const platform = usePlatform() const platform = usePlatform()
const location = useLocation() const location = useLocation()
const route = createMemo(() => { const route = createMemo(() => {
const value = currentRoute(location.pathname, location.search) const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value if (value.type === "home") return value
if (value.server) 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 } return { ...value, server: server.key }
}) })
@@ -212,8 +203,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!isRecord(review)) return review if (!isRecord(review)) return review
if (typeof review.panelOpened === "boolean") return review if (typeof review.panelOpened === "boolean") return review
const opened = const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true
isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : DEFAULT_REVIEW_PANEL_OPENED
return { return {
...review, ...review,
panelOpened: opened, panelOpened: opened,
@@ -282,7 +272,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
review: { review: {
diffStyle: "split" as ReviewDiffStyle, diffStyle: "split" as ReviewDiffStyle,
panelOpened: DEFAULT_REVIEW_PANEL_OPENED, panelOpened: true,
}, },
fileTree: { fileTree: {
opened: false, opened: false,
@@ -300,14 +290,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
handoff: { handoff: {
tabs: undefined as TabHandoff | undefined, tabs: undefined as TabHandoff | undefined,
}, },
home: {
selection: { server: server.key } as HomeProjectSelection,
},
}), }),
) )
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
})
const MAX_SESSION_KEYS = 50 const MAX_SESSION_KEYS = 50
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
@@ -595,12 +579,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return { return {
route, route,
ready, ready,
home: {
selection: createMemo(() => store.home.selection),
setSelection(selection: HomeProjectSelection) {
setStore("home", "selection", reconcile(selection))
},
},
handoff: { handoff: {
tabs: createMemo(() => store.handoff?.tabs), tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) { setTabs(dir: string, id: string) {
@@ -668,7 +646,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
setDiffStyle(diffStyle: ReviewDiffStyle) { setDiffStyle(diffStyle: ReviewDiffStyle) {
if (!store.review) { if (!store.review) {
setStore("review", { diffStyle, panelOpened: DEFAULT_REVIEW_PANEL_OPENED }) setStore("review", { diffStyle, panelOpened: true })
return return
} }
setStore("review", "diffStyle", diffStyle) setStore("review", "diffStyle", diffStyle)
@@ -783,8 +761,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const key = createSessionKeyReader(sessionKey, ensureKey) const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const terminalOpened = createMemo(() => store.terminal?.opened ?? false) const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED) const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
function setTerminalOpened(next: boolean) { function setTerminalOpened(next: boolean) {
const current = store.terminal const current = store.terminal
@@ -798,26 +775,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("terminal", "opened", next) setStore("terminal", "opened", next)
} }
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) { function setReviewPanelOpened(next: boolean) {
const nextSource = next ? source : "other"
const current = store.review const current = store.review
if (!current) { if (!current) {
batch(() => { setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
setEphemeral("reviewPanelSource", nextSource)
})
return return
} }
const value = current.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED const value = current.panelOpened ?? true
if (value === next) { if (value === next) return
if (ephemeral.reviewPanelSource !== nextSource) setEphemeral("reviewPanelSource", nextSource) setStore("review", "panelOpened", next)
return
}
batch(() => {
setStore("review", "panelOpened", next)
setEphemeral("reviewPanelSource", nextSource)
})
} }
return { return {
@@ -853,15 +820,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
reviewPanel: { reviewPanel: {
opened: reviewPanelOpened, opened: reviewPanelOpened,
source: reviewPanelSource, open() {
open(source: ReviewPanelSource = "other") { setReviewPanelOpened(true)
setReviewPanelOpened(true, source)
}, },
close() { close() {
setReviewPanelOpened(false, "other") setReviewPanelOpened(false)
}, },
toggle() { toggle() {
setReviewPanelOpened(!reviewPanelOpened(), "other") setReviewPanelOpened(!reviewPanelOpened())
}, },
}, },
review: { review: {
+49 -73
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js" import { batch, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
@@ -18,12 +18,10 @@ type State = {
agent?: string agent?: string
model?: ModelKey model?: ModelKey
variant?: string | null variant?: string | null
source?: "manual" | "message"
} }
type Saved = { type Saved = {
session: Record<string, State | undefined> session: Record<string, State | undefined>
draft?: State
} }
const WORKSPACE_KEY = "__workspace__" const WORKSPACE_KEY = "__workspace__"
@@ -39,13 +37,11 @@ const migrate = (value: unknown) => {
pick?: Record<string, State | undefined> pick?: Record<string, State | undefined>
} }
const draft = "draft" in item && item.draft && typeof item.draft === "object" ? (item.draft as State) : undefined if (item.session && typeof item.session === "object") return { session: item.session }
if (item.session && typeof item.session === "object") return { session: item.session, draft }
if (!item.pick || typeof item.pick !== "object") return { session: {} } if (!item.pick || typeof item.pick !== "object") return { session: {} }
return { return {
session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)), session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
draft,
} }
} }
@@ -61,7 +57,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local", name: "Local",
init: () => { init: () => {
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
@@ -69,15 +64,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const models = useModels() const models = useModels()
const id = createMemo(() => params.id || undefined) const id = createMemo(() => params.id || undefined)
const draftID = createMemo(() => search.draftId || undefined)
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved] = persisted( const [saved, setSaved] = persisted(
{ {
...(draftID() ...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
? Persist.draft(draftID()!, "model-selection")
: Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"])),
migrate, migrate,
}, },
createStore<Saved>({ createStore<Saved>({
@@ -88,7 +80,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
current?: string current?: string
draft?: State draft?: State
promoting?: State
last?: { last?: {
type: "agent" | "model" | "variant" type: "agent" | "model" | "variant"
agent?: string agent?: string
@@ -132,7 +123,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => { const scope = createMemo<State | undefined>(() => {
const session = id() const session = id()
if (!session) return saved.draft ?? store.draft ?? store.promoting if (!session) return store.draft
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session)) return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
}) })
@@ -145,13 +136,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!next) return if (!next) return
if (saved.session[session] !== undefined) { if (saved.session[session] !== undefined) {
handoff.delete(key) handoff.delete(key)
setStore("promoting", undefined)
return return
} }
setSaved("session", session, clone(next)) setSaved("session", session, clone(next))
handoff.delete(key) handoff.delete(key)
setStore("promoting", undefined)
}) })
const configuredModel = () => { const configuredModel = () => {
@@ -186,19 +175,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const fallback = createMemo<ModelKey | undefined>(() => configuredModel() ?? recentModel() ?? defaultModel()) const fallback = createMemo<ModelKey | undefined>(() => configuredModel() ?? recentModel() ?? defaultModel())
const save = (state: State) => {
const session = id()
if (session) {
setSaved("session", session, state)
return
}
if (draftID()) {
setSaved("draft", state)
return
}
setStore("draft", state)
}
const agent = { const agent = {
list, list,
current() { current() {
@@ -224,9 +200,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent: item.name, agent: item.name,
model: item.model ?? prev?.model, model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant, variant: item.variant ?? prev?.variant,
source: "manual",
} satisfies State } satisfies State
save(next) const session = id()
if (session) {
setSaved("session", session, next)
return
}
setStore("draft", next)
}) })
}, },
move(direction: 1 | -1) { move(direction: 1 | -1) {
@@ -280,10 +260,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const state = { const state = {
...(scope() ?? { agent: agent.current()?.name }), ...(scope() ?? { agent: agent.current()?.name }),
...next, ...next,
source: "manual",
} satisfies State } satisfies State
save(state) const session = id()
if (session) {
setSaved("session", session, state)
return
}
setStore("draft", state)
} }
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean)) const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
@@ -310,21 +294,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model.set({ providerID: entry.provider.id, modelID: entry.id }) model.set({ providerID: entry.provider.id, modelID: entry.id })
}, },
set(item: ModelKey | undefined, options?: { recent?: boolean }) { set(item: ModelKey | undefined, options?: { recent?: boolean }) {
startTransition(() => batch(() => {
batch(() => { setStore("last", {
setStore("last", { type: "model",
type: "model", agent: agent.current()?.name,
agent: agent.current()?.name, model: item ?? null,
model: item ?? null, variant: selected(),
variant: selected(), })
}) write({ model: item })
write({ model: item }) if (!item) return
if (!item) return models.setVisibility(item, true)
models.setVisibility(item, true) if (!options?.recent) return
if (!options?.recent) return models.recent.push(item)
models.recent.push(item) })
}),
)
}, },
visible(item: ModelKey) { visible(item: ModelKey) {
return models.visible(item) return models.visible(item)
@@ -353,21 +335,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return Object.keys(item.variants) return Object.keys(item.variants)
}, },
set(value: string | undefined) { set(value: string | undefined) {
startTransition(() => batch(() => {
batch(() => { const model = current()
const model = current() setStore("last", {
setStore("last", { type: "variant",
type: "variant", agent: agent.current()?.name,
agent: agent.current()?.name, model: model ? { providerID: model.provider.id, modelID: model.id } : null,
model: model ? { providerID: model.provider.id, modelID: model.id } : null, variant: value ?? null,
variant: value ?? null, })
}) write({ variant: value ?? null })
write({ variant: value ?? null }) if (model) {
if (model) { models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined) }
} })
}),
)
}, },
cycle() { cycle() {
const items = this.list() const items = this.list()
@@ -389,36 +369,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent, agent,
session: { session: {
reset() { reset() {
setStore({ draft: undefined, promoting: undefined }) setStore("draft", undefined)
if (draftID()) setSaved("draft", undefined)
}, },
promote(dir: string, session: string) { promote(dir: string, session: string) {
const next = clone(snapshot()) const next = clone(snapshot())
if (!next) return if (!next) return
next.source = "manual"
const key = handoffKey(serverSDK().scope, dir, session)
handoff.set(key, next)
if (dir === sdk().directory) { if (dir === sdk().directory) {
setSaved("session", session, next) setSaved("session", session, next)
setStore("draft", undefined)
return
} }
setStore("promoting", next) handoff.set(handoffKey(serverSDK().scope, dir, session), next)
setStore("draft", undefined) setStore("draft", undefined)
}, },
restore(msg: { id?: string; sessionID: string; agent: string; model: ModelKey }) { restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
const session = id() const session = id()
if (!session) return if (!session) return
if (msg.sessionID !== session) return if (msg.sessionID !== session) return
const current = saved.session[session] if (saved.session[session] !== undefined) return
if (current?.source === "manual") return
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
setSaved("session", session, { setSaved("session", session, {
agent: msg.agent, agent: msg.agent,
model: msg.model, model: msg.model,
variant: msg.model?.variant ?? null, variant: msg.model?.variant ?? null,
source: "message",
}) })
}, },
}, },
+245 -343
View File
@@ -1,9 +1,9 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams, useSearchParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
@@ -12,11 +12,6 @@ import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2" import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound" import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { type DraftTab, useTabs } from "./tabs"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
type NotificationBase = { type NotificationBase = {
directory?: string directory?: string
@@ -112,360 +107,267 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification", name: "Notification",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>() const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>() const serverSDK = useServerSDK()
const global = useGlobal() const serverSync = useServerSync()
const server = useServer()
const tabs = useTabs()
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings() const settings = useSettings()
const language = useLanguage() const language = useLanguage()
const owner = getOwner()
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
const activeServer = createMemo(() => { const empty: Notification[] = []
if (params.serverKey) return requireServerKey(params.serverKey)
if (search.draftId) { const currentDirectory = createMemo(() => {
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId) return props.directory?.() ?? decode64(params.dir)
if (draft) return draft.server
}
return server.key
}) })
const activeDirectory = createMemo(() => decode64(params.dir))
const activeSession = createMemo(() => params.id)
const ensure = (key: ServerConnection.Key) => { const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
if (!conn) throw new Error(`Notification server not found: ${key}`) const [store, setStore, _, ready] = persisted(
const ctx = global.ensureServerCtx(conn) Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
const existing = states.get(ctx.sdk.scope) createStore({
if (existing) return existing.state list: [] as Notification[],
const root = createRoot( }),
(dispose) => ({ )
dispose, const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
state: createServerNotificationState({
sdk: ctx.sdk, const meta = { pruned: false, disposed: false }
sync: ctx.sync,
active: () => server.scope(activeServer()) === ctx.sdk.scope, const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
directory: activeDirectory, setIndex(scope, "unseen", key, unseen)
sessionID: activeSession, setIndex(scope, "unseenCount", key, unseen.length)
platform, setIndex(
settings, scope,
language, "unseenHasError",
}), key,
}), unseen.some((notification) => notification.type === "error"),
owner ?? undefined,
) )
states.set(ctx.sdk.scope, root) }
return root.state
const appendToIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
}
}
}
const removeFromIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
updateUnseen("session", notification.session, unseen)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
updateUnseen("project", notification.directory, unseen)
}
}
} }
createEffect(() => { createEffect(() => {
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn))) if (!ready()) return
}) if (meta.pruned) return
meta.pruned = true
createEffect(() => { const list = pruneNotifications(store.list)
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn)))) batch(() => {
states.forEach((value, scope) => { setStore("list", list)
if (scopes.has(scope)) return setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
value.dispose()
states.delete(scope)
}) })
}) })
onCleanup(() => states.forEach((value) => value.dispose())) const append = (notification: Notification) => {
const list = pruneNotifications([...store.list, notification])
const keep = new Set(list)
const removed = store.list.filter((n) => !keep.has(n))
const selected = () => ensure(activeServer()) batch(() => {
if (keep.has(notification)) appendToIndex(notification)
removed.forEach((n) => removeFromIndex(n))
setStore("list", list)
})
}
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const sync = serverSync().ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
.sync(sessionID)
.then(() => sync.session.get(sessionID))
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeDirectory) return false
if (!activeSession) return false
if (!sessionID) return false
if (directory !== activeDirectory) return false
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
}
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
unsub()
})
return { return {
ready: () => selected().ready(), ready,
ensureServerState: ensure,
session: { session: {
all: (session: string) => selected().session.all(session), all(session: string) {
unseen: (session: string) => selected().session.unseen(session), return index.session.all[session] ?? empty
unseenCount: (session: string) => selected().session.unseenCount(session), },
unseenHasError: (session: string) => selected().session.unseenHasError(session), unseen(session: string) {
markViewed: (session: string) => selected().session.markViewed(session), return index.session.unseen[session] ?? empty
},
unseenCount(session: string) {
return index.session.unseenCount[session] ?? 0
},
unseenHasError(session: string) {
return index.session.unseenHasError[session] ?? false
},
markViewed(session: string) {
const unseen = index.session.unseen[session] ?? empty
if (!unseen.length) return
const projects = [
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
]
batch(() => {
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
updateUnseen("session", session, [])
projects.forEach((directory) => {
const next = (index.project.unseen[directory] ?? empty).filter(
(notification) => notification.session !== session,
)
updateUnseen("project", directory, next)
})
})
},
}, },
project: { project: {
all: (directory: string) => selected().project.all(directory), all(directory: string) {
unseen: (directory: string) => selected().project.unseen(directory), return index.project.all[directory] ?? empty
unseenCount: (directory: string) => selected().project.unseenCount(directory), },
unseenHasError: (directory: string) => selected().project.unseenHasError(directory), unseen(directory: string) {
markViewed: (directory: string) => selected().project.markViewed(directory), return index.project.unseen[directory] ?? empty
},
unseenCount(directory: string) {
return index.project.unseenCount[directory] ?? 0
},
unseenHasError(directory: string) {
return index.project.unseenHasError[directory] ?? false
},
markViewed(directory: string) {
const unseen = index.project.unseen[directory] ?? empty
if (!unseen.length) return
const sessions = [
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
]
batch(() => {
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
updateUnseen("project", directory, [])
sessions.forEach((session) => {
const next = (index.session.unseen[session] ?? empty).filter(
(notification) => notification.directory !== directory,
)
updateUnseen("session", session, next)
})
})
},
}, },
} }
}, },
}) })
type NotificationState = ReturnType<typeof createServerNotificationState>
function createServerNotificationState(input: {
sdk: ServerSDK
sync: ServerSync
active: Accessor<boolean>
directory: Accessor<string | undefined>
sessionID: Accessor<string | undefined>
platform: ReturnType<typeof usePlatform>
settings: ReturnType<typeof useSettings>
language: ReturnType<typeof useLanguage>
}) {
const serverSDK = () => input.sdk
const serverSync = () => input.sync
const platform = input.platform
const settings = input.settings
const language = input.language
const empty: Notification[] = []
const currentDirectory = input.directory
const currentSession = input.sessionID
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
)
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
const meta = { pruned: false, disposed: false }
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
setIndex(scope, "unseen", key, unseen)
setIndex(scope, "unseenCount", key, unseen.length)
setIndex(
scope,
"unseenHasError",
key,
unseen.some((notification) => notification.type === "error"),
)
}
const appendToIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
}
}
}
const removeFromIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
updateUnseen("session", notification.session, unseen)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
updateUnseen("project", notification.directory, unseen)
}
}
}
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
const list = pruneNotifications(store.list)
batch(() => {
setStore("list", list)
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
})
})
const append = (notification: Notification) => {
const list = pruneNotifications([...store.list, notification])
const keep = new Set(list)
const removed = store.list.filter((n) => !keep.has(n))
batch(() => {
if (keep.has(notification)) appendToIndex(notification)
removed.forEach((n) => removeFromIndex(n))
setStore("list", list)
})
}
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const sync = serverSync().ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
.sync(sessionID)
.then(() => sync.session.get(sessionID))
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
if (!input.active()) return false
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeSession) return false
if (!sessionID) return false
if (activeDirectory && directory !== activeDirectory) return false
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
}
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
unsub()
})
return {
ready,
session: {
all(session: string) {
return index.session.all[session] ?? empty
},
unseen(session: string) {
return index.session.unseen[session] ?? empty
},
unseenCount(session: string) {
return index.session.unseenCount[session] ?? 0
},
unseenHasError(session: string) {
return index.session.unseenHasError[session] ?? false
},
markViewed(session: string) {
const unseen = index.session.unseen[session] ?? empty
if (!unseen.length) return
const projects = [
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
]
batch(() => {
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
updateUnseen("session", session, [])
projects.forEach((directory) => {
const next = (index.project.unseen[directory] ?? empty).filter(
(notification) => notification.session !== session,
)
updateUnseen("project", directory, next)
})
})
},
},
project: {
all(directory: string) {
return index.project.all[directory] ?? empty
},
unseen(directory: string) {
return index.project.unseen[directory] ?? empty
},
unseenCount(directory: string) {
return index.project.unseenCount[directory] ?? 0
},
unseenHasError(directory: string) {
return index.project.unseenHasError[directory] ?? false
},
markViewed(directory: string) {
const unseen = index.project.unseen[directory] ?? empty
if (!unseen.length) return
const sessions = [
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
]
batch(() => {
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
updateUnseen("project", directory, [])
sessions.forEach((session) => {
const next = (index.session.unseen[session] ?? empty).filter(
(notification) => notification.directory !== directory,
)
updateUnseen("session", session, next)
})
})
},
},
}
}
+4 -15
View File
@@ -9,10 +9,9 @@ import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs" import { useTabs, type Tab } from "./tabs"
import { ServerConnection } from "./server" import { ServerConnection, useServer } from "./server"
import { requireServerKey } from "@/utils/session-route" import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings" import { useSettings } from "./settings"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
interface PartBase { interface PartBase {
content: string content: string
@@ -28,10 +27,6 @@ export interface FileAttachmentPart extends PartBase {
type: "file" type: "file"
path: string path: string
selection?: FileSelection selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
} }
export interface AgentPart extends PartBase { export interface AgentPart extends PartBase {
@@ -78,13 +73,7 @@ function isPartEqual(partA: ContentPart, partB: ContentPart) {
case "text": case "text":
return partB.type === "text" && partA.content === partB.content return partB.type === "text" && partA.content === partB.content
case "file": case "file":
return ( return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection)
partB.type === "file" &&
partA.path === partB.path &&
partA.mime === partB.mime &&
partA.filename === partB.filename &&
isSelectionEqual(partA.selection, partB.selection)
)
case "agent": case "agent":
return partB.type === "agent" && partA.name === partB.name return partB.type === "agent" && partA.name === partB.name
case "image": case "image":
@@ -298,6 +287,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const sdk = useSDK() const sdk = useSDK()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const settings = useSettings() const settings = useSettings()
const cache = new Map<string, PromptCacheEntry>() const cache = new Map<string, PromptCacheEntry>()
@@ -322,8 +312,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const owner = getOwner() const owner = getOwner()
const serverKey = () => const serverKey = () => (params.serverKey ? requireServerKey(params.serverKey) : server.key)
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
const scope = () => const scope = () =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: Scope) => { const load = (scope: Scope) => {
+8 -115
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
import type { Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => { describe("resumeStreamAfterPageShow", () => {
@@ -15,23 +15,19 @@ describe("resumeStreamAfterPageShow", () => {
}) })
describe("coalesceServerEvents", () => { describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text", partID = "part") => ({ const delta = (value: string, field = "text") => ({
directory: "/repo", directory: "/repo",
payload: { payload: {
type: "message.part.delta", type: "message.part.delta",
properties: { messageID: "msg", partID, field, delta: value }, properties: { messageID: "msg", partID: "part", field, delta: value },
} as Event, } as Event,
}) })
test("merges adjacent deltas for the same field", () => { test("merges adjacent deltas for the same field", () => {
const first = delta("hello ") const result = coalesceServerEvents([delta("hello "), delta("world")])
const second = delta("world")
first.payload.id = "first"
second.payload.id = "second"
const result = coalesceServerEvents([first, second])
expect(result).toHaveLength(1) expect(result).toHaveLength(1)
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
}) })
test("preserves event boundaries and distinct fields", () => { test("preserves event boundaries and distinct fields", () => {
@@ -49,112 +45,9 @@ describe("coalesceServerEvents", () => {
]) ])
}) })
test("preserves event ID order across interleaved deltas", () => { test("drops stale deltas", () => {
const first = delta("a") const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
const other = delta("b", "text", "other")
const last = delta("c")
first.payload.id = "1"
other.payload.id = "2"
last.payload.id = "3"
const result = coalesceServerEvents([first, other, last]) expect(result).toEqual([])
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
})
})
describe("enqueueServerEvent", () => {
const partUpdated = (text: string) =>
({
type: "message.part.updated",
properties: {
sessionID: "session",
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
},
}) as Event
test("preserves part updates across message remove and re-add barriers", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
enqueue({
type: "message.updated",
properties: {
sessionID: "session",
info: {
id: "message",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
},
},
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"message.removed",
"message.updated",
"message.part.updated",
])
})
test("preserves deltas after a replacement snapshot", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("a"))
enqueue(partUpdated("ab"))
enqueue({
type: "message.part.delta",
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
} as Event)
const result = coalesceServerEvents(events)
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
})
test("preserves updates after session deletion", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({
type: "session.deleted",
properties: { sessionID: "session", info: { id: "session" } },
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"session.deleted",
"message.part.updated",
])
})
test("does not coalesce edge-triggered session statuses", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (status: "retry" | "busy") =>
enqueueServerEvent(events, {
directory: "/repo",
payload: {
type: "session.status",
properties: {
sessionID: "session",
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
},
} as Event,
})
enqueue("retry")
enqueue("busy")
expect(events).toHaveLength(2)
}) })
}) })
+50 -42
View File
@@ -17,56 +17,34 @@ const isAbortError = (error: unknown) =>
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: Event } type QueuedServerEvent = { directory: string; payload: Event }
const coalescedKey = (event: QueuedServerEvent) => { const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
if (event.payload.type === "message.part.updated") {
const part = event.payload.properties.part
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
}
return undefined
}
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) { export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
const key = coalescedKey(event)
const previous = queue[queue.length - 1]
if (key && previous && coalescedKey(previous) === key) {
queue[queue.length - 1] = event
return false
}
queue.push(event)
return true
}
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = [] const output: QueuedServerEvent[] = []
const deltas = new Map<string, number>()
events.forEach((event) => { events.forEach((event) => {
if (stale && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
}
if (event.payload.type !== "message.part.delta") { if (event.payload.type !== "message.part.delta") {
deltas.clear()
output.push(event) output.push(event)
return return
} }
const props = event.payload.properties const props = event.payload.properties
const previous = output[output.length - 1] const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
if ( const index = deltas.get(id)
!previous || const existing = index === undefined ? undefined : output[index]
previous.payload.type !== "message.part.delta" || if (!existing || existing.payload.type !== "message.part.delta") {
previous.directory !== event.directory || deltas.set(id, output.length)
previous.payload.properties.messageID !== props.messageID ||
previous.payload.properties.partID !== props.partID ||
previous.payload.properties.field !== props.field
) {
output.push({ output.push({
directory: event.directory, directory: event.directory,
payload: { ...event.payload, properties: { ...props } }, payload: { ...event.payload, properties: { ...props } },
}) })
return return
} }
output[output.length - 1] = { existing.payload.properties.delta += props.delta
directory: event.directory,
payload: {
...event.payload,
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
},
}
}) })
return output return output
} }
@@ -107,9 +85,20 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
let queue: Queued[] = [] let queue: Queued[] = []
let buffer: Queued[] = [] let buffer: Queued[] = []
const coalesced = new Map<string, number>()
const staleDeltas = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let last = 0 let last = 0
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => { const flush = () => {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
timer = undefined timer = undefined
@@ -117,12 +106,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
if (queue.length === 0) return if (queue.length === 0) return
const events = queue const events = queue
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
queue = buffer queue = buffer
buffer = events buffer = events
queue.length = 0 queue.length = 0
coalesced.clear()
staleDeltas.clear()
last = Date.now() last = Date.now()
const output = coalesceServerEvents(events) const output = coalesceServerEvents(events, skip)
batch(() => { batch(() => {
output.forEach((event) => emitter.emit(event.directory, event.payload)) output.forEach((event) => emitter.emit(event.directory, event.payload))
}) })
@@ -192,12 +184,29 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
for await (const event of events.stream) { for await (const event of events.stream) {
resetHeartbeat() resetHeartbeat()
streamErrorLogged = false streamErrorLogged = false
if (event.payload.type !== "sync") { const directory = event.directory ?? "global"
const directory = event.directory ?? "global" if (event.payload.type === "sync") {
const payload = event.payload as Event continue
if (enqueueServerEvent(queue, { directory, payload })) schedule()
} }
const payload = event.payload as Event
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now() yielded = Date.now()
await wait(0) await wait(0)
@@ -260,7 +269,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}) })
return { return {
server,
scope, scope,
url: server.http.url, url: server.http.url,
client: sdk, client: sdk,
File diff suppressed because it is too large Load Diff
+66 -537
View File
@@ -18,67 +18,44 @@ import { rootSession } from "@/utils/session-route"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 2 const initialMessagePageSize = 2
const historyMessagePageSize = 200 const historyMessagePageSize = 200
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
type OptimisticItem = { type OptimisticItem = {
message: Message message: Message
parts: Part[] parts: Part[]
confirmedParts?: Part[]
confirmedMessage?: boolean
} }
type MessagePage = { const hasParts = (parts: Part[] | undefined, want: Part[]) => {
session: Message[] if (!parts) return want.length === 0
part: { id: string; part: Part[] }[] return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
cursor?: string
complete: boolean
} }
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. function mergeOptimisticPage(
type MessageLoadState = { page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean },
touchedMessages: Set<string> items: OptimisticItem[],
removedMessages: Set<string> ) {
retainedMessages: Set<string> if (items.length === 0) return { ...page, confirmed: [] as string[] }
touchedParts: Map<string, Set<string>>
deltaParts: Map<string, Set<string>>
carriedDeltaParts: Map<string, Set<string>>
removedParts: Map<string, Set<string>>
optimisticParts: Map<string, Set<string>>
orphanParents: Set<string>
clearedMessageParts: Set<string>
}
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
const session = [...page.session] const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, item.part])) const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: Part[] }[] = [] const confirmed: string[] = []
for (const item of items) { for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id) const result = Binary.search(session, item.message.id, (message) => message.id)
if (!result.found) session.splice(result.index, 0, item.message) if (!result.found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id) const current = part.get(item.message.id)
const confirmed = result.found if (result.found && hasParts(current, item.parts)) {
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found) confirmed.push(item.message.id)
: [] continue
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed }) }
part.set( part.set(item.message.id, merge(current ?? [], item.parts))
item.message.id,
merge(
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)),
),
)
} }
return { return {
...page, ...page,
session, session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })), part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
observed, confirmed,
} }
} }
@@ -98,38 +75,7 @@ function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
return [...items.values()].sort((x, y) => cmp(x.id, y.id)) return [...items.values()].sort((x, y) => cmp(x.id, y.id))
} }
function reconcileFetched<T extends { id: string }>( export function createServerSession(client: OpencodeClient) {
fetched: T[],
current: readonly T[],
options: {
touched?: ReadonlySet<string>
retained?: ReadonlySet<string>
preserveUnfetched?: boolean | ((item: T) => boolean)
} = {},
) {
const result = new Map(fetched.map((item) => [item.id, item]))
const live = new Map(current.map((item) => [item.id, item]))
if (options.preserveUnfetched) {
for (const item of current) {
if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item)))
result.set(item.id, item)
}
}
for (const id of options.retained ?? emptyIDs) {
if (result.has(id)) continue
const item = live.get(id)
if (item) result.set(id, item)
}
// Events observed while the request is pending are the freshest client state for those identities.
for (const id of options.touched ?? emptyIDs) {
const item = live.get(id)
if (item) result.set(id, item)
if (!item) result.delete(id)
}
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
}
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
const [data, setData] = createStore({ const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>, info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>, session_status: {} as Record<string, SessionStatus>,
@@ -149,32 +95,10 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const inflightDiff = new Map<string, Promise<void>>() const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>() const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>() const optimistic = new Map<string, Map<string, OptimisticItem>>()
const messageLoads = new Map<string, MessageLoadState>()
const pendingParts = new Map<string, Map<string, Set<string>>>()
const orphanParts = new Map<string, Set<string>>()
const removedMessages = new Map<string, Set<string>>()
const deltaBases = new Map<string, { base: string; sessionID: string }>()
const deleteMessageParts = (
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
messageID: string,
) => {
for (const part of cache.part[messageID] ?? []) {
delete cache.part_text_accum_delta[part.id]
deltaBases.delete(part.id)
}
delete cache.part[messageID]
}
const seen = new Set<string>() const seen = new Set<string>()
const infoSeen = new Set<string>() const infoSeen = new Set<string>()
const pinned = new Map<string, number>() const pinned = new Map<string, number>()
const generations = new Map<string, object>() const generations = new Map<string, number>()
const generation = (sessionID: string) => {
const current = generations.get(sessionID)
if (current) return current
const created = {}
generations.set(sessionID, created)
return created
}
const [meta, setMeta] = createStore({ const [meta, setMeta] = createStore({
limit: {} as Record<string, number | undefined>, limit: {} as Record<string, number | undefined>,
cursor: {} as Record<string, string | undefined>, cursor: {} as Record<string, string | undefined>,
@@ -191,11 +115,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const preserve = new Set([ const preserve = new Set([
...pinned.keys(), ...pinned.keys(),
...requests.keys(), ...requests.keys(),
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...Object.entries(data.permission) ...Object.entries(data.permission)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
@@ -219,7 +138,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (!preserve.has(sessionID)) stale.push(sessionID) if (!preserve.has(sessionID)) stale.push(sessionID)
} }
stale.forEach((sessionID) => infoSeen.delete(sessionID)) stale.forEach((sessionID) => infoSeen.delete(sessionID))
stale.forEach((sessionID) => generations.delete(sessionID))
setData( setData(
"info", "info",
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])), produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
@@ -233,27 +151,21 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (cached && !options?.force) return Promise.resolve(cached) if (cached && !options?.force) return Promise.resolve(cached)
const pending = requests.get(sessionID) const pending = requests.get(sessionID)
if (pending) return pending if (pending) return pending
const active = generation(sessionID) const generation = generations.get(sessionID) ?? 0
const request = client.session.get({ sessionID }).then((result) => { const request = client.session.get({ sessionID }).then((result) => {
if (!result.data) throw new Error(`Session not found: ${sessionID}`) if (!result.data) throw new Error(`Session not found: ${sessionID}`)
if (generations.get(sessionID) !== active) return result.data if ((generations.get(sessionID) ?? 0) !== generation) return result.data
return remember(result.data) return remember(result.data)
}) })
requests.set(sessionID, request) requests.set(sessionID, request)
const cleanup = () => { void request.then(
if (requests.get(sessionID) === request) requests.delete(sessionID) () => {
if ( if (requests.get(sessionID) === request) requests.delete(sessionID)
generations.get(sessionID) === active && },
!data.info[sessionID] && () => {
!requests.has(sessionID) && if (requests.get(sessionID) === request) requests.delete(sessionID)
!messageLoads.has(sessionID) && },
!inflight.has(sessionID) && )
!inflightDiff.has(sessionID) &&
!inflightTodo.has(sessionID)
)
generations.delete(sessionID)
}
void request.then(cleanup, cleanup)
return request return request
} }
@@ -283,121 +195,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (items.size === 0) optimistic.delete(sessionID) if (items.size === 0) optimistic.delete(sessionID)
} }
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((part) => part.id !== partID)
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
}
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((value) => value.id !== part.id)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], [part]),
confirmedMessage: true,
})
}
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const confirmed = new Set(confirmedParts.map((part) => part.id))
const parts = item.parts.filter((part) => !confirmed.has(part.id))
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
confirmedMessage: true,
})
}
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
const load = messageLoads.get(sessionID)
if (!load) return
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
const parts = load.touchedParts.get(messageID)
if (parts) {
parts.add(partID)
return
}
load.touchedParts.set(messageID, new Set([partID]))
}
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
load.touchedMessages.clear()
load.retainedMessages.clear()
load.touchedParts.clear()
load.carriedDeltaParts.clear()
load.clearedMessageParts.clear()
for (const messageID of load.removedMessages) {
load.touchedMessages.add(messageID)
load.clearedMessageParts.add(messageID)
}
for (const [messageID, parts] of load.deltaParts) {
load.touchedParts.set(messageID, new Set(parts))
load.carriedDeltaParts.set(messageID, new Set(parts))
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.removedParts) {
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.optimisticParts) {
load.removedMessages.delete(messageID)
load.clearedMessageParts.add(messageID)
load.touchedMessages.add(messageID)
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
}
}
const evict = (sessionIDs: string[]) => { const evict = (sessionIDs: string[]) => {
if (sessionIDs.length === 0) return if (sessionIDs.length === 0) return
const evicted = new Set(sessionIDs)
for (const [partID, item] of deltaBases) {
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
}
sessionIDs.forEach((sessionID) => { sessionIDs.forEach((sessionID) => {
generations.delete(sessionID) generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1)
clearOptimistic(sessionID) clearOptimistic(sessionID)
requests.delete(sessionID) requests.delete(sessionID)
inflight.delete(sessionID) inflight.delete(sessionID)
inflightDiff.delete(sessionID) inflightDiff.delete(sessionID)
inflightTodo.delete(sessionID) inflightTodo.delete(sessionID)
messageLoads.delete(sessionID)
pendingParts.delete(sessionID)
orphanParts.delete(sessionID)
removedMessages.delete(sessionID)
}) })
setData( setData(
produce((draft) => { produce((draft) => {
@@ -424,7 +230,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
...inflight.keys(), ...inflight.keys(),
...inflightDiff.keys(), ...inflightDiff.keys(),
...inflightTodo.keys(), ...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(), ...optimistic.keys(),
...Object.entries(data.permission) ...Object.entries(data.permission)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
@@ -442,11 +247,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }), pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
) )
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { const fetchMessages = async (sessionID: string, limit: number, before?: string) => {
const response = await (options?.retry ?? retry)(() => { const response = await retry(() => client.session.messages({ sessionID, limit, before }))
onAttempt?.()
return client.session.messages({ sessionID, limit, before })
})
const items = (response.data ?? []).filter((item) => !!item?.info?.id) const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return { return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)), session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
@@ -459,153 +261,30 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
} }
} }
const replaceMessages = (sessionID: string, messages: Message[]) => {
const messageIDs = new Set(messages.map((message) => message.id))
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
setData("message", sessionID, reconcile(messages, { key: "id" }))
setData(
produce((draft) => {
for (const message of dropped) deleteMessageParts(draft, message.id)
}),
)
return messageIDs
}
const replaceParts = (
sessionID: string,
items: MessagePage["part"],
messageIDs: Set<string>,
load?: MessageLoadState,
) => {
for (const item of items) {
if (!messageIDs.has(item.id)) continue
const fetched = load?.clearedMessageParts.has(item.id)
? []
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
const fetchedIDs = new Set(fetched.map((part) => part.id))
const pending = pendingParts.get(sessionID)?.get(item.id)
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
for (const part of fetched) {
const accumulated = data.part_text_accum_delta[part.id]
const base = deltaBases.get(part.id)?.base
const preserveDelta =
base !== undefined &&
accumulated !== undefined &&
"text" in part &&
typeof part.text === "string" &&
part.text.startsWith(base) &&
accumulated.startsWith(part.text) &&
accumulated !== part.text
if (preserveDelta) touched.add(part.id)
if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id)
}
for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) {
if (!fetchedIDs.has(partID)) touched.delete(partID)
}
const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched })
if (!parts.length) {
orphanParts.get(sessionID)?.delete(item.id)
setData(produce((draft) => deleteMessageParts(draft, item.id)))
continue
}
const partIDs = new Set(parts.map((part) => part.id))
setData(
"part_text_accum_delta",
produce((draft) => {
for (const part of data.part[item.id] ?? []) {
if (!partIDs.has(part.id) || !touched.has(part.id)) {
delete draft[part.id]
deltaBases.delete(part.id)
}
}
}),
)
setData("part", item.id, reconcile(parts, { key: "id" }))
orphanParts.get(sessionID)?.delete(item.id)
}
}
const applyMessagePage = (
sessionID: string,
page: MessagePage,
load: MessageLoadState | undefined,
preserveUnfetched: boolean | ((message: Message) => boolean),
cleanupOrphans: boolean,
) => {
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
merged.observed.forEach((item) => {
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
})
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
touched: touchedMessages,
retained: load?.retainedMessages,
preserveUnfetched,
})
batch(() => {
const messageIDs = replaceMessages(sessionID, messages)
replaceParts(sessionID, merged.part, messageIDs, load)
const orphans = orphanParts.get(sessionID)
if (cleanupOrphans && page.complete && orphans) {
for (const messageID of orphans) {
if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID)))
}
orphanParts.delete(sessionID)
}
setMeta("limit", sessionID, messages.length)
setMeta("cursor", sessionID, merged.cursor)
setMeta("complete", sessionID, merged.complete)
setMeta("at", sessionID, Date.now())
})
}
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => { const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
if (meta.loading[sessionID]) return if (meta.loading[sessionID]) return
const active = generation(sessionID) const generation = generations.get(sessionID) ?? 0
const load: MessageLoadState = {
touchedMessages: new Set(),
removedMessages: new Set(),
retainedMessages: new Set(),
touchedParts: new Map(),
deltaParts: new Map(),
carriedDeltaParts: new Map(),
removedParts: new Map(),
optimisticParts: new Map(),
orphanParents: new Set(),
clearedMessageParts: new Set(),
}
messageLoads.set(sessionID, load)
setMeta("loading", sessionID, true) setMeta("loading", sessionID, true)
let applied = false await fetchMessages(sessionID, limit, before)
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
.then((page) => { .then((page) => {
if (generations.get(sessionID) !== active) return if ((generations.get(sessionID) ?? 0) !== generation) return
const first = page.session.reduce<Message | undefined>( const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest), next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID))
undefined, const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session
) batch(() => {
const preserveUnfetched = setData("message", sessionID, reconcile(messages, { key: "id" }))
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0))) for (const item of next.part) {
applyMessagePage( const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type))
sessionID, if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" }))
page, }
messageLoads.get(sessionID) === load ? load : undefined, setMeta("limit", sessionID, messages.length)
preserveUnfetched, setMeta("cursor", sessionID, next.cursor)
mode !== "prepend", setMeta("complete", sessionID, next.complete)
) setMeta("at", sessionID, Date.now())
applied = true })
}) })
.finally(() => { .finally(() => {
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) { if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false)
for (const messageID of load.orphanParents) {
if (!orphanParts.get(sessionID)?.has(messageID)) continue
setData(produce((draft) => deleteMessageParts(draft, messageID)))
orphanParts.get(sessionID)?.delete(messageID)
}
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
}
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
}) })
} }
@@ -660,13 +339,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const eventID = eventSessionID(event) const eventID = eventSessionID(event)
if (eventID) { if (eventID) {
touch(eventID) touch(eventID)
if ( if (!data.info[eventID]) void resolve(eventID).catch(() => {})
!data.info[eventID] &&
event.type !== "session.created" &&
event.type !== "session.updated" &&
event.type !== "session.deleted"
)
void resolve(eventID).catch(() => {})
} }
switch (event.type) { switch (event.type) {
case "session.created": case "session.created":
@@ -705,21 +378,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
} }
case "message.updated": { case "message.updated": {
const info = cleanMessage((event.properties as { info: Message }).info) const info = cleanMessage((event.properties as { info: Message }).info)
const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id)
const items = optimistic.get(info.sessionID)
const item = items?.get(info.id)
if (items && item) {
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
}
const orphans = orphanParts.get(info.sessionID)
orphans?.delete(info.id)
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
const removedMessagesForSession = removedMessages.get(info.sessionID)
removedMessagesForSession?.delete(info.id)
if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID)
const messages = data.message[info.sessionID] const messages = data.message[info.sessionID]
if (!messages) { if (!messages) {
setData("message", info.sessionID, [info]) setData("message", info.sessionID, [info])
@@ -737,20 +395,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
} }
case "message.removed": { case "message.removed": {
const props = event.properties as { sessionID: string; messageID: string } const props = event.properties as { sessionID: string; messageID: string }
const load = messageLoads.get(props.sessionID)
load?.touchedMessages.add(props.messageID)
load?.removedMessages.add(props.messageID)
load?.clearedMessageParts.add(props.messageID)
load?.deltaParts.delete(props.messageID)
load?.carriedDeltaParts.delete(props.messageID)
load?.removedParts.delete(props.messageID)
load?.optimisticParts.delete(props.messageID)
pendingParts.get(props.sessionID)?.delete(props.messageID)
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
removedMessagesForSession.add(props.messageID)
removedMessages.set(props.sessionID, removedMessagesForSession)
clearOptimistic(props.sessionID, props.messageID)
setData( setData(
produce((draft) => { produce((draft) => {
const messages = draft.message[props.sessionID] const messages = draft.message[props.sessionID]
@@ -758,7 +402,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const result = Binary.search(messages, props.messageID, (message) => message.id) const result = Binary.search(messages, props.messageID, (message) => message.id)
if (result.found) messages.splice(result.index, 1) if (result.found) messages.splice(result.index, 1)
} }
deleteMessageParts(draft, props.messageID) for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id]
delete draft.part[props.messageID]
}), }),
) )
return return
@@ -766,42 +411,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
case "message.part.updated": { case "message.part.updated": {
const part = (event.properties as { part: Part }).part const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) return if (SKIP_PARTS.has(part.type)) return
const messages = data.message[part.sessionID]
const load = messageLoads.get(part.sessionID)
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
if (
missing &&
(!load ||
load.clearedMessageParts.has(part.messageID) ||
removedMessages.get(part.sessionID)?.has(part.messageID))
)
return
if (missing) {
const orphans = orphanParts.get(part.sessionID) ?? new Set<string>()
orphans.add(part.messageID)
orphanParts.set(part.sessionID, orphans)
load?.orphanParents.add(part.messageID)
}
const deltas = load?.deltaParts.get(part.messageID)
deltas?.delete(part.id)
if (deltas?.size === 0) load?.deltaParts.delete(part.messageID)
const carried = load?.carriedDeltaParts.get(part.messageID)
carried?.delete(part.id)
if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID)
const removed = load?.removedParts.get(part.messageID)
removed?.delete(part.id)
if (removed?.size === 0) load?.removedParts.delete(part.messageID)
const pending = pendingParts.get(part.sessionID)?.get(part.messageID)
pending?.delete(part.id)
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
const optimistic = load?.optimisticParts.get(part.messageID)
optimistic?.delete(part.id)
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
deltaBases.delete(part.id)
trackPartChange(part.sessionID, part.messageID, part.id)
confirmOptimisticPart(part.sessionID, part.messageID, part)
setData( setData(
"part_text_accum_delta", "part_text_accum_delta",
produce((draft) => void delete draft[part.id]), produce((draft) => void delete draft[part.id]),
@@ -822,34 +431,10 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return return
} }
case "message.part.removed": { case "message.part.removed": {
const props = event.properties as { sessionID: string; messageID: string; partID: string } const props = event.properties as { messageID: string; partID: string }
// Part removal is event-only on the server, so its tombstone lasts until a later update or eviction.
const pending = pendingParts.get(props.sessionID) ?? new Map<string, Set<string>>()
const parts = pending.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
pending.set(props.messageID, parts)
pendingParts.set(props.sessionID, pending)
const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID)
deltas?.delete(props.partID)
if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID)
const load = messageLoads.get(props.sessionID)
const carried = load?.carriedDeltaParts.get(props.messageID)
carried?.delete(props.partID)
if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID)
if (load) {
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
load.removedParts.set(props.messageID, parts)
const optimistic = load.optimisticParts.get(props.messageID)
optimistic?.delete(props.partID)
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
}
trackPartChange(props.sessionID, props.messageID, props.partID)
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
setData( setData(
produce((draft) => { produce((draft) => {
delete draft.part_text_accum_delta[props.partID] delete draft.part_text_accum_delta[props.partID]
deltaBases.delete(props.partID)
const parts = draft.part[props.messageID] const parts = draft.part[props.messageID]
if (!parts) return if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id) const result = Binary.search(parts, props.partID, (part) => part.id)
@@ -860,31 +445,13 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return return
} }
case "message.part.delta": { case "message.part.delta": {
const props = event.properties as { const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
sessionID: string
messageID: string
partID: string
field: string
delta: string
}
const parts = data.part[props.messageID] const parts = data.part[props.messageID]
if (!parts) return if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id) const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) return if (!result.found) return
trackPartChange(props.sessionID, props.messageID, props.partID)
const load = messageLoads.get(props.sessionID)
if (load) {
const parts = load.deltaParts.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
load.deltaParts.set(props.messageID, parts)
const carried = load.carriedDeltaParts.get(props.messageID)
carried?.delete(props.partID)
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
}
const field = props.field as keyof (typeof parts)[number] const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field] const current = parts[result.index]?.[field]
if (!deltaBases.has(props.partID) && typeof current === "string")
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
setData( setData(
"part_text_accum_delta", "part_text_accum_delta",
props.partID, props.partID,
@@ -992,70 +559,32 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
}, },
optimistic: { optimistic: {
add(input: { sessionID: string; message: Message; parts: Part[] }) { add(input: { sessionID: string; message: Message; parts: Part[] }) {
const parts = input.parts
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
.sort((a, b) => cmp(a.id, b.id))
const load = messageLoads.get(input.sessionID)
if (load?.clearedMessageParts.has(input.message.id)) {
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
parts.forEach((part) => touched.add(part.id))
load.touchedParts.set(input.message.id, touched)
}
if (load) {
load.removedMessages.delete(input.message.id)
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
}
const items = optimistic.get(input.sessionID) const items = optimistic.get(input.sessionID)
const removedMessagesForSession = removedMessages.get(input.sessionID) if (items) items.set(input.message.id, input)
removedMessagesForSession?.delete(input.message.id) if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]]))
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message])) setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
setData( setData(
"part_text_accum_delta", "part",
produce((draft) => { input.message.id,
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) { input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
delete draft[part.id]
deltaBases.delete(part.id)
}
}),
) )
setData("part", input.message.id, parts)
}, },
remove(input: { sessionID: string; messageID: string }) { remove(input: { sessionID: string; messageID: string }) {
const item = optimistic.get(input.sessionID)?.get(input.messageID)
if (!item) return
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
clearOptimistic(input.sessionID, input.messageID) clearOptimistic(input.sessionID, input.messageID)
if (item.confirmedMessage) {
const partIDs = new Set(item.parts.map((part) => part.id))
setData(
produce((draft) => {
for (const part of item.parts) {
delete draft.part_text_accum_delta[part.id]
deltaBases.delete(part.id)
}
const parts = draft.part[input.messageID]
if (!parts) return
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
}),
)
return
}
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID)) setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData(produce((draft) => deleteMessageParts(draft, input.messageID))) setData(
"part",
produce((draft) => void delete draft[input.messageID]),
)
}, },
}, },
diff(sessionID: string, options?: { force?: boolean }) { diff(sessionID: string, options?: { force?: boolean }) {
touch(sessionID) touch(sessionID)
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve() if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightDiff, sessionID, () => { return runInflight(inflightDiff, sessionID, () => {
const active = generation(sessionID) const generation = generations.get(sessionID) ?? 0
return retry(() => client.session.diff({ sessionID })).then((result) => { return retry(() => client.session.diff({ sessionID })).then((result) => {
if (generations.get(sessionID) !== active) return if ((generations.get(sessionID) ?? 0) !== generation) return
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" })) setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
}) })
}) })
@@ -1064,9 +593,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
touch(sessionID) touch(sessionID)
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve() if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightTodo, sessionID, () => { return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID) const generation = generations.get(sessionID) ?? 0
return retry(() => client.session.todo({ sessionID })).then((result) => { return retry(() => client.session.todo({ sessionID })).then((result) => {
if (generations.get(sessionID) !== active) return if ((generations.get(sessionID) ?? 0) !== generation) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" })) setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
}) })
}) })
+1 -22
View File
@@ -1,11 +1,4 @@
import type { import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client"
Config,
McpResource,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -22,7 +15,6 @@ import {
loadPathQuery, loadPathQuery,
loadProjectsQuery, loadProjectsQuery,
loadProvidersQuery, loadProvidersQuery,
loadReferencesQuery,
} from "./global-sync/bootstrap" } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store" import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
@@ -64,13 +56,6 @@ export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: Opencod
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
}) })
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions<Record<string, McpResource>>({
queryKey: [scope, directory, "mcpResources"] as const,
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
placeholderData: {},
})
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "lsp"] as const, queryKey: [scope, directory, "lsp"] as const,
@@ -90,9 +75,7 @@ function makeQueryOptionsApi(
path: (directory: PathKey | null) => path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
} }
@@ -413,9 +396,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
loadLsp: () => { loadLsp: () => {
void queryClient.fetchQuery(queryOptionsApi.lsp(key)) void queryClient.fetchQuery(queryOptionsApi.lsp(key))
}, },
loadReferences: () => {
void queryClient.fetchQuery(queryOptionsApi.references(key))
},
}) })
}) })
@@ -504,7 +484,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}, },
refresh: async () => { refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key)) await queryClient.refetchQueries(queryOptionsApi.mcp(key))
await queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
}, },
}) })
}, },
+21 -25
View File
@@ -138,14 +138,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const next = { type: "session" as const, ...tab } const next = { type: "session" as const, ...tab }
const existing = store.find((item) => tabKey(item) === tabKey(next)) const existing = store.find((item) => tabKey(item) === tabKey(next))
if (existing) return existing if (existing) return existing
void startTransition(() => { setStore(
setStore( produce((tabs) => {
produce((tabs) => { if (tabs.some((item) => tabKey(item) === tabKey(next))) return
if (tabs.some((item) => tabKey(item) === tabKey(next))) return tabs.push(next)
tabs.push(next) }),
}), )
)
})
return next return next
}, },
reorder(keys: string[]) { reorder(keys: string[]) {
@@ -165,29 +163,27 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}, },
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) { newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
const draftID = uuid() const draftID = uuid()
void startTransition(() => { setStore(
setStore( produce((tabs) => {
produce((tabs) => { tabs.push({ type: "draft", draftID, ...draft })
tabs.push({ type: "draft", draftID, ...draft }) }),
}), )
) navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
})
}, },
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) { updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
void startTransition(() => { setStore(
setStore( (tab) => tab.type === "draft" && tab.draftID === draftID,
(tab) => tab.type === "draft" && tab.draftID === draftID, produce((tab) => Object.assign(tab, draft)),
produce((tab) => Object.assign(tab, draft)), )
)
})
}, },
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) { promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
// Keep the replacement and navigation atomic so /new-session never renders // We're viewing this draft when /new-session?draftId=… points at it. Promoting
// after its backing draft tab has been removed from the store. // replaces the draft tab with a session tab, so the draft route would stop resolving
// and fall back home. Navigate to the new session first so we leave /new-session
// before the draft is removed from the store.
const active = location.pathname === "/new-session" && location.query.draftId === draftID const active = location.pathname === "/new-session" && location.query.draftId === draftID
const next = { type: "session" as const, ...session } const next = { type: "session" as const, ...session }
void startTransition(() => { startTransition(() => {
setStore( setStore(
produce((tabs) => { produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
-13
View File
@@ -1,13 +0,0 @@
import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => {
test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.label === "Export Logs...",
)
expect(items).toHaveLength(2)
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
})
})
-4
View File
@@ -656,10 +656,6 @@ export const dict = {
"session.new.worktree.main": "Main branch", "session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})", "session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
"session.new.worktree.create": "Create new worktree", "session.new.worktree.create": "Create new worktree",
"session.new.workspace.runIn": "Run session in",
"session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Local repository",
"session.new.workspace.existing": "Workspace…",
"session.new.lastModified": "Last modified", "session.new.lastModified": "Last modified",
"session.header.search.placeholder": "Search {{project}}", "session.header.search.placeholder": "Search {{project}}",
+3 -96
View File
@@ -1,7 +1,6 @@
@import "@opencode-ai/ui/styles/tailwind"; @import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/session-ui/styles"; @import "@opencode-ai/session-ui/styles";
@import "@opencode-ai/ui/v2/styles/tailwind.css"; @import "@opencode-ai/ui/v2/styles/tailwind.css";
@import "tw-animate-css";
@font-face { @font-face {
font-family: "JetBrainsMono Nerd Font Mono"; font-family: "JetBrainsMono Nerd Font Mono";
@@ -108,45 +107,6 @@
} }
} }
.home-session-group-header::before {
content: "";
position: absolute;
top: -12px;
left: 0;
width: 100%;
height: 12px;
background: var(--v2-background-bg-base);
}
.home-session-group-header::after {
content: "";
position: absolute;
top: 100%;
left: 0;
width: 100%;
height: 16px;
pointer-events: none;
background: linear-gradient(
180deg,
var(--v2-background-bg-base) 0%,
color-mix(in srgb, var(--v2-background-bg-base) 92.0456%, transparent) 7.93%,
color-mix(in srgb, var(--v2-background-bg-base) 84.9947%, transparent) 14.14%,
color-mix(in srgb, var(--v2-background-bg-base) 78.6813%, transparent) 19%,
color-mix(in srgb, var(--v2-background-bg-base) 72.9394%, transparent) 22.85%,
color-mix(in srgb, var(--v2-background-bg-base) 67.6028%, transparent) 26.05%,
color-mix(in srgb, var(--v2-background-bg-base) 62.5055%, transparent) 28.95%,
color-mix(in srgb, var(--v2-background-bg-base) 57.4815%, transparent) 31.91%,
color-mix(in srgb, var(--v2-background-bg-base) 52.3647%, transparent) 35.27%,
color-mix(in srgb, var(--v2-background-bg-base) 46.989%, transparent) 39.4%,
color-mix(in srgb, var(--v2-background-bg-base) 41.1884%, transparent) 44.65%,
color-mix(in srgb, var(--v2-background-bg-base) 34.7969%, transparent) 51.36%,
color-mix(in srgb, var(--v2-background-bg-base) 27.6484%, transparent) 59.9%,
color-mix(in srgb, var(--v2-background-bg-base) 19.5767%, transparent) 70.62%,
color-mix(in srgb, var(--v2-background-bg-base) 10.416%, transparent) 83.87%,
transparent 100%
);
}
[data-slot="titlebar-update-loader"] { [data-slot="titlebar-update-loader"] {
display: block; display: block;
flex-shrink: 0; flex-shrink: 0;
@@ -172,65 +132,12 @@
} }
} }
@keyframes home-projects-fade-top { @keyframes fade-in {
from { from {
visibility: hidden; opacity: 0;
} }
to { to {
visibility: visible; opacity: 1;
}
}
@keyframes home-projects-fade-bottom {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-slot="home-projects-scroll"] {
timeline-scope: --home-projects-scroll;
}
[data-slot="home-projects-scroll"]::before,
[data-slot="home-projects-scroll"]::after {
content: "";
position: absolute;
left: 0;
right: 0;
z-index: 10;
height: 16px;
pointer-events: none;
visibility: hidden;
}
[data-slot="home-projects-scroll"]::before {
top: 0;
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
}
[data-slot="home-projects-scroll"]::after {
bottom: 0;
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
}
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
[data-slot="home-projects-scroll"] .scroll-view__viewport {
scroll-timeline: --home-projects-scroll y;
}
[data-slot="home-projects-scroll"]::before {
animation: home-projects-fade-top linear both;
animation-timeline: --home-projects-scroll;
animation-range: 0 0.1px;
}
[data-slot="home-projects-scroll"]::after {
animation: home-projects-fade-bottom linear both;
animation-timeline: --home-projects-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
} }
} }
} }
+140 -291
View File
@@ -1,9 +1,8 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2/client"
import { import {
type ComponentProps, batch,
createEffect, createEffect,
createMemo, createMemo,
createResource,
createRoot, createRoot,
For, For,
Match, Match,
@@ -27,7 +26,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getProjectAvatarVariant, useLayout, type HomeProjectSelection, type LocalProject } from "@/context/layout" import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -35,7 +34,6 @@ import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server" import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, serverName, useServer } from "@/context/server" import { ServerConnection, serverName, useServer } from "@/context/server"
@@ -49,6 +47,7 @@ import {
errorMessage, errorMessage,
getProjectAvatarSource, getProjectAvatarSource,
homeProjectDirectories, homeProjectDirectories,
type HomeProjectSelection,
projectForSession, projectForSession,
sortedRootSessions, sortedRootSessions,
toggleHomeProjectSelection, toggleHomeProjectSelection,
@@ -69,10 +68,6 @@ import { archiveHomeSession } from "./home-session-archive"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
const HOME_SESSION_LIMIT = 64 const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT = const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0` const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
@@ -137,107 +132,6 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}` return `${pathKey(record.session.directory)}:${record.session.id}`
} }
function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let viewport: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
})
createEffect(() => {
const items = groups()
const ids = new Set(items.map((group) => group.id))
headerRefs.forEach((_, id) => {
if (!ids.has(id)) headerRefs.delete(id)
})
headerOffsets.forEach((_, id) => {
if (!ids.has(id)) headerOffsets.delete(id)
})
if (items.length === 0) {
content = undefined
bindResizeObserver()
}
queuePositionUpdate()
})
onCleanup(() => {
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
resizeObserver?.disconnect()
})
function setViewport(el: HTMLDivElement) {
viewport = el
bindResizeObserver()
queuePositionUpdate()
}
function setContentRef(el: HTMLDivElement) {
content = el
bindResizeObserver()
queuePositionUpdate()
}
function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
headerRefs.set(id, el)
queuePositionUpdate()
}
function queuePositionUpdate() {
if (typeof requestAnimationFrame === "undefined") {
updatePositionCache()
return
}
if (positionFrame !== undefined) return
positionFrame = requestAnimationFrame(() => {
positionFrame = undefined
updatePositionCache()
})
}
function updatePositionCache() {
if (!viewport) return
groups().forEach((group) => {
const el = headerRefs.get(group.id)
if (!el) return
headerOffsets.set(group.id, el.offsetTop)
})
update(viewport.scrollTop)
}
function update(scrollTop: number) {
const items = groups()
items.forEach((group, index) => {
const nextOffset = items
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined)
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
})
}
function titleOpacity(id: HomeSessionGroup["id"]) {
return state.titleOpacity[id] ?? 1
}
function bindResizeObserver() {
resizeObserver?.disconnect()
if (typeof ResizeObserver === "undefined") return
resizeObserver = new ResizeObserver(() => queuePositionUpdate())
if (viewport) resizeObserver.observe(viewport)
if (content) resizeObserver.observe(content)
}
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
}
export function NewHome() { export function NewHome() {
const sync = useServerSync() const sync = useServerSync()
const layout = useLayout() const layout = useLayout()
@@ -252,16 +146,15 @@ export function NewHome() {
const command = useCommand() const command = useCommand()
const notification = useNotification() const notification = useNotification()
const marked = useMarked() const marked = useMarked()
const openSettings = useSettingsCommand()
let focusSessionSearch: (() => void) | undefined let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({ const [state, setState] = createStore({
search: "", search: "",
selection: { server: server.key } as HomeProjectSelection,
searchFocused: false, searchFocused: false,
}) })
const selection = layout.home.selection
const focusedServer = createMemo( const focusedServer = createMemo(
() => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current, () => global.servers.list().find((conn) => ServerConnection.key(conn) === state.selection.server) ?? server.current,
) )
const focusedServerCtx = createMemo(() => { const focusedServerCtx = createMemo(() => {
const conn = focusedServer() const conn = focusedServer()
@@ -270,7 +163,7 @@ export function NewHome() {
}) })
const focusedSync = () => focusedServerCtx()?.sync ?? sync() const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list()) const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory)) const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory))
const newSessionProject = createMemo( const newSessionProject = createMemo(
() => () =>
selectedProject() ?? selectedProject() ??
@@ -298,7 +191,7 @@ export function NewHome() {
return language.t("home.sessions.search.placeholder") return language.t("home.sessions.search.placeholder")
}) })
const sessionLoad = useQuery(() => ({ const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", selection().server, ...projectDirectories()] as const, queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
queryFn: async () => { queryFn: async () => {
await Promise.all( await Promise.all(
projectDirectories().map((directory) => projectDirectories().map((directory) =>
@@ -328,7 +221,6 @@ export function NewHome() {
}) })
const searchOpen = createMemo(() => state.searchFocused && search().length > 0) const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const groups = createMemo(() => groupSessions(records(), language)) const groups = createMemo(() => groupSessions(records(), language))
const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
const prefetched = new Set<string>() const prefetched = new Set<string>()
createEffect(() => { createEffect(() => {
@@ -365,7 +257,10 @@ export function NewHome() {
}) })
function setSelection(next: HomeProjectSelection) { function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next) batch(() => {
if (state.selection.server !== next.server) setState("selection", "server", next.server)
if (state.selection.directory !== next.directory) setState("selection", "directory", next.directory)
})
} }
function closeSearch() { function closeSearch() {
@@ -390,7 +285,7 @@ export function NewHome() {
createEffect(() => { createEffect(() => {
const list = global.servers.list() const list = global.servers.list()
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return if (list.some((conn) => ServerConnection.key(conn) === state.selection.server)) return
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) }) if (conn) setSelection({ server: ServerConnection.key(conn) })
}) })
@@ -408,7 +303,6 @@ export function NewHome() {
function selectProject(conn: ServerConnection.Any, directory: string) { function selectProject(conn: ServerConnection.Any, directory: string) {
const key = ServerConnection.key(conn) const key = ServerConnection.key(conn)
if (global.servers.health[key]?.healthy === false) return
if ( if (
!global !global
.ensureServerCtx(conn) .ensureServerCtx(conn)
@@ -416,7 +310,7 @@ export function NewHome() {
.some((project) => project.worktree === directory) .some((project) => project.worktree === directory)
) )
return return
setSelection(toggleHomeProjectSelection(selection(), key, directory)) setSelection(toggleHomeProjectSelection(state.selection, key, directory))
} }
function addProjects(conn: ServerConnection.Any, directories: string[]) { function addProjects(conn: ServerConnection.Any, directories: string[]) {
@@ -449,15 +343,15 @@ export function NewHome() {
} }
function unseenCount(conn: ServerConnection.Any, project: LocalProject) { function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn)) if (ServerConnection.key(conn) !== server.key) return 0
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0) return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
} }
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) { function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn)) if (ServerConnection.key(conn) !== server.key) return
directories(project) directories(project)
.filter((directory) => state.project.unseenCount(directory) > 0) .filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => state.project.markViewed(directory)) .forEach((directory) => notification.project.markViewed(directory))
} }
function openSession(session: Session) { function openSession(session: Session) {
@@ -513,12 +407,18 @@ export function NewHome() {
}) })
} }
function openSettings() {
void import("@/components/settings-v2").then((x) => {
dialog.show(() => <x.DialogSettings />)
})
}
return ( 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="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 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6"> <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 <HomeProjectColumn
projects={projects()} projects={projects()}
selected={selection()} selected={state.selection}
focusServer={focusServer} focusServer={focusServer}
selectProject={selectProject} selectProject={selectProject}
openNewSession={openProjectNewSession} openNewSession={openProjectNewSession}
@@ -526,7 +426,7 @@ export function NewHome() {
editProject={editProject} editProject={editProject}
closeProject={(conn, directory) => { closeProject={(conn, directory) => {
const next = closeHomeProject( const next = closeHomeProject(
selection(), state.selection,
ServerConnection.key(conn), ServerConnection.key(conn),
global.ensureServerCtx(conn).projects, global.ensureServerCtx(conn).projects,
directory, directory,
@@ -541,7 +441,7 @@ export function NewHome() {
/> />
<section <section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative" class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12"
aria-label={language.t("sidebar.project.recentSessions")} aria-label={language.t("sidebar.project.recentSessions")}
> >
<HomeSessionSearch <HomeSessionSearch
@@ -551,8 +451,8 @@ export function NewHome() {
loading={sessionLoad.isLoading} loading={sessionLoad.isLoading}
results={searchResults()} results={searchResults()}
showProjectName={!selectedProject()} showProjectName={!selectedProject()}
server={selection().server} server={state.selection.server}
activeServer={selection().server === server.key} activeServer={state.selection.server === server.key}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })} noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => { bindFocus={(focus) => {
focusSessionSearch = focus focusSessionSearch = focus
@@ -562,25 +462,7 @@ export function NewHome() {
onClose={closeSearch} onClose={closeSearch}
onSelect={selectSearchSession} onSelect={selectSearchSession}
/> />
<ScrollView <ScrollView class="mt-3 min-h-0 flex-1">
class="mt-3 -mr-3 min-h-0 flex-1 relative"
viewportRef={sessionHeaderOpacity.setViewport}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
>
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
<Show <Show
when={!sessionLoad.isLoading} when={!sessionLoad.isLoading}
fallback={ fallback={
@@ -593,33 +475,29 @@ export function NewHome() {
when={groups().length > 0} when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />} fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
> >
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16"> <div class="pt-3 flex flex-col gap-6">
<For each={groups()}> <For each={groups()}>
{(group, index) => ( {(group, index) => (
<> <div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader <HomeSessionGroupHeader
title={group.title} title={group.title}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)} onNewSession={index() === 0 && newSessionProject() ? openNewSession : undefined}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/> />
<div <div class="flex min-w-0 flex-col gap-px">
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}> <For each={group.sessions}>
{(record) => ( {(record) => (
<HomeSessionRow <HomeSessionRow
record={record} record={record}
showProjectName={!selectedProject()} showProjectName={!selectedProject()}
server={selection().server} server={state.selection.server}
activeServer={selection().server === server.key} activeServer={state.selection.server === server.key}
openSession={openSession} openSession={openSession}
archiveSession={archiveSession} archiveSession={archiveSession}
/> />
)} )}
</For> </For>
</div> </div>
</> </div>
)} )}
</For> </For>
</div> </div>
@@ -656,22 +534,16 @@ function HomeProjectColumn(props: {
const global = useGlobal() const global = useGlobal()
const dialog = useDialog() const dialog = useDialog()
const controller = useServerManagementController({ navigateOnAdd: false }) const controller = useServerManagementController({ navigateOnAdd: false })
const [_state, setState, _, ready] = persisted( const [state, setState] = persisted(
Persist.global("home.servers", ["home.servers.v1"]), Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }), createStore({ collapsed: {} as Record<string, boolean> }),
) )
const [state] = createResource(
() => ready.promise ?? Promise.resolve(),
(p) => p.then(() => _state),
{ initialValue: _state },
)
return ( return (
<aside <aside
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]" class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
aria-label={props.language.t("home.projects")} aria-label={props.language.t("home.projects")}
> >
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5"> <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> <div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show when={global.servers.list().length === 1}> <Show when={global.servers.list().length === 1}>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}> <TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
@@ -688,51 +560,42 @@ function HomeProjectColumn(props: {
</TooltipV2> </TooltipV2>
</Show> </Show>
</div> </div>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink"> <Show
<Show when={global.servers.list().length > 1}
when={global.servers.list().length > 1} fallback={<HomeProjectList {...props} server={global.servers.list()[0]!} />}
fallback={ >
<div class="pr-3"> <For each={global.servers.list()}>
<HomeProjectList {...props} server={global.servers.list()[0]!} /> {(item) => {
</div> const key = ServerConnection.key(item)
} const healthy = () => !!global.servers.health[key]?.healthy
> const serverCtx = global.ensureServerCtx(item)
<div class="flex min-w-0 flex-col gap-1 pr-3"> const collapsed = () => !!state.collapsed[key]
<For each={global.servers.list()}> return (
{(item) => { <div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
const key = ServerConnection.key(item) <HomeServerRow
const healthy = () => !!global.servers.health[key]?.healthy server={item}
const serverCtx = global.ensureServerCtx(item) selected={props.selected.server === key && !props.selected.directory}
const projects = () => serverCtx.projects.list() healthy={healthy()}
const hasProjects = () => projects().length > 0 collapsed={collapsed()}
const collapsed = () => !!state().collapsed[key] health={global.servers.health[key]}
return ( controller={controller}
<div class="flex min-w-0 flex-col gap-1"> focusServer={props.focusServer}
<HomeServerRow chooseProject={props.chooseProject}
server={item} openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
selected={props.selected.server === key && !props.selected.directory} toggleCollapsed={() => setState("collapsed", key, !state.collapsed[key])}
collapsed={collapsed()} language={props.language}
health={global.servers.health[key]} />
controller={controller} <Show when={healthy() && !collapsed()}>
focusServer={props.focusServer} <div class="mx-3 h-px bg-v2-border-border-base" />
chooseProject={props.chooseProject} <HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)} </Show>
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])} </div>
language={props.language} )
/> }}
<Show when={healthy() && hasProjects() && !collapsed()}> </For>
<div class="mx-3 h-px bg-v2-border-border-base" /> </Show>
<HomeProjectList {...props} server={item} projects={projects()} />
</Show>
</div>
)
}}
</For>
</div>
</Show>
</ScrollView>
<HomeUtilityNav <HomeUtilityNav
class="mb-8 mt-4 hidden shrink-0 lg:flex" class="mt-4 hidden lg:flex"
openSettings={props.openSettings} openSettings={props.openSettings}
openHelp={props.openHelp} openHelp={props.openHelp}
language={props.language} language={props.language}
@@ -772,6 +635,7 @@ function HomeUtilityNav(props: {
function HomeServerRow(props: { function HomeServerRow(props: {
server: ServerConnection.Any server: ServerConnection.Any
selected: boolean selected: boolean
healthy: boolean
collapsed: boolean collapsed: boolean
health: ServerHealth | undefined health: ServerHealth | undefined
controller: ReturnType<typeof useServerManagementController> controller: ReturnType<typeof useServerManagementController>
@@ -781,46 +645,39 @@ function HomeServerRow(props: {
toggleCollapsed: () => void toggleCollapsed: () => void
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}) { }) {
const global = useGlobal()
const [state, setState] = createStore({ menuOpen: false }) const [state, setState] = createStore({ menuOpen: false })
const healthy = () => !!props.health?.healthy
const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
return ( return (
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]"> <div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<button <button
type="button" type="button"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`} class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected ? "" : undefined} data-selected={props.selected ? "" : undefined}
disabled={!healthy()} disabled={!props.healthy}
onClick={() => props.focusServer(props.server)} onClick={() => props.focusServer(props.server)}
> >
<span <Show when={props.healthy}>
data-action="home-server-collapse" <span
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted" data-action="home-server-collapse"
classList={{ class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(), aria-label={
"cursor-default opacity-40": !canToggle(), props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}} }
aria-label={ aria-expanded={!props.collapsed}
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse") onClick={(event) => {
} event.preventDefault()
aria-disabled={!canToggle()} event.stopPropagation()
aria-expanded={canToggle() ? !props.collapsed : undefined} props.toggleCollapsed()
onClick={(event) => { }}
event.preventDefault() onPointerDown={(event) => event.preventDefault()}
event.stopPropagation() >
if (!canToggle()) return <IconV2
props.toggleCollapsed() name="chevron-down"
}} size="small"
onPointerDown={(event) => event.preventDefault()} class="transition-transform duration-150 ease-in-out"
> style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
<IconV2 />
name="chevron-down" </span>
size="small" </Show>
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5"> <div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} /> <ServerHealthIndicator health={props.health} />
</div> </div>
@@ -911,18 +768,15 @@ function HomeProjectRow(props: {
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}) { }) {
const global = useGlobal()
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
const [state, setState] = createStore({ menuOpen: false }) const [state, setState] = createStore({ menuOpen: false })
return ( return (
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"> <div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
<button <button
type="button" type="button"
data-component="home-project-row" data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`} class={`${HOME_PROJECT_NAV_ROW} pr-16`}
data-selected={props.selected ? "" : undefined} data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined} aria-current={props.selected ? "page" : undefined}
disabled={serverUnreachable()}
onClick={() => props.selectProject(props.server, props.project.worktree)} onClick={() => props.selectProject(props.server, props.project.worktree)}
> >
<HomeProjectAvatar project={props.project} /> <HomeProjectAvatar project={props.project} />
@@ -997,7 +851,6 @@ function HomeSessionLeading(props: {
session: Session session: Session
server: ServerConnection.Key server: ServerConnection.Key
activeServer: boolean activeServer: boolean
revealProjectOnHover: boolean
}) { }) {
const tabs = useTabs() const tabs = useTabs()
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session)) const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
@@ -1015,7 +868,6 @@ function HomeSessionLeading(props: {
directory={props.session.directory} directory={props.session.directory}
sessionId={props.session.id} sessionId={props.session.id}
activeServer={props.activeServer} activeServer={props.activeServer}
revealProjectOnHover={props.revealProjectOnHover}
/> />
</div> </div>
) )
@@ -1105,8 +957,8 @@ function HomeSessionSearch(props: {
) )
return ( return (
<div class="w-full"> <div class="mr-2 w-[calc(100%_-_8px)]">
<div ref={root} data-component="home-session-search" class="relative z-30 w-full"> <div ref={root} data-component="home-session-search" class="relative z-10 w-full">
<Show when={props.open}> <Show when={props.open}>
<div <div
data-component="home-session-search-panel" data-component="home-session-search-panel"
@@ -1114,7 +966,7 @@ function HomeSessionSearch(props: {
style={{ style={{
top: "-6px", top: "-6px",
left: "-6px", left: "-6px",
width: "calc(100% + 12px)", width: "calc(100% + 14px)",
}} }}
> >
<div class="flex flex-col pt-9"> <div class="flex flex-col pt-9">
@@ -1255,7 +1107,6 @@ function HomeSessionSearchResultRow(props: {
classList={{ classList={{
[HOME_SEARCH_RESULT_ROW]: true, [HOME_SEARCH_RESULT_ROW]: true,
"bg-v2-overlay-simple-overlay-hover": props.selected, "bg-v2-overlay-simple-overlay-hover": props.selected,
group: !!showProjectName(),
}} }}
onMouseEnter={() => props.onHighlight()} onMouseEnter={() => props.onHighlight()}
onClick={() => props.onSelect(props.record.session)} onClick={() => props.onSelect(props.record.session)}
@@ -1265,7 +1116,6 @@ function HomeSessionSearchResultRow(props: {
session={props.record.session} session={props.record.session}
server={props.server} server={props.server}
activeServer={props.activeServer} activeServer={props.activeServer}
revealProjectOnHover={!!showProjectName()}
/> />
<div class="flex min-w-0 flex-1 items-center gap-1.5"> <div class="flex min-w-0 flex-1 items-center gap-1.5">
<span <span
@@ -1281,20 +1131,25 @@ function HomeSessionSearchResultRow(props: {
) )
} }
function HomeSessionGroupHeader(props: { function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
title: string const language = useLanguage()
titleOpacity: number
ref: ComponentProps<"div">["ref"]
elevated?: boolean
}) {
return ( return (
<div <div class="flex h-7 min-w-0 items-center justify-between pl-[18px] pr-2">
ref={props.ref} <div class={HOME_SECTION_LABEL}>{props.title}</div>
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`} <Show when={props.onNewSession}>
> {(onNewSession) => (
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}> <ButtonV2
{props.title} data-action="home-new-session"
</div> variant="ghost-muted"
size="normal"
icon="edit"
class="h-7 px-2 [font-weight:530]"
onClick={onNewSession()}
>
{language.t("command.session.new")}
</ButtonV2>
)}
</Show>
</div> </div>
) )
} }
@@ -1312,10 +1167,7 @@ function HomeSessionRow(props: {
const showProjectName = () => props.showProjectName && props.record.projectName const showProjectName = () => props.showProjectName && props.record.projectName
return ( return (
<div <div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]">
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
classList={{ group: !!showProjectName() }}
>
<button <button
type="button" type="button"
data-component="home-session-row" data-component="home-session-row"
@@ -1327,7 +1179,6 @@ function HomeSessionRow(props: {
session={props.record.session} session={props.record.session}
server={props.server} server={props.server}
activeServer={props.activeServer} activeServer={props.activeServer}
revealProjectOnHover={!!showProjectName()}
/> />
<span <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]"}`} 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]"}`}
@@ -1340,24 +1191,22 @@ function HomeSessionRow(props: {
</span> </span>
</Show> </Show>
</button> </button>
<Show when={SHOW_HOME_SESSION_ARCHIVE}> <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">
<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")}>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}> <IconButtonV2
<IconButtonV2 data-action="home-session-archive"
data-action="home-session-archive" variant="ghost-muted"
variant="ghost-muted" size="large"
size="large" icon={<IconV2 name="archive" />}
icon={<IconV2 name="archive" />} aria-label={language.t("common.archive")}
aria-label={language.t("common.archive")} onClick={(event) => {
onClick={(event) => { event.preventDefault()
event.preventDefault() event.stopPropagation()
event.stopPropagation() void props.archiveSession(props.record.session)
void props.archiveSession(props.record.session) }}
}} />
/> </TooltipV2>
</TooltipV2> </div>
</div>
</Show>
</div> </div>
) )
} }
+30 -2
View File
@@ -1,18 +1,46 @@
import { createEffect, Suspense, type ParentProps } from "solid-js" import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar" import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button" import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useCommand } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click" import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast" import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) { export default function NewLayout(props: ParentProps) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const notification = useNotification()
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams<{ id?: string }>()
setNavigate(navigate) setNavigate(navigate)
createEffect(() => setV2Toast(true)) createEffect(() => setV2Toast(true))
createEffect(() => {
if (!notification.ready() || !params.id) return
if (notification.session.unseenCount(params.id) === 0) return
notification.session.markViewed(params.id)
})
command.register("layout", () => [
{
id: "settings.open",
title: language.t("command.settings.open"),
category: language.t("command.category.settings"),
keybind: "mod+comma",
onSelect: () => {
void import("@/components/settings-v2").then((x) => {
dialog.show(() => <x.DialogSettings />)
})
},
},
])
const update: TitlebarUpdate = { const update: TitlebarUpdate = {
version: () => { version: () => {
@@ -36,7 +64,7 @@ export default function NewLayout(props: ParentProps) {
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict"> <main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense> <Suspense>{props.children}</Suspense>
</main> </main>
{import.meta.env.DEV && <DebugBar inline />} {import.meta.env.DEV && <DebugBar />}
<HelpButton /> <HelpButton />
<ToastRegion v2 /> <ToastRegion v2 />
</div> </div>
+12
View File
@@ -943,6 +943,18 @@ export default function LegacyLayout(props: ParentProps) {
keybind: "mod+comma", keybind: "mod+comma",
onSelect: () => openSettings(), onSelect: () => openSettings(),
}, },
...(platform.platform === "desktop" && platform.exportDebugLogs
? [
{
id: "logs.export",
title: "Export logs",
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
},
]
: []),
{ {
id: "session.previous", id: "session.previous",
title: language.t("command.session.previous"), title: language.t("command.session.previous"),
+2 -1
View File
@@ -2,7 +2,6 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import type { ServerConnection } from "@/context/server" import type { ServerConnection } from "@/context/server"
import type { HomeProjectSelection } from "@/context/layout"
type SessionStore = { type SessionStore = {
session?: Session[] session?: Session[]
@@ -57,6 +56,8 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
export const displayName = (project: { name?: string; worktree: string }) => export const displayName = (project: { name?: string; worktree: string }) =>
project.name || getFilename(project.worktree) || project.worktree project.name || getFilename(project.worktree) || project.worktree
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export function toggleHomeProjectSelection( export function toggleHomeProjectSelection(
current: HomeProjectSelection | undefined, current: HomeProjectSelection | undefined,
server: ServerConnection.Key, server: ServerConnection.Key,
@@ -11,28 +11,32 @@ export function SessionTabAvatar(props: {
directory: string directory: string
sessionId: string sessionId: string
activeServer: boolean activeServer: boolean
revealProjectOnHover?: boolean
}) { }) {
const directory = () => props.directory const directory = () => props.directory
const sessionId = () => props.sessionId const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
const projectAvatar = () => (
<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()}
/>
)
return ( return (
<Show when={state.loading()} fallback={projectAvatar()}> <Show
<span class="relative block size-4 shrink-0"> when={state.loading()}
<SessionProgressIndicatorV2 fallback={
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`} <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()}
/> />
<Show when={props.revealProjectOnHover !== false}> }
<span class="invisible absolute inset-0 group-hover:visible">{projectAvatar()}</span> >
</Show> <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> </span>
</Show> </Show>
) )
+33 -100
View File
@@ -1,27 +1,18 @@
import { Show, createEffect, createMemo, createResource, untrack } from "solid-js" import { createEffect, createMemo, onMount, untrack } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useSearchParams } from "@solidjs/router" import { useSearchParams } from "@solidjs/router"
import { NewSessionDesignView } from "@/components/session" 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 { useComments } from "@/context/comments"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import {
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer" createSessionComposerControls,
createSessionComposerState,
SessionComposerRegion,
} from "@/pages/session/composer"
import { useSessionKey } from "@/pages/session/session-layout" 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"
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
/** /**
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
@@ -34,41 +25,28 @@ export default function NewSessionPage() {
const sync = useSync() const sync = useSync()
const serverSync = useServerSync() const serverSync = useServerSync()
const comments = useComments() const comments = useComments()
const language = useLanguage()
const route = useSessionKey() const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
useComposerCommands()
useSettingsCommand()
let inputRef: HTMLDivElement | undefined let inputRef: HTMLDivElement | undefined
const inputController = createPromptInputController({ const composer = createSessionComposerState()
const composerControls = createSessionComposerControls({
sessionKey: route.sessionKey, sessionKey: route.sessionKey,
sessionID: () => route.params.id, sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions, queryOptions: serverSync().queryOptions,
}) })
const projectControls = createPromptProjectControls()
const projectController = createPromptProjectController({ const [store, setStore] = createStore({
controls: projectControls, worktree: "main",
onDone: () => inputRef?.focus(),
}) })
const [store, setStore] = createStore<{ worktree?: string }>({})
const newSessionWorktree = createMemo(() => { const newSessionWorktree = createMemo(() => {
if (store.worktree) return store.worktree if (store.worktree === "create") return "create"
const project = sync().project const project = sync().project
if (project && sdk().directory !== project.worktree) return sdk().directory if (project && sdk().directory !== project.worktree) return sdk().directory
return "main" return "main"
}) })
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
const selectedBranch = createMemo(() => {
const worktree = newSessionWorktree()
if (worktree === "main" || worktree === "create") return localBranch()
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
})
createEffect(() => { createEffect(() => {
if (!prompt.ready()) return if (!prompt.ready()) return
@@ -80,15 +58,9 @@ export default function NewSessionPage() {
}) })
}) })
createEffect(() => { onMount(() => {
if (!prompt.ready()) return
requestAnimationFrame(() => inputRef?.focus()) requestAnimationFrame(() => inputRef?.focus())
}) })
const ready = Promise.resolve()
const [promptReady] = createResource(
() => prompt.ready.promise ?? ready,
(promise) => promise.then(() => true),
)
return ( return (
<div class="relative size-full overflow-hidden flex flex-col"> <div class="relative size-full overflow-hidden flex flex-col">
@@ -96,65 +68,26 @@ export default function NewSessionPage() {
<div class="@container relative flex flex-col min-h-0 h-full bg-background-stronger flex-1"> <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]"> <div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
<NewSessionDesignView> <NewSessionDesignView>
<div class={NEW_SESSION_CONTENT_WIDTH}> <SessionComposerRegion
<Show state={composer}
when={prompt.ready() || promptReady()} sessionKey={route.sessionKey()}
fallback={ sessionID={route.params.id}
<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"> controls={composerControls()}
{language.t("prompt.loading")} promptInput={{
</div> ref: (el) => {
} inputRef = el
> },
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}> newSessionWorktree: newSessionWorktree(),
<PromptInput onNewSessionWorktreeReset: () => setStore("worktree", "main"),
controls={inputController()} onSubmit: () => comments.clear(),
variant="new-session" }}
ref={(el) => { todo={{ collapsed: false, onToggle: () => {} }}
inputRef = el ready
}} centered={false}
newSessionWorktree={newSessionWorktree()} placement="inline"
onNewSessionWorktreeReset={() => setStore("worktree", undefined)} onResponseSubmit={() => {}}
onSubmit={() => comments.clear()} setPromptDockRef={() => {}}
toolbar={ />
<Show when={!projectController.selected()}>
<PromptProjectAddButton controller={projectController} />
</Show>
}
/>
<Show when={projectController.selected()}>
<div
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
classList={{
"flex-col justify-center sm:flex-row": showWorkspaceBar,
"justify-start": !showWorkspaceBar,
}}
>
<PromptProjectSelector
controller={projectController}
placement={showWorkspaceBar ? "bottom" : "bottom-start"}
/>
<Show when={showWorkspaceBar}>
<PromptWorkspaceSelector
value={newSessionWorktree()}
projectRoot={projectRoot()}
workspaces={sync().project?.sandboxes ?? []}
branch={selectedBranch()}
onChange={(value) =>
setStore(
"worktree",
value === "main" && sync().project?.worktree !== sdk().directory
? sync().project?.worktree
: value,
)
}
onDone={() => inputRef?.focus()}
/>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</NewSessionDesignView> </NewSessionDesignView>
</div> </div>
</div> </div>
+68 -88
View File
@@ -42,13 +42,10 @@ import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" 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 { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import { import {
createPromptInputController, createSessionComposerControls,
createSessionComposerController, createSessionComposerState,
createSessionComposerRegionController,
SessionComposerRegion, SessionComposerRegion,
} from "@/pages/session/composer" } from "@/pages/session/composer"
import { import {
@@ -66,7 +63,6 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { syncSessionModel } from "@/pages/session/session-model-helpers" import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-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 { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
@@ -159,8 +155,8 @@ export default function Page() {
}, },
}) })
const composer = createSessionComposerController() const composer = createSessionComposerState()
const inputController = createPromptInputController({ const composerControls = createSessionComposerControls({
sessionKey, sessionKey,
sessionID: () => params.id, sessionID: () => params.id,
queryOptions: serverSync().queryOptions, queryOptions: serverSync().queryOptions,
@@ -787,8 +783,6 @@ export default function Page() {
inputRef?.focus() inputRef?.focus()
} }
useComposerCommands()
useSettingsCommand()
useSessionCommands({ useSessionCommands({
navigateMessageByOffset, navigateMessageByOffset,
setActiveMessage, setActiveMessage,
@@ -908,13 +902,7 @@ export default function Page() {
) )
const reviewPanel = () => ( const reviewPanel = () => (
<div <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
classList={{
"flex flex-col h-full overflow-hidden contain-strict": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
{reviewContent({ {reviewContent({
diffStyle: layout.review.diffStyle(), diffStyle: layout.review.diffStyle(),
@@ -1579,38 +1567,31 @@ export default function Page() {
useUsageExceededDialogs() useUsageExceededDialogs()
const composerRegion = () => { const composerRegion = (placement: "dock" | "inline") => (
const controller = createSessionComposerRegionController({ <SessionComposerRegion
state: composer, state={composer}
sessionKey, sessionKey={sessionKey()}
sessionID: () => params.id, sessionID={params.id}
prompt, controls={composerControls()}
ready: () => !store.deferRender && messagesReady(), promptInput={{
centered, ref: (el) => {
todo: { inputRef = el
collapsed: () => view().todoCollapsed.get(), },
newSessionWorktree: newSessionWorktree(),
onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
onSubmit: () => {
comments.clear()
resumeScroll()
},
}}
todo={{
collapsed: view().todoCollapsed.get(),
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()), onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
}, }}
followup: () => ready={!store.deferRender && messagesReady()}
params.id && !isChildSession() centered={placement === "dock" && centered()}
? { placement={placement}
items: followupDock(), openParent={() => {
sending: sendingFollowup(),
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
onEdit: editFollowup,
}
: undefined,
revert: () =>
rolled().length > 0
? {
items: rolled(),
restoring: restoring(),
disabled: reverting(),
onRestore: restore,
}
: undefined,
onResponseSubmit: resumeScroll,
openParent: () => {
const id = info()?.parentID const id = info()?.parentID
if (!id) return if (!id) return
navigate( navigate(
@@ -1618,43 +1599,44 @@ export default function Page() {
? sessionHref(requireServerKey(params.serverKey), id) ? sessionHref(requireServerKey(params.serverKey), id)
: legacySessionHref(sdk().directory, id), : legacySessionHref(sdk().directory, id),
) )
}, }}
setPromptRef: (el) => { onResponseSubmit={resumeScroll}
inputRef = el followup={
}, params.id && !isChildSession()
setDockRef: (el) => { ? {
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 })
},
onEdit: editFollowup,
onEditLoaded: clearFollowupEdit,
}
: undefined
}
revert={
rolled().length > 0
? {
items: rolled(),
restoring: restoring(),
disabled: reverting(),
onRestore: restore,
}
: undefined
}
setPromptDockRef={(el) => {
promptDock = 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) => ( const mobileTabs = (compact = false, bottom = false) => (
<Tabs value={store.mobileTab} class="h-auto"> <Tabs value={store.mobileTab} class="h-auto">
@@ -1719,9 +1701,7 @@ export default function Page() {
> >
<div <div
classList={{ classList={{
"flex-1 min-h-0 flex flex-col": true, "flex-1 min-h-0 flex flex-col bg-background-stronger": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(), "rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id, "shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}} }}
@@ -1793,7 +1773,7 @@ export default function Page() {
</Switch> </Switch>
</div> </div>
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show> <Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show> <Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
</div> </div>
@@ -1,4 +1,3 @@
export { SessionComposerRegion } from "./session-composer-region" export { SessionComposerRegion } from "./session-composer-region"
export { createPromptInputController, createPromptProjectControls } from "./session-composer-controls" export { createSessionComposerControls } from "./session-composer-controls"
export { createSessionComposerController } from "./session-composer-state" export { createSessionComposerState } from "./session-composer-state"
export { createSessionComposerRegionController } from "./session-composer-region-controller"
@@ -3,37 +3,88 @@ import { createQuery } from "@tanstack/solid-query"
import { useNavigate, useSearchParams } from "@solidjs/router" import { useNavigate, useSearchParams } from "@solidjs/router"
import { type Accessor, createMemo } from "solid-js" import { type Accessor, createMemo } from "solid-js"
import type { PromptInputControls } from "@/components/prompt-input" import type { PromptInputControls } from "@/components/prompt-input"
import type { PromptProjectControls } from "@/components/prompt-project-selector"
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import type { QueryOptionsApi } from "@/context/server-sync" import type { QueryOptionsApi } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk" import { ServerConnection, useServer } from "@/context/server"
import { serverName, ServerConnection, useServer } from "@/context/server"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs" import { type DraftTab, useTabs } from "@/context/tabs"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
export function createPromptInputController(input: { export function createSessionComposerControls(input: {
sessionKey: Accessor<string> sessionKey: Accessor<string>
sessionID: Accessor<string | undefined> sessionID: Accessor<string | undefined>
queryOptions: Pick<QueryOptionsApi, "agents" | "providers"> queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
}) { }) {
const navigate = useNavigate()
const layout = useLayout() const layout = useLayout()
const local = useLocal() const local = useLocal()
const providers = useProviders() const providers = useProviders()
const settings = useSettings() const settings = useSettings()
const server = useServer()
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const [search] = useSearchParams<{ draftId?: string }>()
const view = layout.view(input.sessionKey) 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 agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null)) const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory))) 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>(() => ({ return createMemo<PromptInputControls>(() => ({
agents: { agents: {
available: sync().data.agent, available: sync().data.agent,
@@ -48,6 +99,12 @@ export function createPromptInputController(input: {
paid: providers.paid().length > 0, paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
}, },
projects: {
available: projects(),
directory: sdk().directory,
select: selectProject,
add: addProject,
},
session: { session: {
id: input.sessionID(), id: input.sessionID(),
tabs: layout.tabs(input.sessionKey), tabs: layout.tabs(input.sessionKey),
@@ -56,75 +113,3 @@ export function createPromptInputController(input: {
newLayoutDesigns: settings.general.newLayoutDesigns(), 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,
}))
}
@@ -1,145 +0,0 @@
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,89 +1,216 @@
import { Show, type JSX } from "solid-js" 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 { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" 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 { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-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 { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller" 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"
export function SessionComposerRegion(props: { export function SessionComposerRegion(props: {
controller: SessionComposerRegionController state: SessionComposerState
promptInput: JSX.Element sessionKey: string
}) { sessionID?: string
const language = useLanguage() controls: PromptInputControls
const controller = props.controller promptInput: Omit<PromptInputProps, "controls" | "variant">
const settings = useSettings() todo: {
const rolled = () => { collapsed: boolean
const revert = controller.revert() onToggle: () => void
return revert?.items.length ? revert : undefined
} }
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
}) {
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
}
}
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 ( return (
<div <div
ref={controller.setDockRef} ref={props.setPromptDockRef}
data-component="session-prompt-dock" data-component="session-prompt-dock"
classList={{ classList={{
"w-full shrink-0 flex flex-col justify-center items-center pb-3 pointer-events-none": true, "w-full flex flex-col justify-center items-center pointer-events-none": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(), "shrink-0 pb-3 bg-background-stronger": props.placement !== "inline",
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}} }}
> >
<div <div
classList={{ classList={{
"w-full px-3 pointer-events-auto": true, "w-full pointer-events-auto": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": controller.centered(), "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,
}} }}
> >
<Show when={controller.state.questionRequest()} keyed> <Show when={props.state.questionRequest()} keyed>
{(request) => ( {(request) => (
<div> <div>
<SessionQuestionDock request={request} onSubmit={controller.onResponseSubmit} /> <SessionQuestionDock request={request} onSubmit={props.onResponseSubmit} />
</div> </div>
)} )}
</Show> </Show>
<Show when={controller.state.permissionRequest()} keyed> <Show when={props.state.permissionRequest()} keyed>
{(request) => ( {(request) => (
<div> <div>
<SessionPermissionDock <SessionPermissionDock
request={request} request={request}
responding={controller.state.permissionResponding()} responding={props.state.permissionResponding()}
onDecide={(response) => { onDecide={(response) => {
controller.onResponseSubmit() props.onResponseSubmit()
controller.state.decide(response) props.state.decide(response)
}} }}
/> />
</div> </div>
)} )}
</Show> </Show>
<Show when={controller.showComposer()}> <Show when={showComposer()}>
<Show when={controller.dock()}> <Show when={dock()}>
<div <div
classList={{ classList={{
"overflow-hidden": true, "overflow-hidden": true,
"pointer-events-none": controller.dockProgress() < 0.98, "pointer-events-none": value() < 0.98,
}} }}
style={{ style={{
"max-height": `${controller.dockHeight() * controller.dockProgress()}px`, "max-height": `${full() * value()}px`,
}} }}
> >
<div ref={controller.setDockBodyRef}> <div ref={(el) => setStore("body", el)}>
<SessionTodoDock <SessionTodoDock
todos={controller.state.todos()} todos={props.state.todos()}
collapsed={controller.todo.collapsed()} collapsed={props.todo.collapsed}
onToggle={controller.todo.onToggle} onToggle={props.todo.onToggle}
collapseLabel={language.t("session.todo.collapse")} collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")} expandLabel={language.t("session.todo.expand")}
dockProgress={controller.dockProgress()} dockProgress={value()}
/> />
</div> </div>
</div> </div>
</Show> </Show>
<Show <Show
when={controller.promptReady()} when={prompt.ready() || promptReadyResource()}
fallback={ fallback={
<> <>
<Show when={rolled()} keyed> <Show when={rolled()} keyed>
@@ -100,9 +227,9 @@ export function SessionComposerRegion(props: {
</Show> </Show>
<div <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" 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 * controller.dockProgress()}px` }} style={{ "margin-top": `${-36 * value()}px` }}
> >
{controller.handoffPrompt() || language.t("prompt.loading")} {handoffPrompt() || language.t("prompt.loading")}
</div> </div>
</> </>
} }
@@ -111,7 +238,7 @@ export function SessionComposerRegion(props: {
{(revert) => ( {(revert) => (
<div <div
style={{ style={{
"margin-top": `${-36 * controller.dockProgress()}px`, "margin-top": `${-36 * value()}px`,
}} }}
> >
<SessionRevertDock <SessionRevertDock
@@ -125,34 +252,47 @@ export function SessionComposerRegion(props: {
</Show> </Show>
<div <div
classList={{ classList={{
"relative z-[70]": true, "relative z-30": true,
}} }}
style={{ style={{
"margin-top": `${-controller.lift()}px`, "margin-top": `${-lift()}px`,
}} }}
> >
<Show when={controller.followup()?.items.length}> <Show when={props.followup?.items.length}>
<SessionFollowupDock <SessionFollowupDock
items={controller.followup()!.items} items={props.followup!.items}
sending={controller.followup()!.sending} sending={props.followup!.sending}
onSend={controller.followup()!.onSend} onSend={props.followup!.onSend}
onEdit={controller.followup()!.onEdit} onEdit={props.followup!.onEdit}
/> />
</Show> </Show>
<Show <Show
when={controller.child()} when={child()}
fallback={<Show when={!controller.state.blocked()}>{props.promptInput}</Show>} 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>
}
> >
<div <div
ref={controller.setPromptRef} ref={props.promptInput.ref}
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak" 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> <span>{language.t("session.child.promptDisabled")} </span>
<Show when={controller.parentID()}> <Show when={parentID() && props.openParent}>
<button <button
type="button" type="button"
class="text-text-base transition-colors hover:text-text-strong" class="text-text-base transition-colors hover:text-text-strong"
onClick={controller.openParent} onClick={props.openParent}
> >
{language.t("session.child.backToParent")} {language.t("session.child.backToParent")}
</button> </button>
@@ -25,7 +25,7 @@ export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) { export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
const params = useParams() const params = useParams()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
@@ -201,4 +201,4 @@ export function createSessionComposerController(options?: { closeMs?: number | (
} }
} }
export type SessionComposerController = ReturnType<typeof createSessionComposerController> export type SessionComposerState = ReturnType<typeof createSessionComposerState>
@@ -432,23 +432,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
header={ header={
<> <>
<div data-slot="question-header-title">{summary()}</div> <div data-slot="question-header-title">{summary()}</div>
<Show when={total() > 1}> <div data-slot="question-progress">
<div data-slot="question-progress"> <For each={questions()}>
<For each={questions()}> {(_, i) => (
{(_, i) => ( <button
<button type="button"
type="button" data-slot="question-progress-segment"
data-slot="question-progress-segment" data-active={i() === store.tab}
data-active={i() === store.tab} data-answered={answered(i())}
data-answered={answered(i())} disabled={sending()}
disabled={sending()} onClick={() => jump(i())}
onClick={() => jump(i())} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} />
/> )}
)} </For>
</For> </div>
</div>
</Show>
</> </>
} }
footer={ footer={
@@ -3,13 +3,7 @@ import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { Todo } from "@opencode-ai/sdk/v2" import type { Todo } from "@opencode-ai/sdk/v2"
import { useServerSync } from "@/context/global-sync" import { useServerSync } from "@/context/global-sync"
import { PromptInput } from "@/components/prompt-input" import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
import { usePrompt } from "@/context/prompt"
import {
SessionComposerRegion,
createSessionComposerController,
createSessionComposerRegionController,
} from "@/pages/session/composer"
export default { export default {
title: "UI/Todo Panel Motion", title: "UI/Todo Panel Motion",
@@ -66,6 +60,7 @@ const controls = {
paid: true, paid: true,
loading: false, loading: false,
}, },
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
session: { session: {
id: "story-session", id: "story-session",
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} }, tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
@@ -154,7 +149,6 @@ const css = `
export const Playground = { export const Playground = {
render: () => { render: () => {
const global = useServerSync() const global = useServerSync()
const prompt = usePrompt()
const [cfg, setCfg] = createStore({ const [cfg, setCfg] = createStore({
open: true, open: true,
collapsed: false, collapsed: false,
@@ -194,7 +188,7 @@ export const Playground = {
const countMask = () => cfg.countMask const countMask = () => cfg.countMask
const countMaskHeight = () => cfg.countMaskHeight const countMaskHeight = () => cfg.countMaskHeight
const countWidthDuration = () => cfg.countWidthDuration const countWidthDuration = () => cfg.countWidthDuration
const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) }) const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
let frame let frame
let scrollRef let scrollRef
@@ -223,6 +217,7 @@ export const Playground = {
const collapsed = () => cfg.collapsed const collapsed = () => cfg.collapsed
const setCollapsed = (value: boolean) => setCfg("collapsed", value) const setCollapsed = (value: boolean) => setCfg("collapsed", value)
const openDock = () => { const openDock = () => {
clear() clear()
setCfg("open", true) setCfg("open", true)
@@ -286,30 +281,36 @@ export const Playground = {
<div> <div>
<SessionComposerRegion <SessionComposerRegion
controller={createSessionComposerRegionController({ state={state}
state, sessionKey="story-session"
sessionKey: () => "story-session", sessionID="story-session"
sessionID: () => "story-session", controls={controls}
prompt, promptInput={{
ready: () => true, submission: { abort: () => {}, handleSubmit: (event) => event.preventDefault() },
centered: () => false, ref: () => {},
todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) }, newSessionWorktree: "",
followup: () => undefined, onNewSessionWorktreeReset: () => {},
revert: () => undefined, }}
onResponseSubmit: pin, todo={{ collapsed: collapsed(), onToggle: () => setCollapsed(!collapsed()) }}
openParent: () => {}, ready
setPromptRef: () => {}, centered={false}
setDockRef: () => {}, onResponseSubmit={pin}
})} setPromptDockRef={() => {}}
promptInput={ dockOpenVisualDuration={dockOpenDuration()}
<PromptInput dockOpenBounce={dockOpenBounce()}
controls={controls} dockCloseVisualDuration={dockCloseDuration()}
submission={{ abort: () => {}, handleSubmit: (event) => event.preventDefault() }} dockCloseBounce={dockCloseBounce()}
ref={() => {}} drawerExpandVisualDuration={drawerExpandDuration()}
newSessionWorktree="" drawerExpandBounce={drawerExpandBounce()}
onNewSessionWorktreeReset={() => {}} drawerCollapseVisualDuration={drawerCollapseDuration()}
/> drawerCollapseBounce={drawerCollapseBounce()}
} subtitleDuration={subtitleDuration()}
subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()}
subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()}
countDuration={countDuration()}
countMask={countMask()}
countMaskHeight={countMaskHeight()}
countWidthDuration={countWidthDuration()}
/> />
</div> </div>
</div> </div>
@@ -293,7 +293,7 @@ export function TerminalPanel() {
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>
<div class="flex-1 min-h-0 relative"> <div class="flex-1 min-h-0 relative">
<Show when={opened() && terminal.active()} keyed> <Show when={terminal.active()} keyed>
{(id) => { {(id) => {
const ops = terminal.bind() const ops = terminal.bind()
return ( return (
@@ -31,14 +31,9 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { InlineInput } from "@opencode-ai/ui/inline-input" import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry" import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -676,34 +671,6 @@ export function MessageTimeline(props: {
if (!shareEnabled()) return if (!shareEnabled()) return
unshareMutation.mutate(id) unshareMutation.mutate(id)
} }
const copyShareUrl = () => {
const url = shareUrl()
if (!url) return
void navigator.clipboard
.writeText(url)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: url,
}),
)
.catch((err: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
}),
)
}
const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
const selection = window.getSelection()
if (!selection) return
const range = document.createRange()
range.selectNodeContents(event.currentTarget)
selection.removeAllRanges()
selection.addRange(range)
}
createEffect( createEffect(
on( on(
@@ -889,26 +856,6 @@ export function MessageTimeline(props: {
dialog.close() dialog.close()
} }
if (settings.general.newLayoutDesigns())
return (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={handleDelete}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
return ( return (
<Dialog title={language.t("session.delete.title")} fit> <Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3"> <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
@@ -1013,7 +960,6 @@ export function MessageTimeline(props: {
message={message()} message={message()}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)} showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)} turnDurationMs={turnDurationMs(row().userMessageID)}
useV2Actions={settings.general.newLayoutDesigns()}
defaultOpen={defaultOpen()} defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()} toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)} onToolOpenChange={(open) => setToolOpen(part().id, open)}
@@ -1073,13 +1019,7 @@ export function MessageTimeline(props: {
<div class="flex w-max min-w-full justify-end gap-2"> <div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}> <Index each={comments()}>
{(comment) => ( {(comment) => (
<div <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
classList={{
"shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true,
"border-[0.5px]": settings.general.newLayoutDesigns(),
border: !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong"> <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
<FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" /> <FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" />
<span class="truncate">{getFilename(comment().path)}</span> <span class="truncate">{getFilename(comment().path)}</span>
@@ -1121,7 +1061,6 @@ export function MessageTimeline(props: {
message={message()} message={message()}
parts={getMsgParts(userMessageRow().userMessageID)} parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions} actions={props.actions}
useV2Actions={settings.general.newLayoutDesigns()}
/> />
</div> </div>
</div> </div>
@@ -1349,26 +1288,17 @@ export function MessageTimeline(props: {
<div <div
data-session-title data-session-title
classList={{ classList={{
"sticky top-0 z-30": true, "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]":
settings.general.newLayoutDesigns(),
"bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]":
!settings.general.newLayoutDesigns(),
"w-full": true, "w-full": true,
"pb-4": true, "pb-4": true,
"pr-3": true, "pr-3": true,
"pl-2": settings.general.newLayoutDesigns(), "pl-4": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(), "pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(), "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
<div class="h-12 w-full flex items-center justify-between gap-2"> <div class="h-12 w-full flex items-center justify-between gap-2">
<div <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
classList={{
"flex items-center gap-1 min-w-0 flex-1": true,
"pr-3": !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center min-w-0 grow-1"> <div class="flex items-center min-w-0 grow-1">
<Show when={parentID()}> <Show when={parentID()}>
<button <button
@@ -1393,13 +1323,8 @@ export function MessageTimeline(props: {
fallback={ fallback={
<h1 <h1
data-slot="session-title-child" data-slot="session-title-child"
classList={{ class="text-14-medium text-text-strong truncate grow-1 min-w-0"
"text-14-medium text-text-strong truncate": true, onDblClick={openTitleEditor}
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
settings.general.newLayoutDesigns(),
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
}}
onClick={openTitleEditor}
> >
{childTitle()} {childTitle()}
</h1> </h1>
@@ -1412,17 +1337,8 @@ export function MessageTimeline(props: {
data-slot="session-title-child" data-slot="session-title-child"
value={title.draft} value={title.draft}
disabled={titleMutation.isPending} disabled={titleMutation.isPending}
classList={{ class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
"text-14-medium text-text-strong grow-1 min-w-0 pl-1 ml-1": true, style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
"grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
"rounded-[6px] -ml-2 px-2 py-1 h-6 leading-4 focus:shadow-none focus:outline focus:outline-1 focus:outline-offset-[-1px] focus:outline-v2-border-border-focus":
settings.general.newLayoutDesigns(),
}}
style={{
"--inline-input-shadow": settings.general.newLayoutDesigns()
? "none"
: "var(--shadow-xs-border-select)",
}}
onInput={(event) => setTitle("draft", event.currentTarget.value)} onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -1444,170 +1360,88 @@ export function MessageTimeline(props: {
</div> </div>
<Show when={sessionID()} keyed> <Show when={sessionID()} keyed>
{(id) => ( {(id) => (
<div <div class="shrink-0 flex items-center gap-3">
classList={{ <SessionContextUsage placement="bottom" />
"shrink-0 flex items-center": true,
"gap-2": settings.general.newLayoutDesigns(),
"gap-3": !settings.general.newLayoutDesigns(),
}}
>
<SessionContextUsage
placement="bottom"
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
/>
<Show when={!parentID()}> <Show when={!parentID()}>
<Show <DropdownMenu
when={settings.general.newLayoutDesigns()} gutter={4}
fallback={ placement="bottom-end"
<DropdownMenu open={title.menuOpen}
gutter={4} onOpenChange={(open) => {
placement="bottom-end" setTitle("menuOpen", open)
open={title.menuOpen} if (open) return
onOpenChange={(open) => { }}
setTitle("menuOpen", open) >
if (open) return <DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}} }}
> >
<DropdownMenu.Trigger <DropdownMenu.Item
as={IconButton} onSelect={() => {
icon="dot-grid" setTitle("pendingRename", true)
variant="ghost" setTitle("menuOpen", false)
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={shareEnabled()}>
<DropdownMenu.Item
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
}}
>
<DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
}
>
<MenuV2
gutter={6}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
>
<MenuV2.Trigger
as={IconButtonV2}
icon={<IconV2 name="outline-dots" />}
variant="ghost-muted"
size="large"
state={share.open || title.pendingShare ? "pressed" : undefined}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<MenuV2.Portal>
<MenuV2.Content
style={{ width: "120px", "min-width": "120px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}} }}
> >
<MenuV2.Item <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={shareEnabled()}>
<DropdownMenu.Item
onSelect={() => { onSelect={() => {
setTitle("pendingRename", true) setTitle({ pendingShare: true, menuOpen: false })
setTitle("menuOpen", false)
}} }}
> >
{language.t("common.rename")} <DropdownMenu.ItemLabel>
</MenuV2.Item> {language.t("session.share.action.share")}
<Show when={shareEnabled()}> </DropdownMenu.ItemLabel>
<MenuV2.Item </DropdownMenu.Item>
onSelect={() => { </Show>
setTitle({ pendingShare: true, menuOpen: false }) <DropdownMenu.Item onSelect={() => void archiveSession(id)}>
}} <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
> </DropdownMenu.Item>
{language.t("session.share.action.share")}... <DropdownMenu.Separator />
</MenuV2.Item> <DropdownMenu.Item
</Show> onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
<MenuV2.Item onSelect={() => void archiveSession(id)}> >
{language.t("common.archive")} <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</MenuV2.Item> </DropdownMenu.Item>
<MenuV2.Separator /> </DropdownMenu.Content>
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}> </DropdownMenu.Portal>
{language.t("common.delete")}... </DropdownMenu>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
<KobaltePopover <KobaltePopover
open={share.open} open={share.open}
anchorRef={() => more} anchorRef={() => more}
placement="bottom-end" placement="bottom-end"
gutter={settings.general.newLayoutDesigns() ? 6 : 4} gutter={4}
modal={false} modal={false}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) setShare("dismiss", null) if (open) setShare("dismiss", null)
@@ -1617,10 +1451,6 @@ export function MessageTimeline(props: {
<KobaltePopover.Portal> <KobaltePopover.Portal>
<KobaltePopover.Content <KobaltePopover.Content
data-component="popover-content" data-component="popover-content"
classList={{
"flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
settings.general.newLayoutDesigns(),
}}
style={{ "min-width": "320px" }} style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => { onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false }) setShare({ dismiss: "escape", open: false })
@@ -1638,90 +1468,24 @@ export function MessageTimeline(props: {
setShare("dismiss", null) setShare("dismiss", null)
}} }}
> >
<Show <div class="flex flex-col p-3">
when={settings.general.newLayoutDesigns()} <div class="flex flex-col gap-1">
fallback={ <div class="text-13-medium text-text-strong">
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
{language.t("session.share.popover.title")}
</div>
<div class="text-12-regular text-text-weak">
{shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
class="w-full"
onClick={shareSession}
disabled={shareMutation.isPending}
>
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</div>
}
>
<div class="flex w-full flex-col gap-1.5 px-0.5 pt-0.5">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]">
{language.t("session.share.popover.title")} {language.t("session.share.popover.title")}
</div> </div>
<div class="select-none text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-variation-settings:'slnt'_0]"> <div class="text-12-regular text-text-weak">
{shareUrl() {shareUrl()
? language.t("session.share.popover.description.shared") ? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")} : language.t("session.share.popover.description.unshared")}
</div> </div>
</div> </div>
<div class="flex w-full flex-col gap-2"> <div class="mt-3 flex flex-col gap-2">
<Show <Show
when={shareUrl()} when={shareUrl()}
fallback={ fallback={
<ButtonV2 <Button
variant="contrast" size="large"
variant="primary"
class="w-full" class="w-full"
onClick={shareSession} onClick={shareSession}
disabled={shareMutation.isPending} disabled={shareMutation.isPending}
@@ -1729,57 +1493,44 @@ export function MessageTimeline(props: {
{shareMutation.isPending {shareMutation.isPending
? language.t("session.share.action.publishing") ? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")} : language.t("session.share.action.publish")}
</ButtonV2> </Button>
} }
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div <TextField
class="flex h-8 w-full items-center gap-1.5 rounded-[6px] py-1 pl-2.5 pr-1.5 shadow-[var(--v2-elevation-button-neutral)]" value={shareUrl() ?? ""}
style={{ readOnly
background: copyable
"linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-button-neutral)", copyKind="link"
}} tabIndex={-1}
> class="w-full"
<div />
class="min-w-0 flex-1 truncate select-text cursor-text text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]" <div class="grid grid-cols-2 gap-2">
onClick={selectShareUrlText} <Button
> size="large"
{shareUrl()} variant="secondary"
</div> class="w-full shadow-none border border-border-weak-base"
<IconButtonV2
type="button"
size="small"
variant="ghost-muted"
icon={<IconV2 name="outline-copy" />}
aria-label={language.t("session.share.copy.copyLink")}
onClick={copyShareUrl}
/>
<IconButtonV2
type="button"
size="small"
variant="ghost-muted"
icon={<IconV2 name="outline-square-arrow" />}
aria-label={language.t("session.share.action.view")}
onClick={viewShare}
disabled={unshareMutation.isPending}
/>
</div>
<div class="flex w-full">
<ButtonV2
variant="outline"
class="w-full"
onClick={unshareSession} onClick={unshareSession}
disabled={unshareMutation.isPending} disabled={unshareMutation.isPending}
> >
{unshareMutation.isPending {unshareMutation.isPending
? language.t("session.share.action.unpublishing") ? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")} : language.t("session.share.action.unpublish")}
</ButtonV2> </Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
</div> </div>
</div> </div>
</Show> </Show>
</div> </div>
</Show> </div>
</KobaltePopover.Content> </KobaltePopover.Content>
</KobaltePopover.Portal> </KobaltePopover.Portal>
</KobaltePopover> </KobaltePopover>
@@ -1,69 +0,0 @@
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,7 +136,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const contextCommand = withCategory(language.t("command.category.context")) const contextCommand = withCategory(language.t("command.category.context"))
const viewCommand = withCategory(language.t("command.category.view")) const viewCommand = withCategory(language.t("command.category.view"))
const terminalCommand = withCategory(language.t("command.category.terminal")) 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 mcpCommand = withCategory(language.t("command.category.mcp"))
const agentCommand = withCategory(language.t("command.category.agent"))
const permissionsCommand = withCategory(language.t("command.category.permissions")) const permissionsCommand = withCategory(language.t("command.category.permissions"))
const isAutoAcceptActive = () => { const isAutoAcceptActive = () => {
@@ -269,6 +271,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
view().terminal.open() view().terminal.open()
} }
const chooseModel = () => {
void openDialog(
() => import("@/components/dialog-select-model"),
(x) => dialog.show(() => <x.DialogSelectModel model={local.model} />),
)
}
const chooseMcp = () => { const chooseMcp = () => {
void openDialog( void openDialog(
() => import("@/components/dialog-select-mcp"), () => import("@/components/dialog-select-mcp"),
@@ -546,6 +555,24 @@ 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 = () => [ const mcpCmds = () => [
mcpCommand({ mcpCommand({
id: "mcp.toggle", id: "mcp.toggle",
@@ -557,6 +584,26 @@ 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 = () => [ const permissionsCmds = () => [
permissionsCommand({ permissionsCommand({
id: "permissions.autoaccept", id: "permissions.autoaccept",
@@ -577,7 +624,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
...viewCmds(), ...viewCmds(),
...terminalCmds(), ...terminalCmds(),
...messageCmds(), ...messageCmds(),
...modelCmds(),
...mcpCmds(), ...mcpCmds(),
...agentCmds(),
...permissionsCmds(), ...permissionsCmds(),
]) ])
} }
+3 -10
View File
@@ -1,8 +1,7 @@
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
@@ -31,14 +30,8 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const language = useLanguage() const language = useLanguage()
const openAddWsl = () => { const openAddWsl = () => {
dialog.push(() => ( dialog.push(() => (
<Dialog size="large" fit class="settings-v2-wsl-dialog"> <Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
<DialogHeader hideClose={true}> <DialogAddWslServer />
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody>
<DialogAddWslServer />
</DialogBody>
</Dialog> </Dialog>
)) ))
} }
+1 -1
View File
@@ -7,7 +7,7 @@ Private generation target for clients derived directly from OpenCode's authorita
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`. - `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`. - `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
The generated surface includes every standard HTTP group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Custom transports such as the PTY WebSocket connection remain outside the generic HTTP client. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift. The generated surface starts with the Session group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API. The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
+7 -14
View File
@@ -1,27 +1,20 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract" import { Api } from "@opencode-ai/server/api"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints }) const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), {
groupNames: { "server.session": "sessions" },
})
await Effect.runPromise( await Effect.runPromise(
Effect.all( Effect.all(
[ [
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
write( write(
emitPromise(contract, { emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }),
outputTypes: {
"events.subscribe": {
name: "OpenCodeEventEncoded",
import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"',
},
},
}),
fileURLToPath(new URL("../src/generated", import.meta.url)),
),
write(
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
fileURLToPath(new URL("../src/generated-effect", import.meta.url)), fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
), ),
], ],
+2 -36
View File
@@ -11,43 +11,9 @@ class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocatio
{ error: [InvalidRequestError, SessionNotFoundError] }, { error: [InvalidRequestError, SessionNotFoundError] },
) {} ) {}
export const ClientApi = makeDefaultApi({ const Api = makeDefaultApi({
locationMiddleware: LocationMiddleware, locationMiddleware: LocationMiddleware,
sessionLocationMiddleware: SessionLocationMiddleware, sessionLocationMiddleware: SessionLocationMiddleware,
}) })
export const groupNames = { export const SessionGroup = Api.groups["server.session"]
"server.health": "health",
"server.location": "location",
"server.agent": "agents",
"server.session": "sessions",
"server.message": "messages",
"server.model": "models",
"server.provider": "providers",
"server.integration": "integrations",
"server.credential": "credentials",
"server.permission": "permissions",
"server.fs": "files",
"server.command": "commands",
"server.skill": "skills",
"server.event": "events",
"server.pty": "ptys",
"server.question": "questions",
"server.reference": "references",
"server.projectCopy": "projectCopies",
} as const
export const endpointNames = {
"session.messages": "list",
"integration.connect.key": "connectKey",
"integration.connect.oauth": "connectOauth",
"integration.attempt.status": "attemptStatus",
"integration.attempt.complete": "attemptComplete",
"integration.attempt.cancel": "attemptCancel",
"permission.request.list": "listRequests",
"permission.saved.list": "listSaved",
"permission.saved.remove": "removeSaved",
"question.request.list": "listRequests",
} as const
export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
-13
View File
@@ -2,24 +2,11 @@
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations. // Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
export * from "./generated-effect/index" export * from "./generated-effect/index"
export { Agent } from "@opencode-ai/schema/agent" export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Credential } from "@opencode-ai/schema/credential"
export { FileSystem } from "@opencode-ai/schema/filesystem"
export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location" export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model" export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
export { Provider } from "@opencode-ai/schema/provider" export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
export { Reference } from "@opencode-ai/schema/reference"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema" export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session" export { Session } from "@opencode-ai/schema/session"
export { SessionInput } from "@opencode-ai/schema/session-input" export { SessionInput } from "@opencode-ai/schema/session-input"
export { SessionMessage } from "@opencode-ai/schema/session-message" export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt" export { Prompt } from "@opencode-ai/schema/prompt"
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
+102 -644
View File
@@ -1,706 +1,164 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit. // Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect, Stream, Schema } from "effect" import { Effect, Schema } from "effect"
import { Sse } from "effect/unstable/encoding" import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http" import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi" import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../contract" import { SessionGroup } from "../contract"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
type RawClient = HttpApiClient.ForApi<typeof ClientApi> const Api = HttpApi.make("generated").add(SessionGroup)
type RawClient = HttpApiClient.ForApi<typeof Api>
const mapClientError = <E>(error: E) => const mapClientError = <E>(error: E) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error }) ? new ClientError({ cause: error })
: error : error
const Endpoint0_0 = (raw: RawClient["server.health"]) => () => type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
raw["health.get"]({}).pipe(Effect.mapError(mapClientError)) type Endpoint0_0Input = {
readonly workspace?: Endpoint0_0Request["query"]["workspace"]
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) }) readonly limit?: Endpoint0_0Request["query"]["limit"]
readonly order?: Endpoint0_0Request["query"]["order"]
type Endpoint1_0Request = Parameters<RawClient["server.location"]["location.get"]>[0] readonly search?: Endpoint0_0Request["query"]["search"]
type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] } readonly directory?: Endpoint0_0Request["query"]["directory"]
const Endpoint1_0 = (raw: RawClient["server.location"]) => (input?: Endpoint1_0Input) => readonly project?: Endpoint0_0Request["query"]["project"]
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) readonly subpath?: Endpoint0_0Request["query"]["subpath"]
readonly cursor?: Endpoint0_0Request["query"]["cursor"]
const adaptGroup1 = (raw: RawClient["server.location"]) => ({ get: Endpoint1_0(raw) })
type Endpoint2_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] }
const Endpoint2_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint2_0Input) =>
raw["agent.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup2 = (raw: RawClient["server.agent"]) => ({ list: Endpoint2_0(raw) })
type Endpoint3_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
type Endpoint3_0Input = {
readonly workspace?: Endpoint3_0Request["query"]["workspace"]
readonly limit?: Endpoint3_0Request["query"]["limit"]
readonly order?: Endpoint3_0Request["query"]["order"]
readonly search?: Endpoint3_0Request["query"]["search"]
readonly directory?: Endpoint3_0Request["query"]["directory"]
readonly project?: Endpoint3_0Request["query"]["project"]
readonly subpath?: Endpoint3_0Request["query"]["subpath"]
readonly cursor?: Endpoint3_0Request["query"]["cursor"]
} }
const Endpoint3_0 = (raw: RawClient["server.session"]) => (input?: Endpoint3_0Input) => const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) =>
raw["session.list"]({ raw["session.list"]({
query: { query: {
workspace: input?.["workspace"], workspace: input?.workspace,
limit: input?.["limit"], limit: input?.limit,
order: input?.["order"], order: input?.order,
search: input?.["search"], search: input?.search,
directory: input?.["directory"], directory: input?.directory,
project: input?.["project"], project: input?.project,
subpath: input?.["subpath"], subpath: input?.subpath,
cursor: input?.["cursor"], cursor: input?.cursor,
}, },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint3_1Request = Parameters<RawClient["server.session"]["session.create"]>[0] type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
type Endpoint3_1Input = { type Endpoint0_1Input = {
readonly id?: Endpoint3_1Request["payload"]["id"] readonly id?: Endpoint0_1Request["payload"]["id"]
readonly agent?: Endpoint3_1Request["payload"]["agent"] readonly agent?: Endpoint0_1Request["payload"]["agent"]
readonly model?: Endpoint3_1Request["payload"]["model"] readonly model?: Endpoint0_1Request["payload"]["model"]
readonly location?: Endpoint3_1Request["payload"]["location"] readonly location?: Endpoint0_1Request["payload"]["location"]
} }
const Endpoint3_1 = (raw: RawClient["server.session"]) => (input?: Endpoint3_1Input) => const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) =>
raw["session.create"]({ raw["session.create"]({
payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] }, payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
}).pipe( }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
const Endpoint3_2 = (raw: RawClient["server.session"]) => () => type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0]
raw["session.active"]({}).pipe( type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] }
const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) =>
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint3_3Request = Parameters<RawClient["server.session"]["session.get"]>[0] type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] } type Endpoint0_3Input = {
const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Input) => readonly sessionID: Endpoint0_3Request["params"]["sessionID"]
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe( readonly agent: Endpoint0_3Request["payload"]["agent"]
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint3_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint3_4Input = {
readonly sessionID: Endpoint3_4Request["params"]["sessionID"]
readonly agent: Endpoint3_4Request["payload"]["agent"]
} }
const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) => const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint3_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0] type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint3_5Input = { type Endpoint0_4Input = {
readonly sessionID: Endpoint3_5Request["params"]["sessionID"] readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
readonly model: Endpoint3_5Request["payload"]["model"] readonly model: Endpoint0_4Request["payload"]["model"]
} }
const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) => const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe( raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0] type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint3_6Input = { type Endpoint0_5Input = {
readonly sessionID: Endpoint3_6Request["params"]["sessionID"] readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
readonly id?: Endpoint3_6Request["payload"]["id"] readonly id?: Endpoint0_5Request["payload"]["id"]
readonly prompt: Endpoint3_6Request["payload"]["prompt"] readonly prompt: Endpoint0_5Request["payload"]["prompt"]
readonly delivery?: Endpoint3_6Request["payload"]["delivery"] readonly delivery?: Endpoint0_5Request["payload"]["delivery"]
readonly resume?: Endpoint3_6Request["payload"]["resume"] readonly resume?: Endpoint0_5Request["payload"]["resume"]
} }
const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) => const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
raw["session.prompt"]({ raw["session.prompt"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input.sessionID },
payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
}).pipe( }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] } type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] }
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) => const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0] type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] } type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) => const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0] type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint3_9Input = { type Endpoint0_8Input = {
readonly sessionID: Endpoint3_9Request["params"]["sessionID"] readonly sessionID: Endpoint0_8Request["params"]["sessionID"]
readonly messageID: Endpoint3_9Request["payload"]["messageID"] readonly messageID: Endpoint0_8Request["payload"]["messageID"]
readonly files?: Endpoint3_9Request["payload"]["files"] readonly files?: Endpoint0_8Request["payload"]["files"]
} }
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) => const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
raw["session.revert.stage"]({ raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input.sessionID },
payload: { messageID: input["messageID"], files: input["files"] }, payload: { messageID: input.messageID, files: input.files },
}).pipe( }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] } type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] }
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) => const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0] type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] } type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) => 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.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.context"]>[0] type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] } type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) => const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.history"]>[0] const adaptGroup0 = (raw: RawClient["server.session"]) => ({
type Endpoint3_13Input = { list: Endpoint0_0(raw),
readonly sessionID: Endpoint3_13Request["params"]["sessionID"] create: Endpoint0_1(raw),
readonly limit?: Endpoint3_13Request["query"]["limit"] get: Endpoint0_2(raw),
readonly after?: Endpoint3_13Request["query"]["after"] switchAgent: Endpoint0_3(raw),
} switchModel: Endpoint0_4(raw),
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) => prompt: Endpoint0_5(raw),
raw["session.history"]({ compact: Endpoint0_6(raw),
params: { sessionID: input["sessionID"] }, wait: Endpoint0_7(raw),
query: { limit: input["limit"], after: input["after"] }, stage: Endpoint0_8(raw),
}).pipe(Effect.mapError(mapClientError)) clear: Endpoint0_9(raw),
commit: Endpoint0_10(raw),
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.events"]>[0] context: Endpoint0_11(raw),
type Endpoint3_14Input = {
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
readonly after?: Endpoint3_14Request["query"]["after"]
}
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
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 Endpoint3_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] }
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint3_16Input = {
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
readonly messageID: Endpoint3_16Request["params"]["messageID"]
}
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
const adaptGroup3 = (raw: RawClient["server.session"]) => ({
list: Endpoint3_0(raw),
create: Endpoint3_1(raw),
active: Endpoint3_2(raw),
get: Endpoint3_3(raw),
switchAgent: Endpoint3_4(raw),
switchModel: Endpoint3_5(raw),
prompt: Endpoint3_6(raw),
compact: Endpoint3_7(raw),
wait: Endpoint3_8(raw),
stage: Endpoint3_9(raw),
clear: Endpoint3_10(raw),
commit: Endpoint3_11(raw),
context: Endpoint3_12(raw),
history: Endpoint3_13(raw),
events: Endpoint3_14(raw),
interrupt: Endpoint3_15(raw),
message: Endpoint3_16(raw),
}) })
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0] const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
type Endpoint4_0Input = {
readonly sessionID: Endpoint4_0Request["params"]["sessionID"]
readonly limit?: Endpoint4_0Request["query"]["limit"]
readonly order?: Endpoint4_0Request["query"]["order"]
readonly cursor?: Endpoint4_0Request["query"]["cursor"]
}
const Endpoint4_0 = (raw: RawClient["server.message"]) => (input: Endpoint4_0Input) =>
raw["session.messages"]({
params: { sessionID: input["sessionID"] },
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup4 = (raw: RawClient["server.message"]) => ({ list: Endpoint4_0(raw) })
type Endpoint5_0Request = Parameters<RawClient["server.model"]["model.list"]>[0]
type Endpoint5_0Input = { readonly location?: Endpoint5_0Request["query"]["location"] }
const Endpoint5_0 = (raw: RawClient["server.model"]) => (input?: Endpoint5_0Input) =>
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup5 = (raw: RawClient["server.model"]) => ({ list: Endpoint5_0(raw) })
type Endpoint6_0Request = Parameters<RawClient["server.provider"]["provider.list"]>[0]
type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] }
const Endpoint6_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint6_0Input) =>
raw["provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint6_1Request = Parameters<RawClient["server.provider"]["provider.get"]>[0]
type Endpoint6_1Input = {
readonly providerID: Endpoint6_1Request["params"]["providerID"]
readonly location?: Endpoint6_1Request["query"]["location"]
}
const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1Input) =>
raw["provider.get"]({ params: { providerID: input["providerID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(raw), get: Endpoint6_1(raw) })
type Endpoint7_0Request = Parameters<RawClient["server.integration"]["integration.list"]>[0]
type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] }
const Endpoint7_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint7_0Input) =>
raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint7_1Request = Parameters<RawClient["server.integration"]["integration.get"]>[0]
type Endpoint7_1Input = {
readonly integrationID: Endpoint7_1Request["params"]["integrationID"]
readonly location?: Endpoint7_1Request["query"]["location"]
}
const Endpoint7_1 = (raw: RawClient["server.integration"]) => (input: Endpoint7_1Input) =>
raw["integration.get"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
type Endpoint7_2Input = {
readonly integrationID: Endpoint7_2Request["params"]["integrationID"]
readonly location?: Endpoint7_2Request["query"]["location"]
readonly key: Endpoint7_2Request["payload"]["key"]
readonly label?: Endpoint7_2Request["payload"]["label"]
}
const Endpoint7_2 = (raw: RawClient["server.integration"]) => (input: Endpoint7_2Input) =>
raw["integration.connect.key"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
payload: { key: input["key"], label: input["label"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
type Endpoint7_3Input = {
readonly integrationID: Endpoint7_3Request["params"]["integrationID"]
readonly location?: Endpoint7_3Request["query"]["location"]
readonly methodID: Endpoint7_3Request["payload"]["methodID"]
readonly inputs: Endpoint7_3Request["payload"]["inputs"]
readonly label?: Endpoint7_3Request["payload"]["label"]
}
const Endpoint7_3 = (raw: RawClient["server.integration"]) => (input: Endpoint7_3Input) =>
raw["integration.connect.oauth"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
type Endpoint7_4Input = {
readonly attemptID: Endpoint7_4Request["params"]["attemptID"]
readonly location?: Endpoint7_4Request["query"]["location"]
}
const Endpoint7_4 = (raw: RawClient["server.integration"]) => (input: Endpoint7_4Input) =>
raw["integration.attempt.status"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
type Endpoint7_5Input = {
readonly attemptID: Endpoint7_5Request["params"]["attemptID"]
readonly location?: Endpoint7_5Request["query"]["location"]
readonly code?: Endpoint7_5Request["payload"]["code"]
}
const Endpoint7_5 = (raw: RawClient["server.integration"]) => (input: Endpoint7_5Input) =>
raw["integration.attempt.complete"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
payload: { code: input["code"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
type Endpoint7_6Input = {
readonly attemptID: Endpoint7_6Request["params"]["attemptID"]
readonly location?: Endpoint7_6Request["query"]["location"]
}
const Endpoint7_6 = (raw: RawClient["server.integration"]) => (input: Endpoint7_6Input) =>
raw["integration.attempt.cancel"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup7 = (raw: RawClient["server.integration"]) => ({
list: Endpoint7_0(raw),
get: Endpoint7_1(raw),
connectKey: Endpoint7_2(raw),
connectOauth: Endpoint7_3(raw),
attemptStatus: Endpoint7_4(raw),
attemptComplete: Endpoint7_5(raw),
attemptCancel: Endpoint7_6(raw),
})
type Endpoint8_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint8_0Input = {
readonly credentialID: Endpoint8_0Request["params"]["credentialID"]
readonly location?: Endpoint8_0Request["query"]["location"]
readonly label: Endpoint8_0Request["payload"]["label"]
}
const Endpoint8_0 = (raw: RawClient["server.credential"]) => (input: Endpoint8_0Input) =>
raw["credential.update"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
payload: { label: input["label"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint8_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0]
type Endpoint8_1Input = {
readonly credentialID: Endpoint8_1Request["params"]["credentialID"]
readonly location?: Endpoint8_1Request["query"]["location"]
}
const Endpoint8_1 = (raw: RawClient["server.credential"]) => (input: Endpoint8_1Input) =>
raw["credential.remove"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup8 = (raw: RawClient["server.credential"]) => ({ update: Endpoint8_0(raw), remove: Endpoint8_1(raw) })
type Endpoint9_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] }
const Endpoint9_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_0Input) =>
raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint9_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
type Endpoint9_1Input = { readonly projectID?: Endpoint9_1Request["query"]["projectID"] }
const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_1Input) =>
raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
type Endpoint9_2Input = { readonly id: Endpoint9_2Request["params"]["id"] }
const Endpoint9_2 = (raw: RawClient["server.permission"]) => (input: Endpoint9_2Input) =>
raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint9_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
type Endpoint9_3Input = {
readonly sessionID: Endpoint9_3Request["params"]["sessionID"]
readonly id?: Endpoint9_3Request["payload"]["id"]
readonly action: Endpoint9_3Request["payload"]["action"]
readonly resources: Endpoint9_3Request["payload"]["resources"]
readonly save?: Endpoint9_3Request["payload"]["save"]
readonly metadata?: Endpoint9_3Request["payload"]["metadata"]
readonly source?: Endpoint9_3Request["payload"]["source"]
readonly agent?: Endpoint9_3Request["payload"]["agent"]
}
const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3Input) =>
raw["session.permission.create"]({
params: { sessionID: input["sessionID"] },
payload: {
id: input["id"],
action: input["action"],
resources: input["resources"],
save: input["save"],
metadata: input["metadata"],
source: input["source"],
agent: input["agent"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
type Endpoint9_4Input = { readonly sessionID: Endpoint9_4Request["params"]["sessionID"] }
const Endpoint9_4 = (raw: RawClient["server.permission"]) => (input: Endpoint9_4Input) =>
raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
type Endpoint9_5Input = {
readonly sessionID: Endpoint9_5Request["params"]["sessionID"]
readonly requestID: Endpoint9_5Request["params"]["requestID"]
}
const Endpoint9_5 = (raw: RawClient["server.permission"]) => (input: Endpoint9_5Input) =>
raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
type Endpoint9_6Input = {
readonly sessionID: Endpoint9_6Request["params"]["sessionID"]
readonly requestID: Endpoint9_6Request["params"]["requestID"]
readonly reply: Endpoint9_6Request["payload"]["reply"]
readonly message?: Endpoint9_6Request["payload"]["message"]
}
const Endpoint9_6 = (raw: RawClient["server.permission"]) => (input: Endpoint9_6Input) =>
raw["session.permission.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { reply: input["reply"], message: input["message"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup9 = (raw: RawClient["server.permission"]) => ({
listRequests: Endpoint9_0(raw),
listSaved: Endpoint9_1(raw),
removeSaved: Endpoint9_2(raw),
create: Endpoint9_3(raw),
list: Endpoint9_4(raw),
get: Endpoint9_5(raw),
reply: Endpoint9_6(raw),
})
type Endpoint10_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
type Endpoint10_0Input = {
readonly location?: Endpoint10_0Request["query"]["location"]
readonly path?: Endpoint10_0Request["query"]["path"]
}
const Endpoint10_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_0Input) =>
raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint10_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
type Endpoint10_1Input = {
readonly location?: Endpoint10_1Request["query"]["location"]
readonly query: Endpoint10_1Request["query"]["query"]
readonly type?: Endpoint10_1Request["query"]["type"]
readonly limit?: Endpoint10_1Request["query"]["limit"]
}
const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input: Endpoint10_1Input) =>
raw["fs.find"]({
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup10 = (raw: RawClient["server.fs"]) => ({ list: Endpoint10_0(raw), find: Endpoint10_1(raw) })
type Endpoint11_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
const Endpoint11_0 = (raw: RawClient["server.command"]) => (input?: Endpoint11_0Input) =>
raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup11 = (raw: RawClient["server.command"]) => ({ list: Endpoint11_0(raw) })
type Endpoint12_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
const Endpoint12_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint12_0Input) =>
raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup12 = (raw: RawClient["server.skill"]) => ({ list: Endpoint12_0(raw) })
const Endpoint13_0 = (raw: RawClient["server.event"]) => () =>
Stream.unwrap(
raw["event.subscribe"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
),
)
const adaptGroup13 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint13_0(raw) })
type Endpoint14_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
const Endpoint14_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_0Input) =>
raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint14_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
type Endpoint14_1Input = {
readonly location?: Endpoint14_1Request["query"]["location"]
readonly command?: Endpoint14_1Request["payload"]["command"]
readonly args?: Endpoint14_1Request["payload"]["args"]
readonly cwd?: Endpoint14_1Request["payload"]["cwd"]
readonly title?: Endpoint14_1Request["payload"]["title"]
readonly env?: Endpoint14_1Request["payload"]["env"]
}
const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) =>
raw["pty.create"]({
query: { location: input?.["location"] },
payload: {
command: input?.["command"],
args: input?.["args"],
cwd: input?.["cwd"],
title: input?.["title"],
env: input?.["env"],
},
}).pipe(Effect.mapError(mapClientError))
type Endpoint14_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
type Endpoint14_2Input = {
readonly ptyID: Endpoint14_2Request["params"]["ptyID"]
readonly location?: Endpoint14_2Request["query"]["location"]
}
const Endpoint14_2 = (raw: RawClient["server.pty"]) => (input: Endpoint14_2Input) =>
raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint14_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
type Endpoint14_3Input = {
readonly ptyID: Endpoint14_3Request["params"]["ptyID"]
readonly location?: Endpoint14_3Request["query"]["location"]
readonly title?: Endpoint14_3Request["payload"]["title"]
readonly size?: Endpoint14_3Request["payload"]["size"]
}
const Endpoint14_3 = (raw: RawClient["server.pty"]) => (input: Endpoint14_3Input) =>
raw["pty.update"]({
params: { ptyID: input["ptyID"] },
query: { location: input["location"] },
payload: { title: input["title"], size: input["size"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint14_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
type Endpoint14_4Input = {
readonly ptyID: Endpoint14_4Request["params"]["ptyID"]
readonly location?: Endpoint14_4Request["query"]["location"]
}
const Endpoint14_4 = (raw: RawClient["server.pty"]) => (input: Endpoint14_4Input) =>
raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup14 = (raw: RawClient["server.pty"]) => ({
list: Endpoint14_0(raw),
create: Endpoint14_1(raw),
get: Endpoint14_2(raw),
update: Endpoint14_3(raw),
remove: Endpoint14_4(raw),
})
type Endpoint15_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
const Endpoint15_0 = (raw: RawClient["server.question"]) => (input?: Endpoint15_0Input) =>
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint15_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint15_1Input = { readonly sessionID: Endpoint15_1Request["params"]["sessionID"] }
const Endpoint15_1 = (raw: RawClient["server.question"]) => (input: Endpoint15_1Input) =>
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint15_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
type Endpoint15_2Input = {
readonly sessionID: Endpoint15_2Request["params"]["sessionID"]
readonly requestID: Endpoint15_2Request["params"]["requestID"]
readonly answers: Endpoint15_2Request["payload"]["answers"]
}
const Endpoint15_2 = (raw: RawClient["server.question"]) => (input: Endpoint15_2Input) =>
raw["session.question.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { answers: input["answers"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint15_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
type Endpoint15_3Input = {
readonly sessionID: Endpoint15_3Request["params"]["sessionID"]
readonly requestID: Endpoint15_3Request["params"]["requestID"]
}
const Endpoint15_3 = (raw: RawClient["server.question"]) => (input: Endpoint15_3Input) =>
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup15 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint15_0(raw),
list: Endpoint15_1(raw),
reply: Endpoint15_2(raw),
reject: Endpoint15_3(raw),
})
type Endpoint16_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
const Endpoint16_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint16_0Input) =>
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup16 = (raw: RawClient["server.reference"]) => ({ list: Endpoint16_0(raw) })
type Endpoint17_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
type Endpoint17_0Input = {
readonly projectID: Endpoint17_0Request["params"]["projectID"]
readonly location?: Endpoint17_0Request["query"]["location"]
readonly strategy: Endpoint17_0Request["payload"]["strategy"]
readonly directory: Endpoint17_0Request["payload"]["directory"]
readonly name?: Endpoint17_0Request["payload"]["name"]
}
const Endpoint17_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_0Input) =>
raw["projectCopy.create"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
type Endpoint17_1Input = {
readonly projectID: Endpoint17_1Request["params"]["projectID"]
readonly location?: Endpoint17_1Request["query"]["location"]
readonly directory: Endpoint17_1Request["payload"]["directory"]
readonly force: Endpoint17_1Request["payload"]["force"]
}
const Endpoint17_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_1Input) =>
raw["projectCopy.remove"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
type Endpoint17_2Input = {
readonly projectID: Endpoint17_2Request["params"]["projectID"]
readonly location?: Endpoint17_2Request["query"]["location"]
}
const Endpoint17_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_2Input) =>
raw["projectCopy.refresh"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint17_0(raw),
remove: Endpoint17_1(raw),
refresh: Endpoint17_2(raw),
})
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
location: adaptGroup1(raw["server.location"]),
agents: adaptGroup2(raw["server.agent"]),
sessions: adaptGroup3(raw["server.session"]),
messages: adaptGroup4(raw["server.message"]),
models: adaptGroup5(raw["server.model"]),
providers: adaptGroup6(raw["server.provider"]),
integrations: adaptGroup7(raw["server.integration"]),
credentials: adaptGroup8(raw["server.credential"]),
permissions: adaptGroup9(raw["server.permission"]),
files: adaptGroup10(raw["server.fs"]),
commands: adaptGroup11(raw["server.command"]),
skills: adaptGroup12(raw["server.skill"]),
events: adaptGroup13(raw["server.event"]),
ptys: adaptGroup14(raw["server.pty"]),
questions: adaptGroup15(raw["server.question"]),
references: adaptGroup16(raw["server.reference"]),
projectCopies: adaptGroup17(raw["server.projectCopy"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) => export const make = (options?: { readonly baseUrl?: URL | string }) =>
HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient)) HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
+13 -693
View File
@@ -1,14 +1,8 @@
import type { import type {
HealthGetOutput,
LocationGetInput,
LocationGetOutput,
AgentsListInput,
AgentsListOutput,
SessionsListInput, SessionsListInput,
SessionsListOutput, SessionsListOutput,
SessionsCreateInput, SessionsCreateInput,
SessionsCreateOutput, SessionsCreateOutput,
SessionsActiveOutput,
SessionsGetInput, SessionsGetInput,
SessionsGetOutput, SessionsGetOutput,
SessionsSwitchAgentInput, SessionsSwitchAgentInput,
@@ -29,89 +23,6 @@ import type {
SessionsCommitOutput, SessionsCommitOutput,
SessionsContextInput, SessionsContextInput,
SessionsContextOutput, SessionsContextOutput,
SessionsHistoryInput,
SessionsHistoryOutput,
SessionsEventsInput,
SessionsEventsOutput,
SessionsInterruptInput,
SessionsInterruptOutput,
SessionsMessageInput,
SessionsMessageOutput,
MessagesListInput,
MessagesListOutput,
ModelsListInput,
ModelsListOutput,
ProvidersListInput,
ProvidersListOutput,
ProvidersGetInput,
ProvidersGetOutput,
IntegrationsListInput,
IntegrationsListOutput,
IntegrationsGetInput,
IntegrationsGetOutput,
IntegrationsConnectKeyInput,
IntegrationsConnectKeyOutput,
IntegrationsConnectOauthInput,
IntegrationsConnectOauthOutput,
IntegrationsAttemptStatusInput,
IntegrationsAttemptStatusOutput,
IntegrationsAttemptCompleteInput,
IntegrationsAttemptCompleteOutput,
IntegrationsAttemptCancelInput,
IntegrationsAttemptCancelOutput,
CredentialsUpdateInput,
CredentialsUpdateOutput,
CredentialsRemoveInput,
CredentialsRemoveOutput,
PermissionsListRequestsInput,
PermissionsListRequestsOutput,
PermissionsListSavedInput,
PermissionsListSavedOutput,
PermissionsRemoveSavedInput,
PermissionsRemoveSavedOutput,
PermissionsCreateInput,
PermissionsCreateOutput,
PermissionsListInput,
PermissionsListOutput,
PermissionsGetInput,
PermissionsGetOutput,
PermissionsReplyInput,
PermissionsReplyOutput,
FilesListInput,
FilesListOutput,
FilesFindInput,
FilesFindOutput,
CommandsListInput,
CommandsListOutput,
SkillsListInput,
SkillsListOutput,
EventsSubscribeOutput,
PtysListInput,
PtysListOutput,
PtysCreateInput,
PtysCreateOutput,
PtysGetInput,
PtysGetOutput,
PtysUpdateInput,
PtysUpdateOutput,
PtysRemoveInput,
PtysRemoveOutput,
QuestionsListRequestsInput,
QuestionsListRequestsOutput,
QuestionsListInput,
QuestionsListOutput,
QuestionsReplyInput,
QuestionsReplyOutput,
QuestionsRejectInput,
QuestionsRejectOutput,
ReferencesListInput,
ReferencesListOutput,
ProjectCopiesCreateInput,
ProjectCopiesCreateOutput,
ProjectCopiesRemoveInput,
ProjectCopiesRemoveOutput,
ProjectCopiesRefreshInput,
ProjectCopiesRefreshOutput,
} from "./types" } from "./types"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
@@ -247,41 +158,6 @@ export function make(options: ClientOptions) {
}) })
return { return {
health: {
get: (requestOptions?: RequestOptions) =>
request<HealthGetOutput>(
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
},
location: {
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
request<LocationGetOutput>(
{
method: "GET",
path: `/api/location`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
agents: {
list: (input?: AgentsListInput, requestOptions?: RequestOptions) =>
request<AgentsListOutput>(
{
method: "GET",
path: `/api/agent`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
sessions: { sessions: {
list: (input?: SessionsListInput, requestOptions?: RequestOptions) => list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
request<SessionsListOutput>( request<SessionsListOutput>(
@@ -289,14 +165,14 @@ export function make(options: ClientOptions) {
method: "GET", method: "GET",
path: `/api/session`, path: `/api/session`,
query: { query: {
workspace: input?.["workspace"], workspace: input?.workspace,
limit: input?.["limit"], limit: input?.limit,
order: input?.["order"], order: input?.order,
search: input?.["search"], search: input?.search,
directory: input?.["directory"], directory: input?.directory,
project: input?.["project"], project: input?.project,
subpath: input?.["subpath"], subpath: input?.subpath,
cursor: input?.["cursor"], cursor: input?.cursor,
}, },
successStatus: 200, successStatus: 200,
declaredStatuses: [400, 401], declaredStatuses: [400, 401],
@@ -309,23 +185,7 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session`, path: `/api/session`,
body: { body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
id: input?.["id"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
},
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
active: (requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsActiveOutput }>(
{
method: "GET",
path: `/api/session/active`,
successStatus: 200, successStatus: 200,
declaredStatuses: [401, 400], declaredStatuses: [401, 400],
empty: false, empty: false,
@@ -348,7 +208,7 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`, path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
body: { agent: input["agent"] }, body: { agent: input.agent },
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
@@ -360,7 +220,7 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`, path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
body: { model: input["model"] }, body: { model: input.model },
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
@@ -372,7 +232,7 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`, path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
successStatus: 200, successStatus: 200,
declaredStatuses: [409, 404, 400, 401], declaredStatuses: [409, 404, 400, 401],
empty: false, empty: false,
@@ -406,7 +266,7 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
body: { messageID: input["messageID"], files: input["files"] }, body: { messageID: input.messageID, files: input.files },
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 500, 400, 401], declaredStatuses: [404, 500, 400, 401],
empty: false, empty: false,
@@ -446,546 +306,6 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) =>
request<SessionsHistoryOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
query: { limit: input["limit"], after: input["after"] },
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
),
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),
},
messages: {
list: (input: MessagesListInput, requestOptions?: RequestOptions) =>
request<MessagesListOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
successStatus: 200,
declaredStatuses: [400, 404, 500, 401],
empty: false,
},
requestOptions,
),
},
models: {
list: (input?: ModelsListInput, requestOptions?: RequestOptions) =>
request<ModelsListOutput>(
{
method: "GET",
path: `/api/model`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
},
providers: {
list: (input?: ProvidersListInput, requestOptions?: RequestOptions) =>
request<ProvidersListOutput>(
{
method: "GET",
path: `/api/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
get: (input: ProvidersGetInput, requestOptions?: RequestOptions) =>
request<ProvidersGetOutput>(
{
method: "GET",
path: `/api/provider/${encodeURIComponent(input.providerID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
),
},
integrations: {
list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) =>
request<IntegrationsListOutput>(
{
method: "GET",
path: `/api/integration`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
get: (input: IntegrationsGetInput, requestOptions?: RequestOptions) =>
request<IntegrationsGetOutput>(
{
method: "GET",
path: `/api/integration/${encodeURIComponent(input.integrationID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
connectKey: (input: IntegrationsConnectKeyInput, requestOptions?: RequestOptions) =>
request<IntegrationsConnectKeyOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
query: { location: input["location"] },
body: { key: input["key"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
connectOauth: (input: IntegrationsConnectOauthInput, requestOptions?: RequestOptions) =>
request<IntegrationsConnectOauthOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
query: { location: input["location"] },
body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
attemptStatus: (input: IntegrationsAttemptStatusInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptStatusOutput>(
{
method: "GET",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
attemptComplete: (input: IntegrationsAttemptCompleteInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptCompleteOutput>(
{
method: "POST",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
query: { location: input["location"] },
body: { code: input["code"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
attemptCancel: (input: IntegrationsAttemptCancelInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptCancelOutput>(
{
method: "DELETE",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
},
credentials: {
update: (input: CredentialsUpdateInput, requestOptions?: RequestOptions) =>
request<CredentialsUpdateOutput>(
{
method: "PATCH",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input["location"] },
body: { label: input["label"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
remove: (input: CredentialsRemoveInput, requestOptions?: RequestOptions) =>
request<CredentialsRemoveOutput>(
{
method: "DELETE",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
},
permissions: {
listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) =>
request<PermissionsListRequestsOutput>(
{
method: "GET",
path: `/api/permission/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
listSaved: (input?: PermissionsListSavedInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsListSavedOutput }>(
{
method: "GET",
path: `/api/permission/saved`,
query: { projectID: input?.["projectID"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
removeSaved: (input: PermissionsRemoveSavedInput, requestOptions?: RequestOptions) =>
request<PermissionsRemoveSavedOutput>(
{
method: "DELETE",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
create: (input: PermissionsCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
body: {
id: input["id"],
action: input["action"],
resources: input["resources"],
save: input["save"],
metadata: input["metadata"],
source: input["source"],
agent: input["agent"],
},
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
list: (input: PermissionsListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: PermissionsGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: PermissionsReplyInput, requestOptions?: RequestOptions) =>
request<PermissionsReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
body: { reply: input["reply"], message: input["message"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
},
files: {
list: (input?: FilesListInput, requestOptions?: RequestOptions) =>
request<FilesListOutput>(
{
method: "GET",
path: `/api/fs/list`,
query: { location: input?.["location"], path: input?.["path"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
find: (input: FilesFindInput, requestOptions?: RequestOptions) =>
request<FilesFindOutput>(
{
method: "GET",
path: `/api/fs/find`,
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
commands: {
list: (input?: CommandsListInput, requestOptions?: RequestOptions) =>
request<CommandsListOutput>(
{
method: "GET",
path: `/api/command`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
skills: {
list: (input?: SkillsListInput, requestOptions?: RequestOptions) =>
request<SkillsListOutput>(
{
method: "GET",
path: `/api/skill`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
events: {
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> =>
sse<EventsSubscribeOutput>(
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
},
ptys: {
list: (input?: PtysListInput, requestOptions?: RequestOptions) =>
request<PtysListOutput>(
{
method: "GET",
path: `/api/pty`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
create: (input?: PtysCreateInput, requestOptions?: RequestOptions) =>
request<PtysCreateOutput>(
{
method: "POST",
path: `/api/pty`,
query: { location: input?.["location"] },
body: {
command: input?.["command"],
args: input?.["args"],
cwd: input?.["cwd"],
title: input?.["title"],
env: input?.["env"],
},
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
get: (input: PtysGetInput, requestOptions?: RequestOptions) =>
request<PtysGetOutput>(
{
method: "GET",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
update: (input: PtysUpdateInput, requestOptions?: RequestOptions) =>
request<PtysUpdateOutput>(
{
method: "PUT",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
body: { title: input["title"], size: input["size"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
remove: (input: PtysRemoveInput, requestOptions?: RequestOptions) =>
request<PtysRemoveOutput>(
{
method: "DELETE",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
},
questions: {
listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) =>
request<QuestionsListRequestsOutput>(
{
method: "GET",
path: `/api/question/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
list: (input: QuestionsListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: QuestionsListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: QuestionsReplyInput, requestOptions?: RequestOptions) =>
request<QuestionsReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
body: { answers: input["answers"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
reject: (input: QuestionsRejectInput, requestOptions?: RequestOptions) =>
request<QuestionsRejectOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
},
references: {
list: (input?: ReferencesListInput, requestOptions?: RequestOptions) =>
request<ReferencesListOutput>(
{
method: "GET",
path: `/api/reference`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
projectCopies: {
create: (input: ProjectCopiesCreateInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesCreateOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
remove: (input: ProjectCopiesRemoveInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesRemoveOutput>(
{
method: "DELETE",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
refresh: (input: ProjectCopiesRefreshInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesRefreshOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
}, },
} }
} }
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,2 +1 @@
export * from "./generated/index" export * from "./generated/index"
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
-10
View File
@@ -1,10 +0,0 @@
/* 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 {}
@@ -19,7 +19,8 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace" import { Workspace } from "@opencode-ai/schema/workspace"
import { Api } from "@opencode-ai/server/api" import { Api } from "@opencode-ai/server/api"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract" import { HttpApi } from "effect/unstable/httpapi"
import { SessionGroup } from "../src/contract"
test("Core and Server reuse the authoritative Schema and Protocol values", () => { test("Core and Server reuse the authoritative Schema and Protocol values", () => {
expect(AgentV2.ID).toBe(Agent.ID) expect(AgentV2.ID).toBe(Agent.ID)
@@ -30,16 +31,17 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
expect(CorePrompt).toBe(Prompt) expect(CorePrompt).toBe(Prompt)
expect(Api.groups["server.session"].identifier).toBe("server.session") expect(Api.groups["server.session"].identifier).toBe("server.session")
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups)) expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
expect(Session.ID.create()).toStartWith("ses_") expect(Session.ID.create()).toStartWith("ses_")
expect(Project.ID.global).toBe("global") expect(Project.ID.global).toBe("global")
expect(Provider.ID.anthropic).toBe("anthropic") expect(Provider.ID.anthropic).toBe("anthropic")
expect(Workspace.ID.create()).toStartWith("wrk_") expect(Workspace.ID.create()).toStartWith("wrk_")
}) })
test("client and Server contracts generate identically", () => { test("client and Server Session contracts generate identically", () => {
const server = compile(Api, { groupNames, endpointNames, omitEndpoints }) const options = { groupNames: { "server.session": "sessions" } }
const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints }) const server = compile(HttpApi.make("server").add(Api.groups["server.session"]), options)
const client = compile(HttpApi.make("client").add(SessionGroup), options)
expect(emitPromise(client)).toEqual(emitPromise(server)) expect(emitPromise(client)).toEqual(emitPromise(server))
}) })
+3 -151
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { DateTime, Effect, Stream } from "effect" import { DateTime, Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect" import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session } from "../src/effect"
test("sessions.get returns the decoded Effect projection", async () => { test("sessions.get returns the decoded Effect projection", async () => {
const httpClient = HttpClient.make((request) => const httpClient = HttpClient.make((request) =>
@@ -15,91 +15,15 @@ test("sessions.get returns the decoded Effect projection", async () => {
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
}) })
test("events.subscribe exposes and decodes the native Effect event stream", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
),
),
)
const events = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.events.subscribe().pipe(Stream.runCollect)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
const durable = events[1]
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
})
test("events.subscribe terminates on Effect protocol decode failures", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(`data: {"type":"server.connected"}\n\n`, {
headers: { "content-type": "text/event-stream" },
}),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error._tag).toBe("ClientError")
})
test("session methods retain decoded Effect inputs and outputs", async () => { test("session methods retain decoded Effect inputs and outputs", async () => {
const historyQueries: Array<Record<string, string>> = []
let historyPage = 0
const httpClient = HttpClient.make((request) => { const httpClient = HttpClient.make((request) => {
const url = request.url 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("/history")) {
historyPage++
historyQueries.push(Object.fromEntries(request.urlParams.params))
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
),
),
)
}
if (url.includes("/prompt")) { if (url.includes("/prompt")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
} }
if (url.includes("/context")) { if (url.includes("/context")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] }))) 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")) { if (request.method === "POST" && url.endsWith("/api/session")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))) return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
} }
@@ -113,7 +37,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const result = await Effect.gen(function* () { const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const page = yield* client.sessions.list({ limit: 10 }) const page = yield* client.sessions.list({ limit: 10 })
const active = yield* client.sessions.active()
const created = yield* client.sessions.create({ const created = yield* client.sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }), location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
}) })
@@ -130,31 +53,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") }) yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
yield* client.sessions.wait({ 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") }) const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
const history = yield* client.sessions.history({ return { page, created, admitted, context }
sessionID: Session.ID.make("ses_test"),
after: 0,
limit: 1,
})
const historyNext = history.hasMore
? yield* client.sessions.history({
sessionID: Session.ID.make("ses_test"),
after: history.data.at(-1)?.durable?.seq,
limit: 2,
})
: undefined
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, history, historyNext, events, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) 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.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype) expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test") expect(result.created.id).toBe("ses_test")
@@ -162,37 +64,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([]) expect(result.context).toEqual([])
expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
expect(result.historyNext).toEqual({ data: [], hasMore: false })
expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
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" }))
})
test("sessions.history retains the typed SessionNotFoundError", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
{ status: 404 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.sessions
.history({
sessionID: Session.ID.make("ses_missing"),
})
.pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error._tag).toBe("SessionNotFoundError")
}) })
const session = { const session = {
@@ -225,22 +96,3 @@ const admission = {
timeCreated: 1_717_171_717_000, 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" },
},
}
+3 -141
View File
@@ -1,42 +1,5 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src" import { isUnauthorizedError, OpenCode } from "../src"
test("exposes every standard HTTP API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
expect(Object.keys(client)).toEqual([
"health",
"location",
"agents",
"sessions",
"messages",
"models",
"providers",
"integrations",
"credentials",
"permissions",
"files",
"commands",
"skills",
"events",
"ptys",
"questions",
"references",
"projectCopies",
])
expect(Object.keys(client.messages)).toEqual(["list"])
expect(Object.keys(client.integrations)).toEqual([
"list",
"get",
"connectKey",
"connectOauth",
"attemptStatus",
"attemptComplete",
"attemptCancel",
])
expect(Object.keys(client.files)).toEqual(["list", "find"])
expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"])
})
test("sessions.get returns the wire projection", async () => { test("sessions.get returns the wire projection", async () => {
const client = OpenCode.make({ const client = OpenCode.make({
@@ -54,66 +17,22 @@ test("sessions.get returns the wire projection", async () => {
expect(result.time.created).toBe(1_717_171_717_000) expect(result.time.created).toBe(1_717_171_717_000)
}) })
test("events.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
})
const events = []
for await (const event of client.events.subscribe()) events.push(event)
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
})
test("events.subscribe terminates on malformed Promise SSE data", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
})
await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
name: "ClientError",
reason: "MalformedResponse",
})
})
test("session methods use the public HTTP contract", async () => { test("session methods use the public HTTP contract", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [] const requests: Array<{ url: string; init?: RequestInit }> = []
let historyPage = 0
const client = OpenCode.make({ const client = OpenCode.make({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
fetch: async (input, init) => { fetch: async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
requests.push({ url, init }) 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("/history")) {
historyPage++
return Response.json(
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
)
}
if (url.includes("/prompt")) return Response.json(admission) if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/context")) return Response.json({ data: [] }) 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" && url.endsWith("/api/session")) return Response.json(session)
if (init?.method === "POST") return new Response(null, { status: 204 }) if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } }) return Response.json({ data: [session.data], cursor: { next: "next" } })
}, },
}) })
const page = await client.sessions.list({ limit: 10, order: "desc" }) 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" } }) const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" }) await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.sessions.switchModel({ await client.sessions.switchModel({
@@ -128,28 +47,13 @@ test("session methods use the public HTTP contract", async () => {
await client.sessions.compact({ sessionID: "ses_test" }) await client.sessions.compact({ sessionID: "ses_test" })
await client.sessions.wait({ sessionID: "ses_test" }) await client.sessions.wait({ sessionID: "ses_test" })
const context = await client.sessions.context({ sessionID: "ses_test" }) const context = await client.sessions.context({ sessionID: "ses_test" })
const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
const historyAfter = history.data.at(-1)?.durable?.seq
const historyNext = history.hasMore
? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
: undefined
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(page.cursor.next).toBe("next")
expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test") expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test") expect(admitted.id).toBe("msg_test")
expect(context).toEqual([]) expect(context).toEqual([])
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
expect(historyNext).toEqual({ data: [], hasMore: false })
expect(events).toEqual([modelSwitchedEvent])
expect(message).toEqual(modelSwitchedMessage)
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ 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?limit=10&order=desc"],
["GET", "http://localhost:3000/api/session/active"],
["POST", "http://localhost:3000/api/session"], ["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/agent"], ["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"], ["POST", "http://localhost:3000/api/session/ses_test/model"],
@@ -157,13 +61,8 @@ 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/compact"],
["POST", "http://localhost:3000/api/session/ses_test/wait"], ["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/context"],
["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"],
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
["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.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body const body = requests[4]?.init?.body
if (typeof body !== "string") throw new Error("Expected JSON request body") if (typeof body !== "string") throw new Error("Expected JSON request body")
expect(JSON.parse(body)).toEqual({ expect(JSON.parse(body)).toEqual({
prompt: { text: "Hello" }, prompt: { text: "Hello" },
@@ -186,24 +85,6 @@ test("middleware errors remain declared client errors", async () => {
} }
}) })
test("sessions.history decodes SessionNotFoundError", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json(
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
{ status: 404 },
),
})
try {
await client.sessions.history({ sessionID: "ses_missing" })
throw new Error("Expected request to fail")
} catch (error) {
expect(isSessionNotFoundError(error)).toBe(true)
}
})
const session = { const session = {
data: { data: {
id: "ses_test", id: "ses_test",
@@ -234,22 +115,3 @@ const admission = {
timeCreated: 1_717_171_717_000, 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" },
},
}
-5
View File
@@ -369,8 +369,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}", "لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
"zen.api.error.modelDisabled": "النموذج معطل", "zen.api.error.modelDisabled": "النموذج معطل",
"zen.api.error.regionNotAllowed":
"هذا النموذج مستضاف في الصين. إذا كنت ترغب في استخدام هذا النموذج، فعّله في إعداداتك: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}", "انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}",
@@ -648,9 +646,6 @@ export const dict = {
"workspace.lite.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام", "workspace.lite.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'اختر "OpenCode Go" كمزود في إعدادات opencode الخاصة بك لاستخدام نماذج Go.', 'اختر "OpenCode Go" كمزود في إعدادات opencode الخاصة بك لاستخدام نماذج Go.',
"workspace.lite.providers.title": "المزودون",
"workspace.lite.providers.description": "تحكم في المزودين المستخدمين للتوجيه.",
"workspace.lite.providers.useChina": "تفعيل النماذج المستضافة في الصين",
"workspace.lite.black.message": "workspace.lite.black.message":
"أنت مشترك حاليًا في OpenCode Black أو في قائمة الانتظار. يرجى إلغاء الاشتراك أولاً إذا كنت ترغب في التبديل إلى Go.", "أنت مشترك حاليًا في OpenCode Black أو في قائمة الانتظار. يرجى إلغاء الاشتراك أولاً إذا كنت ترغب في التبديل إلى Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
-5
View File
@@ -377,8 +377,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}", "Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
"zen.api.error.modelDisabled": "O modelo está desabilitado", "zen.api.error.modelDisabled": "O modelo está desabilitado",
"zen.api.error.regionNotAllowed":
"Este modelo está hospedado na China. Se você quiser usar este modelo, ative-o nas suas configurações: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}", "A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}",
@@ -658,9 +656,6 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Use seu saldo disponível após atingir os limites de uso", "workspace.lite.subscription.useBalance": "Use seu saldo disponível após atingir os limites de uso",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Selecione "OpenCode Go" como provedor na sua configuração do opencode para usar os modelos Go.', 'Selecione "OpenCode Go" como provedor na sua configuração do opencode para usar os modelos Go.',
"workspace.lite.providers.title": "Provedores",
"workspace.lite.providers.description": "Controle quais provedores são usados para roteamento.",
"workspace.lite.providers.useChina": "Ativar modelos hospedados na China",
"workspace.lite.black.message": "workspace.lite.black.message":
"Você está atualmente inscrito no OpenCode Black ou na lista de espera. Por favor, cancele a assinatura primeiro se desejar mudar para o Go.", "Você está atualmente inscrito no OpenCode Black ou na lista de espera. Por favor, cancele a assinatura primeiro se desejar mudar para o Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
-5
View File
@@ -373,8 +373,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}", "Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modellen er deaktiveret", "zen.api.error.modelDisabled": "Modellen er deaktiveret",
"zen.api.error.regionNotAllowed":
"Denne model hostes i Kina. Hvis du vil bruge denne model, skal du aktivere den i dine indstillinger: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}", "Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}",
@@ -654,9 +652,6 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Brug din tilgængelige saldo, når du har nået forbrugsgrænserne", "workspace.lite.subscription.useBalance": "Brug din tilgængelige saldo, når du har nået forbrugsgrænserne",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Vælg "OpenCode Go" som udbyder i din opencode-konfiguration for at bruge Go-modeller.', 'Vælg "OpenCode Go" som udbyder i din opencode-konfiguration for at bruge Go-modeller.',
"workspace.lite.providers.title": "Udbydere",
"workspace.lite.providers.description": "Styr, hvilke udbydere der bruges til routing.",
"workspace.lite.providers.useChina": "Aktivér modeller hostet i Kina",
"workspace.lite.black.message": "workspace.lite.black.message":
"Du abonnerer i øjeblikket på OpenCode Black eller er på venteliste. Afmeld venligst først, hvis du vil skifte til Go.", "Du abonnerer i øjeblikket på OpenCode Black eller er på venteliste. Afmeld venligst først, hvis du vil skifte til Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
-5
View File
@@ -376,8 +376,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}", "Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modell ist deaktiviert", "zen.api.error.modelDisabled": "Modell ist deaktiviert",
"zen.api.error.regionNotAllowed":
"Dieses Modell wird in China gehostet. Wenn du dieses Modell verwenden möchtest, aktiviere es in deinen Einstellungen: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}", "Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}",
@@ -657,9 +655,6 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Nutze dein verfügbares Guthaben, nachdem die Nutzungslimits erreicht sind", "workspace.lite.subscription.useBalance": "Nutze dein verfügbares Guthaben, nachdem die Nutzungslimits erreicht sind",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Wähle "OpenCode Go" als Anbieter in deiner opencode-Konfiguration, um Go-Modelle zu verwenden.', 'Wähle "OpenCode Go" als Anbieter in deiner opencode-Konfiguration, um Go-Modelle zu verwenden.',
"workspace.lite.providers.title": "Anbieter",
"workspace.lite.providers.description": "Steuere, welche Anbieter für das Routing verwendet werden.",
"workspace.lite.providers.useChina": "In China gehostete Modelle aktivieren",
"workspace.lite.black.message": "workspace.lite.black.message":
"Du hast derzeit OpenCode Black abonniert oder stehst auf der Warteliste. Bitte kündige zuerst, wenn du zu Go wechseln möchtest.", "Du hast derzeit OpenCode Black abonniert oder stehst auf der Warteliste. Bitte kündige zuerst, wenn du zu Go wechseln möchtest.",
"workspace.lite.other.message": "workspace.lite.other.message":
-5
View File
@@ -370,8 +370,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}", "You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model is disabled", "zen.api.error.modelDisabled": "Model is disabled",
"zen.api.error.regionNotAllowed":
"This model is hosted in China. If you would like to use this model, enable it in your settings: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}", "Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}",
@@ -651,9 +649,6 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Use your available balance after reaching the usage limits", "workspace.lite.subscription.useBalance": "Use your available balance after reaching the usage limits",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Select "OpenCode Go" as the provider in your opencode configuration to use Go models.', 'Select "OpenCode Go" as the provider in your opencode configuration to use Go models.',
"workspace.lite.providers.title": "Providers",
"workspace.lite.providers.description": "Control which providers are used for routing.",
"workspace.lite.providers.useChina": "Enable models hosted in China",
"workspace.lite.black.message": "workspace.lite.black.message":
"You're currently subscribed to OpenCode Black or on the waitlist. Please unsubscribe first if you'd like to switch to Go.", "You're currently subscribed to OpenCode Black or on the waitlist. Please unsubscribe first if you'd like to switch to Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
-5
View File
@@ -377,8 +377,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}", "Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
"zen.api.error.modelDisabled": "El modelo está deshabilitado", "zen.api.error.modelDisabled": "El modelo está deshabilitado",
"zen.api.error.regionNotAllowed":
"Este modelo está alojado en China. Si quieres usar este modelo, actívalo en tu configuración: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}", "La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}",
@@ -658,9 +656,6 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Usa tu saldo disponible después de alcanzar los límites de uso", "workspace.lite.subscription.useBalance": "Usa tu saldo disponible después de alcanzar los límites de uso",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Selecciona "OpenCode Go" como proveedor en tu configuración de opencode para usar los modelos Go.', 'Selecciona "OpenCode Go" como proveedor en tu configuración de opencode para usar los modelos Go.',
"workspace.lite.providers.title": "Proveedores",
"workspace.lite.providers.description": "Controla qué proveedores se usan para el enrutamiento.",
"workspace.lite.providers.useChina": "Activar modelos alojados en China",
"workspace.lite.black.message": "workspace.lite.black.message":
"Actualmente estás suscrito a OpenCode Black o estás en la lista de espera. Por favor, cancela la suscripción primero si deseas cambiar a Go.", "Actualmente estás suscrito a OpenCode Black o estás en la lista de espera. Por favor, cancela la suscripción primero si deseas cambiar a Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
-5
View File
@@ -377,8 +377,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}", "Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}",
"zen.api.error.modelDisabled": "Le modèle est désactivé", "zen.api.error.modelDisabled": "Le modèle est désactivé",
"zen.api.error.regionNotAllowed":
"Ce modèle est hébergé en Chine. Si vous souhaitez utiliser ce modèle, activez-le dans vos paramètres : {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}", "La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}",
@@ -664,9 +662,6 @@ export const dict = {
"Utilisez votre solde disponible après avoir atteint les limites d'utilisation", "Utilisez votre solde disponible après avoir atteint les limites d'utilisation",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Sélectionnez "OpenCode Go" comme fournisseur dans votre configuration opencode pour utiliser les modèles Go.', 'Sélectionnez "OpenCode Go" comme fournisseur dans votre configuration opencode pour utiliser les modèles Go.',
"workspace.lite.providers.title": "Fournisseurs",
"workspace.lite.providers.description": "Contrôlez les fournisseurs utilisés pour le routage.",
"workspace.lite.providers.useChina": "Activer les modèles hébergés en Chine",
"workspace.lite.black.message": "workspace.lite.black.message":
"Vous êtes actuellement abonné à OpenCode Black ou sur liste d'attente. Veuillez d'abord vous désabonner si vous souhaitez passer à Go.", "Vous êtes actuellement abonné à OpenCode Black ou sur liste d'attente. Veuillez d'abord vous désabonner si vous souhaitez passer à Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
-5
View File
@@ -373,8 +373,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}", "Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}",
"zen.api.error.modelDisabled": "Il modello è disabilitato", "zen.api.error.modelDisabled": "Il modello è disabilitato",
"zen.api.error.regionNotAllowed":
"Questo modello è ospitato in Cina. Se vuoi usare questo modello, abilitalo nelle tue impostazioni: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}", "La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}",
@@ -656,9 +654,6 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Usa il tuo saldo disponibile dopo aver raggiunto i limiti di utilizzo", "workspace.lite.subscription.useBalance": "Usa il tuo saldo disponibile dopo aver raggiunto i limiti di utilizzo",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Seleziona "OpenCode Go" come provider nella tua configurazione opencode per utilizzare i modelli Go.', 'Seleziona "OpenCode Go" come provider nella tua configurazione opencode per utilizzare i modelli Go.',
"workspace.lite.providers.title": "Provider",
"workspace.lite.providers.description": "Controlla quali provider vengono usati per il routing.",
"workspace.lite.providers.useChina": "Abilita modelli ospitati in Cina",
"workspace.lite.black.message": "workspace.lite.black.message":
"Attualmente sei abbonato a OpenCode Black o sei in lista d'attesa. Annulla l'iscrizione prima se desideri passare a Go.", "Attualmente sei abbonato a OpenCode Black o sei in lista d'attesa. Annulla l'iscrizione prima se desideri passare a Go.",
"workspace.lite.other.message": "workspace.lite.other.message":

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