Compare commits

..

1 Commits

Author SHA1 Message Date
Hona c5b22986b4 fix(core): respect repository git semantics 2026-08-12 07:38:01 +00:00
1101 changed files with 16135 additions and 178286 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
-8
View File
@@ -1,8 +0,0 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Replace Core shell permission parsing with portable, fail-closed Bash and PowerShell scanners.
-1
View File
@@ -10,7 +10,6 @@ kitlangton
kommander kommander
ludvigrask ludvigrask
MrMushrooooom MrMushrooooom
neriousy
nexxeln nexxeln
R44VC0RP R44VC0RP
rekram1-node rekram1-node
-49
View File
@@ -1,49 +0,0 @@
name: deploy-lab-catalog
on:
push:
branches: [v2]
paths:
- ".github/workflows/deploy-lab-catalog.yml"
- "bun.lock"
- "package.json"
- "packages/drive/**"
- "packages/protocol/src/simulation.ts"
- "packages/simulation/**"
- "packages/lab/catalog/**"
workflow_dispatch:
concurrency:
group: deploy-lab-catalog-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: ./.github/actions/setup-bun
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Validate
run: |
bun --cwd packages/protocol typecheck
bun --cwd packages/simulation typecheck
bun --cwd packages/drive run check
bun --cwd packages/drive run test
bun --cwd packages/lab/catalog run check
- name: Deploy
working-directory: packages/lab/catalog
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1
View File
@@ -6,6 +6,7 @@ on:
branches: branches:
- ci - ci
- dev - dev
- v2
- beta - beta
- fix/npm-native-binary-install - fix/npm-native-binary-install
- snapshot-* - snapshot-*
+1 -17
View File
@@ -55,12 +55,6 @@ jobs:
git config --global user.email "bot@opencode.ai" git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode" git config --global user.name "opencode"
- name: Install ffmpeg
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Cache Turbo - name: Cache Turbo
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with: with:
@@ -72,20 +66,10 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }} run: GITHUB_ACTIONS=false bun turbo test
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Verify PowerShell 7 scanner conformance
if: always() && runner.os == 'Windows'
working-directory: packages/shell-scan
run: PWSH=pwsh bun run research:powershell
- name: Verify Windows PowerShell scanner conformance
if: always() && runner.os == 'Windows'
working-directory: packages/shell-scan
run: PWSH=powershell.exe bun run research:powershell
- name: Verify compiled service lifecycle - name: Verify compiled service lifecycle
if: always() if: always()
timeout-minutes: 10 timeout-minutes: 10
-48
View File
@@ -1,48 +0,0 @@
# he Glossary
## Sources
- Hebrew Academy approved IT terminology: https://terms.hebrew-academy.org.il/Millonim/ShowMillon?KodMillon=192
- Firefox Hebrew localization corpus: https://github.com/mozilla-l10n/firefox-l10n/tree/main/he
- KDE Hebrew localization team and corpus: https://l10n.kde.org/team-infos.php?teamcode=he
- Community-maintained VS Code Hebrew language pack: https://github.com/AMAARETS/vscode-language-pack-he
- Microsoft Hebrew developer documentation for Git terminology: https://learn.microsoft.com/he-il/power-platform/alm/tutorials/github-actions-deploy
- W3C guidance for bidirectional text: https://www.w3.org/International/articles/strings-and-bidi/
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose and UI copy)
- `API`, `MCP`, `LSP`, `OAuth`, `Git`, model names, and provider names
- Commands, flags, keyboard shortcuts, file paths, URLs, identifiers, hashes, and code literals
- Keep `commit` and `diff` when they name the exact Git artifact or operation
## Preferred Terms
| English / Context | Preferred | Notes |
| ----------------- | ------------- | ------------------------------------------------------------------------- |
| session | `הפעלה` | Use `שיחה` only when the source specifically means a chat or conversation |
| workspace | `סביבת עבודה` | |
| terminal | `מסוף` | Prefer the established Hebrew term over transliteration |
| command | `פקודה` | |
| provider | `ספק` | Use `ספק מודלים` where the bare noun is ambiguous |
| model | `מודל` | |
| API key | `מפתח API` | Keep the acronym in Latin letters |
| plugin | `תוסף` | |
| repository | `מאגר` | Use `מאגר Git` where context is ambiguous |
| branch | `ענף` | |
| context | `הקשר` | Use `חלון הקשר` for context window |
| tokens | `אסימונים` | |
## Guidance
- Prefer natural modern Israeli Hebrew over word-for-word translation or obscure coined terms.
- Use short action verbs for controls and translate complete phrases in context.
- Keep recognized developer acronyms and exact Git vocabulary in Latin script instead of phonetic transliteration.
- Treat embedded code, paths, commands, shortcuts, hashes, model IDs, and other Latin technical artifacts as LTR content inside the RTL interface.
- Keep recurring concepts consistent and do not collapse session, chat, run, and launch into one Hebrew term.
## Avoid
- Avoid transliterations such as `טרמינל`, `פלאגין`, and `קומנד` when `מסוף`, `תוסף`, and `פקודה` are clear.
- Avoid translating `commit` as `התחייבות`.
- Avoid inventing Hebrew expansions for `API`, `MCP`, or `LSP`.
+4 -4
View File
@@ -51,7 +51,7 @@ files. If the script is unsuccessful, automatically fix the script and run it ag
Scripts use one typed definition object. `setup` runs before OpenCode starts, Scripts use one typed definition object. `setup` runs before OpenCode starts,
and `fs.writeFile` always writes inside the simulated project. and `fs.writeFile` always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
```ts ```ts
import { defineScript } from "opencode-drive" import { defineScript } from "opencode-drive"
@@ -83,7 +83,7 @@ itself (this is extremely rare, do not use this unless explicitly asked). In thi
mode `ui` is typed as `null`; call `server.launch()` exactly mode `ui` is typed as `null`; call `server.launch()` exactly
once before launching clients. Each `clients.launch(name)` result provides the once before launching clients. Each `clients.launch(name)` result provides the
same UI methods as the automatic client. You can see an example of this API same UI methods as the automatic client. You can see an example of this API
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
Use the exported `wait(milliseconds)` utility for an unconditional delay. Use the exported `wait(milliseconds)` utility for an unconditional delay.
@@ -114,8 +114,8 @@ completion are automatic.
You can see some example scripts here: You can see some example scripts here:
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts - https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts - https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
## Prune ## Prune
+1 -1
View File
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = { const TEAM = {
tui: ["kommander", "simonklee"], tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"], desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "neriousy", "nexxeln", "kitlangton"], core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom", "starptech"], inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"], windows: ["Hona"],
} as const } as const
+3 -4
View File
@@ -1,9 +1,8 @@
- 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. - 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. - 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.
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required. - Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `v2`. - The default branch in this repo is `dev`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing ## Live V2 TUI Testing
@@ -171,4 +170,4 @@ const table = sqliteTable("session", {
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle. - One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry. - Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta. - `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
+598 -1086
View File
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -33,7 +33,6 @@
"packages": [ "packages": [
"packages/*", "packages/*",
"packages/console/*", "packages/console/*",
"packages/lab/*",
"packages/stats/*", "packages/stats/*",
"packages/slack" "packages/slack"
], ],
@@ -47,9 +46,9 @@
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.2", "@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.5.2", "@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.5.2", "@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0", "@standard-schema/spec": "1.1.0",
@@ -121,8 +120,7 @@
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "catalog:", "sst": "catalog:",
"turbo": "2.10.2", "turbo": "2.10.2"
"vitest": "4.1.10"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.933.0", "@aws-sdk/client-s3": "3.933.0",
@@ -145,12 +143,16 @@
"esbuild", "esbuild",
"node-pty", "node-pty",
"protobufjs", "protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron" "electron"
], ],
"overrides": { "overrides": {
"@opentui/core": "catalog:", "@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "catalog:", "@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "catalog:", "@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"effect": "catalog:" "effect": "catalog:"
@@ -170,7 +172,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",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch" "@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
} }
} }
-2
View File
@@ -22,8 +22,6 @@
## Localization ## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors. - NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change. - When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. - NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar. - Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
@@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1" import { SessionV1 } from "@opencode-ai/schema/session-v1"
import type { SessionInfo, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise" import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types" import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
import { expect, type Page } from "@playwright/test" import { expect, type Page } from "@playwright/test"
import { Schema } from "effect" import { Schema } from "effect"
@@ -90,8 +90,6 @@ export async function setupTimeline(
page: Page, page: Page,
input: { input: {
messages?: TimelineMessage[] messages?: TimelineMessage[]
currentMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean> settings?: Record<string, boolean>
sessions?: Session[] sessions?: Session[]
cpuRate?: number cpuRate?: number
@@ -104,22 +102,13 @@ export async function setupTimeline(
} = {}, } = {},
) { ) {
const sessions = input.sessions ?? [session()] const sessions = input.sessions ?? [session()]
const messages = const messages = validateTimelineMessages([
input.currentMessages ?? ...(input.seedHistory ? historyMessages(18) : []),
validateTimelineMessages([ ...(input.messages ?? [userMessage(), assistantMessage()]),
...(input.seedHistory ? historyMessages(18) : []), ])
...(input.messages ?? [userMessage(), assistantMessage()]), const active = messages.findLast((message) => message.info.role === "assistant")
])
const active = messages.findLast((message) =>
"info" in message ? message.info.role === "assistant" : message.type === "assistant",
)
const initialStatus = decodeStatus( const initialStatus = decodeStatus(
active && active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
("info" in active
? active.info.role === "assistant" && active.info.time.completed === undefined
: active.type === "assistant" && active.time.completed === undefined)
? { type: "busy" }
: { type: "idle" },
decodeOptions, decodeOptions,
) )
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
@@ -133,7 +122,7 @@ export async function setupTimeline(
project: project(), project: project(),
provider: provider(), provider: provider(),
sessions, sessions,
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus }, sessionStatus: { [sessionID]: initialStatus },
pageMessages: () => ({ pageMessages: () => ({
items: messages, items: messages,
}), }),
@@ -122,7 +122,6 @@ test("applies message latency after a list response gate is released", async ()
const gate = Promise.withResolvers<void>() const gate = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined let handler: ((route: Route) => Promise<void>) | undefined
const page = { const page = {
addInitScript: () => Promise.resolve(),
route: (_url: string, callback: (route: Route) => Promise<void>) => { route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback handler = callback
return Promise.resolve() return Promise.resolve()
@@ -1,7 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server" import { currentSession } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
@@ -79,8 +78,6 @@ function session(id: string, directory: string, title: string) {
} }
async function mockServers(page: Page, requests: string[]) { async function mockServers(page: Page, requests: string[]) {
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await page.route("**/*", async (route) => { await page.route("**/*", async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback() if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
@@ -88,6 +85,7 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} })
@@ -144,3 +142,7 @@ function json(route: Route, body: unknown, status = 200) {
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -13,8 +13,6 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => { test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = [] const permissionRequests: string[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await mockServers(page, permissionRequests) await mockServers(page, permissionRequests)
await configureServers(page) await configureServers(page)
@@ -48,7 +46,6 @@ test("session settings use the remote server context", async ({ page }) => {
test("auto-accept responds for an unfocused server session", async ({ page }) => { test("auto-accept responds for an unfocused server session", async ({ page }) => {
const permissionRequests: string[] = [] const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = [] const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverB })
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: serverA, server: serverA,
retry: 20, retry: 20,
@@ -184,6 +181,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true) return json(route, true)
} }
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/provider") if (url.pathname === "/api/provider")
return json(route, { return json(route, {
location: { directory }, location: { directory },
@@ -327,3 +325,7 @@ function json(route: Route, body: unknown, status = 200) {
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -95,22 +95,22 @@ test("opens and searches project files inline", async ({ page }) => {
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" }) const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
const contextButton = page.getByRole("button", { name: "View context usage" }) const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click() await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible() await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled() await expect(sidebarToggle).toBeDisabled()
await expect(sidebar).toBeVisible() await expect(sidebar).toBeVisible()
await contextButton.click() await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toBeHidden() await expect(sidebar).toBeHidden()
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" }) const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused() await expect(filter).toBeFocused()
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible() await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible() await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "README.md" }).click() await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible() await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled() await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0) await expect(sidebar).toHaveCount(0)
@@ -125,21 +125,21 @@ test("opens and searches project files inline", async ({ page }) => {
expect(resultID).toBeTruthy() expect(resultID).toBeTruthy()
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter") await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible() await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled() await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 }) expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible() await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled() await expect(sidebarToggle).toBeDisabled()
await panel.locator("#session-side-panel-review-tab").click() await panel.locator("#session-side-panel-review-tab").click()
await expect(sidebarToggle).toBeEnabled() await expect(sidebarToggle).toBeEnabled()
await panel.getByRole("tab", { name: "Open file" }).click() await panel.getByRole("tab", { name: "Open file" }).click()
await page.keyboard.press("Control+w") await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible() await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
}) })
function fileNode(path: string) { function fileNode(path: string) {
@@ -11,23 +11,18 @@ const title = "Request dock regression"
test("shows a pending question dock", async ({ page }) => { test("shows a pending question dock", async ({ page }) => {
await mockServer(page, { await mockServer(page, {
forms: [ questions: [
{ {
id: "frm_question_request", id: "question-request",
sessionID, sessionID,
title: "Questions", questions: [
metadata: { kind: "question" },
fields: [
{ {
key: "q0", header: "Implementation",
type: "string", question: "Which implementation should be used?",
title: "Implementation",
description: "Which implementation should be used?",
options: [ options: [
{ value: "minimal", label: "Minimal", description: "Use the smallest correct change" }, { label: "Minimal", description: "Use the smallest correct change" },
{ value: "extended", label: "Extended", description: "Include additional behavior" }, { label: "Extended", description: "Include additional behavior" },
], ],
custom: true,
}, },
], ],
}, },
@@ -47,7 +42,7 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = [] const rejectRequests: string[] = []
page.on("request", (request) => { page.on("request", (request) => {
if (request.method() !== "POST") return if (request.method() !== "POST") return
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`) if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
rejectRequests.push(request.url()) rejectRequests.push(request.url())
}) })
@@ -72,10 +67,10 @@ test("shows a pending question dock", async ({ page }) => {
const reply = page.waitForRequest( const reply = page.waitForRequest(
(request) => (request) =>
request.method() === "POST" && request.method() === "POST" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`, new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
) )
await question.getByRole("button", { name: "Submit" }).click() await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } }) expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
}) })
test("shows a pending permission dock", async ({ page }) => { test("shows a pending permission dock", async ({ page }) => {
@@ -87,7 +82,7 @@ test("shows a pending permission dock", async ({ page }) => {
permission: "bash", permission: "bash",
patterns: ["git status", "git diff"], patterns: ["git status", "git diff"],
metadata: {}, metadata: {},
always: ["git *"], always: [],
}, },
], ],
}) })
@@ -114,7 +109,7 @@ test("restores the draft caret before typing after a request dock closes", async
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: 20, retry: 20,
}) })
await mockServer(page, { forms: [] }) await mockServer(page, { questions: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection() await transport.waitForConnection()
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
@@ -140,24 +135,18 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.send({ await transport.send({
directory, directory,
payload: { payload: {
type: "form.created", type: "question.asked",
properties: { properties: {
form: { id: "question-caret",
id: "frm_question_caret", sessionID,
sessionID, questions: [
title: "Questions", {
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } }, header: "Continue",
fields: [ question: "Continue?",
{ options: [{ label: "Yes", description: "Continue the session" }],
key: "q0", },
type: "string", ],
title: "Continue", tool: { messageID: "message-caret", callID: "call-caret" },
description: "Continue?",
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
custom: true,
},
],
},
}, },
}, },
}) })
@@ -167,10 +156,7 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.send({ await transport.send({
directory, directory,
payload: { payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
type: "form.cancelled",
properties: { sessionID, id: "frm_question_caret" },
},
}) })
await expect(question).toHaveCount(0) await expect(question).toHaveCount(0)
await expect(editor).toBeVisible() await expect(editor).toBeVisible()
@@ -184,8 +170,6 @@ async function mockServer(
requests: { requests: {
permissions?: unknown[] | (() => unknown[]) permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
sessionStatus?: Record<string, unknown>
}, },
) { ) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
@@ -230,8 +214,6 @@ async function mockServer(
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
permissions: requests.permissions, permissions: requests.permissions,
questions: requests.questions, questions: requests.questions,
forms: requests.forms,
sessionStatus: requests.sessionStatus,
}) })
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
@@ -1,184 +0,0 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
const assistant = (completed: boolean, tool = false, childID?: string) =>
({
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: tool
? [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
time: { created: 2 },
},
]
: [{ type: "text", text: "Working" }],
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
}) satisfies SessionMessageInfo
test("renders current protocol notices in CLI order", async ({ page }) => {
const ownerWarnings: string[] = []
page.on("console", (message) => {
if (message.text().includes("computations created outside a `createRoot` or `render`"))
ownerWarnings.push(message.text())
})
await setupTimeline(page, {
currentMessages: [
user,
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
assistant(true),
{
id: "msg_subagent",
type: "synthetic",
text: "done",
description: "Search code",
metadata: { source: "subagent", agent: "explore", state: "completed" },
time: { created: 4 },
},
{
id: "msg_restart",
type: "synthetic",
text: "continue",
description: "Continuing after restart",
time: { created: 5 },
},
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } },
],
})
const notices = page.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toContainText("Agent · explore")
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
expect(ownerWarnings).toEqual([])
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, { currentMessages: [user, assistant(false, true)] })
await expect(page.locator('[data-component="task-tool-card"]')).toBeVisible()
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
)
await page.keyboard.press("Control+b")
await request
})
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
currentMessages: [user, assistant(false, true, childID)],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
})
test("shows a badge for active background work", async ({ page }) => {
const childID = "ses_background_child"
await setupTimeline(page, {
currentMessages: [user, assistant(true)],
sessions: [session(), session({ id: childID, parentID: sessionID })],
sessionStatus: { [childID]: { type: "busy" } },
})
await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background")
})
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
await setupTimeline(page, {
currentMessages: [
user,
{
id: "msg_backgrounded",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_backgrounded",
name: "subagent",
state: {
status: "completed",
input: { description: "Background task" },
content: [{ type: "text", text: "working" }],
metadata: { sessionID: backgroundID, status: "running" },
},
time: { created: 2, completed: 3 },
},
{
type: "tool",
id: "call_shell_backgrounded",
name: "shell",
state: {
status: "completed",
input: { command: "sleep 120" },
content: [{ type: "text", text: "working" }],
metadata: { shellID: "shell_backgrounded", status: "running" },
},
time: { created: 2, completed: 3 },
},
],
time: { created: 2, completed: 3 },
},
{
id: "msg_blocking",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_blocking",
name: "subagent",
state: {
status: "running",
input: { description: "Foreground task" },
metadata: { sessionID: blockingID },
},
time: { created: 4 },
},
],
time: { created: 4 },
},
],
sessions: [
session(),
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
],
sessionStatus: {
[sessionID]: { type: "busy" },
[backgroundID]: { type: "busy" },
[blockingID]: { type: "busy" },
},
})
const dock = page.locator('[data-component="session-background-dock"]')
await expect(dock).toContainText("Move 1 subagent to background")
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
await expect(
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
).toHaveAttribute("data-active", "true")
})
@@ -27,30 +27,6 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
}) })
test("keeps the parent visible while child lineage resolves", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
() => release.resolve(),
)
await expectSessionTitle(page, taskDescription)
})
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => { test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
const events: EventPayload[] = [] const events: EventPayload[] = []
await setup(page, () => events.splice(0, 1)) await setup(page, () => events.splice(0, 1))
@@ -189,12 +189,7 @@ test.describe("smoke: session timeline", () => {
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]') .querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect() ?.getBoundingClientRect()
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
if ( if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
!firstPaint &&
visible.includes(last) &&
Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1 &&
!root.querySelector('[data-markdown-key="initial"]')
) {
firstPaint = true firstPaint = true
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => { root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
const rect = row.getBoundingClientRect() const rect = row.getBoundingClientRect()
@@ -209,16 +204,10 @@ test.describe("smoke: session timeline", () => {
} }
;( ;(
window as Window & { window as Window & {
__sessionTabPaint?: { __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
samples: typeof samples
painted: () => boolean
removed: () => number
stop: () => void
}
} }
).__sessionTabPaint = { ).__sessionTabPaint = {
samples, samples,
painted: () => firstPaint,
removed: () => removedFirstPaintNodes, removed: () => removedFirstPaintNodes,
stop: () => { stop: () => {
running = false running = false
@@ -230,19 +219,17 @@ test.describe("smoke: session timeline", () => {
) )
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
await page.waitForFunction(() => { await page.waitForFunction(() =>
const probe = ( (
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }>; painted: () => boolean } } window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
).__sessionTabPaint ).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
return probe?.painted() && probe.samples.some((sample) => sample.ids.length > 0) )
})
await page.waitForTimeout(200) await page.waitForTimeout(200)
const first = await page.evaluate(() => { const first = await page.evaluate(() => {
const probe = ( const probe = (
window as Window & { window as Window & {
__sessionTabPaint?: { __sessionTabPaint?: {
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
painted: () => boolean
removed: () => number removed: () => number
stop: () => void stop: () => void
} }
+8 -151
View File
@@ -32,110 +32,15 @@ export interface MockServerConfig {
todos?: (sessionID: string) => unknown[] todos?: (sessionID: string) => unknown[]
permissions?: unknown[] | (() => unknown[]) permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown> fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown> fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>) sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
} }
type MockStreamWindow = Window & {
__testSseTransport?: unknown
__mockServerStream?: { push: (payloads: unknown[]) => void }
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>() const cursors = new Map<string, string>()
let nextCursor = 0 let nextCursor = 0
await page.addInitScript(
({ port, retry }) => {
const host = window as MockStreamWindow
if (host.__testSseTransport || host.__mockServerStream) return
const originalFetch = window.fetch.bind(window)
const encoder = new TextEncoder()
const state: {
controller?: ReadableStreamDefaultController<Uint8Array>
buffer: string[]
connections: number
} = { buffer: [], connections: 0 }
const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\n\n`
host.__mockServerStream = {
push(payloads: unknown[]) {
const frames = payloads.map(frame)
const controller = state.controller
if (!controller) {
state.buffer.push(...frames)
return
}
frames.forEach((item) => controller.enqueue(encoder.encode(item)))
},
}
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const url = new URL(request.url)
if (url.port !== port || url.pathname !== "/api/event") return originalFetch(request)
state.connections += 1
const id = state.connections
let ended = false
let own: ReadableStreamDefaultController<Uint8Array> | undefined
const stream = new ReadableStream<Uint8Array>({
start(controller) {
own = controller
state.controller = controller
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
request.signal.addEventListener(
"abort",
() => {
if (ended) return
ended = true
if (state.controller === controller) state.controller = undefined
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
},
{ once: true },
)
},
cancel() {
if (ended) return
ended = true
if (state.controller === own) state.controller = undefined
},
})
return Promise.resolve(
new Response(stream, {
status: 200,
headers: { "cache-control": "no-cache", "content-type": "text/event-stream" },
}),
)
}
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
},
{ port: process.env.PLAYWRIGHT_SERVER_PORT ?? "4096", retry: config.eventRetry },
)
if (config.events) {
const pump = { busy: false }
const timer = setInterval(() => {
if (pump.busy) return
const batch = config.events?.() ?? []
if (batch.length === 0) return
pump.busy = true
void page
.evaluate(
(payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads),
batch.map(currentEvent),
)
.catch(() => {})
.finally(() => {
pump.busy = false
})
}, 50)
page.on("close", () => clearInterval(timer))
}
const staticRoutes: Record<string, unknown> = { const staticRoutes: Record<string, unknown> = {
"/path": { "/path": {
state: config.directory, state: config.directory,
@@ -267,34 +172,16 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
} }
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE") if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") { if (path === "/api/project") return json(route, [config.project])
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return json(route, [
{
...project,
canonical: project.canonical ?? project.worktree ?? config.directory,
},
])
}
if (path === "/api/project/current") if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
const worktree = path.match(/^\/api\/experimental\/project\/([^/]+)\/worktree$/)?.[1]
if (worktree && route.request().method() === "GET")
return json(route, [
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
])
if (path === "/api/location") return json(route, location(config)) if (path === "/api/location") return json(route, location(config))
if (worktree && route.request().method() === "POST") { const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
if (projectCopy && route.request().method() === "POST") {
const input = route.request().postDataJSON() as { directory: string; name?: string } const input = route.request().postDataJSON() as { directory: string; name?: string }
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` }) return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
} }
if (worktree && route.request().method() === "DELETE") if (projectCopy && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/experimental\/project\/[^/]+\/worktree\/refresh$/.test(path))
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request") if (path === "/api/permission/request")
return json(route, { return json(route, {
@@ -308,11 +195,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
location: location(config), location: location(config),
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
}) })
if (path === "/api/form/request")
return json(route, {
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
})
if (path === "/api/vcs") if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
@@ -361,10 +243,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const limit = Number(url.searchParams.get("limit") ?? 50) const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0) const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions const sessions = config.sessions
.filter((session) => { .filter((session) => !directory || session.directory === directory)
const location = session.location as { directory?: string } | undefined
return !directory || location?.directory === directory || session.directory === directory
})
.filter((session) => parentID !== "null" || session.parentID === undefined) .filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => { .filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase() const search = url.searchParams.get("search")?.toLowerCase()
@@ -401,18 +280,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
} }
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
if (sessionForm && route.request().method() === "GET") {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
}
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
return json(route, { data: [] })
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
} }
@@ -599,7 +466,6 @@ function currentPermission(value: unknown) {
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) { export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
const time = session.time && typeof session.time === "object" ? session.time : {} const time = session.time && typeof session.time === "object" ? session.time : {}
const location = session.location && typeof session.location === "object" ? session.location : {}
return { return {
id: session.id, id: session.id,
parentID: session.parentID, parentID: session.parentID,
@@ -617,19 +483,10 @@ export function currentSession(session: { id: string } & Record<string, unknown>
}, },
title: session.title ?? session.id, title: session.title ?? session.id,
location: { location: {
directory: directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
"directory" in location && typeof location.directory === "string" ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
? location.directory
: typeof session.directory === "string"
? session.directory
: fallbackDirectory,
...(typeof session.workspaceID === "string"
? { workspaceID: session.workspaceID }
: "workspaceID" in location && typeof location.workspaceID === "string"
? { workspaceID: location.workspaceID }
: {}),
}, },
subpath: session.subpath ?? session.path, subpath: session.path,
revert: session.revert, revert: session.revert,
} }
} }
+6
View File
@@ -63,13 +63,19 @@
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4", "@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5", "@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5", "@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3", "@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4", "@tanstack/solid-query": "5.91.4",
+257 -73
View File
@@ -1,38 +1,48 @@
import "@/index.css" import "@/index.css"
import * as Sentry from "@sentry/solid" import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context" import { I18nProvider } from "@opencode-ai/ui/context"
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { File } from "@opencode-ai/session-ui/file" import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import { import {
type Component, type Component,
createEffect, createEffect,
createMemo, createMemo,
createRenderEffect, createRenderEffect,
createResource,
createSignal,
ErrorBoundary, ErrorBoundary,
For,
type JSX, type JSX,
lazy, lazy,
onCleanup,
type ParentProps, type ParentProps,
Show, Show,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommandProvider, useCommand, type CommandOption } 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 } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global" import { GlobalProvider, useGlobal } 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 } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServersProvider, useServers } from "@/context/servers" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
@@ -40,26 +50,26 @@ import { WslServersProvider } from "@/wsl/context"
import { DirectoryDataProvider } from "@/pages/directory-layout" import { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { TargetSessionRouteContent } from "@/pages/session" import { TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home" import { Home } from "@/pages/home"
import { ServerProvider } from "./context/server"
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
const DirectoryDraftRedirect = () => { const DirectoryDraftRedirect = () => {
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const server = useServers() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
createEffect(() => { createEffect(() => {
if (search.draftId || !tabs.ready()) return if (search.draftId || !tabs.ready()) return
const directory = decode64(params.dir) const directory = decode64(params.dir)
if (!directory) return if (!directory) return
tabs.newDraft({ server: ServerConnection.key(server.list[0])!, directory }, search.prompt) tabs.newDraft({ server: server.key, directory }, search.prompt)
}) })
return null return null
@@ -76,13 +86,31 @@ function TargetServerRoute(props: ParentProps) {
return ( return (
// Owns the server-identity remount. Session changes must not remount this subtree. // Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={requireServerKey(params.serverKey)} keyed> <Show when={requireServerKey(params.serverKey)} keyed>
<Show when={conn()} keyed> <ServerSDKProvider server={conn}>
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>} <ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</Show> </ServerSDKProvider>
</Show> </Show>
) )
} }
const TargetSessionRoute = () => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell for that server.
function SelectedServerProviders(props: ParentProps) {
return (
<ServerKey>
<ServerSDKProvider>
<ServerSyncProvider>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</ServerKey>
)
}
function DraftRoute() { function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs() const tabs = useTabs()
@@ -102,24 +130,24 @@ function DraftRoute() {
function ResolvedDraftRoute(props: { draft: DraftTab }) { function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal() const global = useGlobal()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server)) const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
const directory = () => props.draft.directory
const serverKey = () => props.draft.server
return ( return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed> <Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<Show when={conn()} keyed> <ServerSDKProvider server={conn}>
{(conn) => ( <ServerSyncProvider server={conn}>
<ServerProvider conn={conn}> <ModelsProvider directory={directory}>
<ModelsProvider directory={props.draft.directory}> <SDKProvider directory={directory}>
<SDKProvider directory={props.draft.directory}> <DirectoryDataProvider directory={directory} server={serverKey}>
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}> <DraftProviders>
<DraftProviders> <NewSession />
<NewSession draftId={props.draft.draftID} /> </DraftProviders>
</DraftProviders> </DirectoryDataProvider>
</DirectoryDataProvider> </SDKProvider>
</SDKProvider> </ModelsProvider>
</ModelsProvider> </ServerSyncProvider>
</ServerProvider> </ServerSDKProvider>
)}
</Show>
</Show> </Show>
) )
} }
@@ -128,13 +156,7 @@ function UiI18nBridge(props: ParentProps) {
const language = useLanguage() const language = useLanguage()
return ( return (
<I18nProvider <I18nProvider
value={{ value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
> >
{props.children} {props.children}
</I18nProvider> </I18nProvider>
@@ -158,8 +180,8 @@ function QueryProvider(props: ParentProps) {
defaultOptions: { defaultOptions: {
queries: { queries: {
refetchOnReconnect: false, refetchOnReconnect: false,
refetchOnWindowFocus: false,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false,
}, },
}, },
}) })
@@ -179,6 +201,18 @@ function BodyDesignClass() {
// Server-agnostic providers shared across every route. These live in the shared // Server-agnostic providers shared across every route. These live in the shared
// shell (router root) so they stay mounted regardless of the active server/route. // shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) {
return (
<>
<BodyDesignClass />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider>
</>
)
}
function DesktopCommands() { function DesktopCommands() {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
@@ -202,14 +236,30 @@ function DesktopCommands() {
return null return null
} }
function AppLayout(props: ParentProps) { type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
serverScoped?: JSX.Element
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<LayoutProvider> <LayoutProvider>
<Layout>{props.children}</Layout> {props.serverScoped}
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</LayoutProvider> </LayoutProvider>
) )
} }
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<SelectedServerProviders>
<ServerScopedProviders serverScoped={props.serverScoped}>
<Layout>{props.children}</Layout>
</ServerScopedProviders>
</SelectedServerProviders>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider. // The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) { function DraftProviders(props: ParentProps) {
@@ -259,6 +309,133 @@ export function AppBaseProviders(
) )
} }
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
// performs repeated health check with a grace period for
// non-http connections, otherwise fails instantly
const [startupHealthCheck, healthCheckActions] = createResource(() =>
props.disableHealthCheck
? true
: Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true
if (checkMode() === "background" || type === "http") return false
}
}).pipe(
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
)
const checking = createMemo(
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
)
const [startup] = createResource(async () => {
if (!props.startup) return true
await props.startup.catch((error) => {
console.error("[startup] startup gate failed", error)
})
return true
})
const startupChecking = createMemo(
() => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
)
const loading = createMemo(() => checking() || startupChecking())
return (
<>
<Show when={!checking()}>
<Show
when={startupHealthCheck.latest}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
void healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show>
<Show when={loading()}>
<div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
</Show>
</>
)
}
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
const language = useLanguage()
const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const name = createMemo(() => server.name || server.key)
const serverToken = "\u0000server\u0000"
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-14-regular text-text-base">
{unreachable()[0]}
<span class="text-text-strong font-medium">{name()}</span>
{unreachable()[1]}
</p>
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
</div>
<Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm">
<span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
<For each={others()}>
{(conn) => {
const key = ServerConnection.key(conn)
return (
<button
type="button"
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => props.onServerSelected?.(key)}
>
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
</button>
)
}}
</For>
</div>
</div>
</Show>
</div>
)
}
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.key} keyed>
{props.children}
</Show>
)
}
export function AppInterface(props: { export function AppInterface(props: {
children?: JSX.Element children?: JSX.Element
defaultServer: ServerConnection.Key defaultServer: ServerConnection.Key
@@ -267,60 +444,67 @@ export function AppInterface(props: {
router?: Component<BaseRouterProps> router?: Component<BaseRouterProps>
disableHealthCheck?: boolean disableHealthCheck?: boolean
startup?: Promise<void> startup?: Promise<void>
serverScoped?: JSX.Element
}) { }) {
// The visual layout lives in the router root so it remains mounted across // The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data // route changes. Draft and session routes override only their server-bound data
// providers beneath it. // providers beneath it.
const Root = (rootProps: ParentProps) => ( const ServerShell = (shellProps: ParentProps) => (
<TabsProvider> <QueryProvider>
<BodyDesignClass /> <SharedProviders>
<CommandProvider> {props.children}
<DesktopCommands /> {shellProps.children}
<HighlightsProvider> </SharedProviders>
{props.children} </QueryProvider>
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</TabsProvider>
) )
return ( return (
<ServersProvider <ServerProvider
defaultServer={props.defaultServer} defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer} canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers} servers={props.servers}
> >
<SettingsProvider> <GlobalProvider>
<GlobalProvider> <SettingsProvider>
<Dynamic component={props.router ?? Router} root={Root}> <ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
{/* Proper Routes */} <Dynamic
<Route component={AppLayout}> component={props.router ?? Router}
<Route path="/" component={Home} /> root={(routerProps) => (
<Route <TabsProvider>
path="/server/:serverKey/session/:id" <PermissionProvider>
component={() => ( <NotificationProvider>
<TargetServerRoute> <ServerShell>
<TargetSessionRouteContent /> <AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
</TargetServerRoute> </ServerShell>
)} </NotificationProvider>
/> </PermissionProvider>
<Route path="/new-session" component={DraftRoute} /> </TabsProvider>
</Route> )}
{/* Legacy Routes */} >
<Route> <Routes />
<Route path="/:dir" component={DirectoryDraftRedirect} /> </Dynamic>
<Route path="/:dir/session" component={DirectoryDraftRedirect} /> </ConnectionGate>
<Route path="/:dir/session/:id" component={LegacySessionRedirect} /> </SettingsProvider>
</Route> </GlobalProvider>
</Dynamic> </ServerProvider>
</GlobalProvider> )
</SettingsProvider> }
</ServersProvider>
function Routes() {
return (
<>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryDraftRedirect} />
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
<Route path="/new-session" component={DraftRoute} />
</>
) )
} }
function LegacySessionRedirect() { function LegacySessionRedirect() {
const server = useServers() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
@@ -331,7 +515,7 @@ function LegacySessionRedirect() {
legacySessionServer( legacySessionServer(
tabs.store.filter((item) => item.type === "session"), tabs.store.filter((item) => item.type === "session"),
params.id, params.id,
ServerConnection.key(server.list[0]!), server.key,
), ),
params.id, params.id,
)} )}
@@ -8,13 +8,12 @@ import { useFile } from "@/context/file"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout, type LocalProject } from "@/context/layout" import { useLayout, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
export type CommandPaletteEntry = { export type CommandPaletteEntry = {
id: string id: string
@@ -84,7 +83,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
const language = useLanguage() const language = useLanguage()
const file = useFile() const file = useFile()
const dialog = useDialog() const dialog = useDialog()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()()
const serverCtx = global.ensureServerCtx(serverSDK.server) const serverCtx = global.ensureServerCtx(serverSDK.server)
const appTabs = useTabs() const appTabs = useTabs()
const { tabs: sessionTabs } = useSessionLayout() const { tabs: sessionTabs } = useSessionLayout()
+3 -3
View File
@@ -1,5 +1,5 @@
import { useIsRouting, useLocation } from "@solidjs/router" import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js" 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"
@@ -571,7 +571,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={language.t(`debugBar.direction.${language.direction()}`)} value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")} onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
/> />
<Show when={platform.setForceFocus}> {platform.setForceFocus && (
<ToggleCell <ToggleCell
active={state.focus} active={state.focus}
inline={props.inline} inline={props.inline}
@@ -580,7 +580,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")} value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()} onClick={() => void toggleFocus()}
/> />
</Show> )}
</div> </div>
</aside> </aside>
) )
@@ -10,7 +10,7 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onC
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command" import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/server"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
@@ -12,7 +12,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js" import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link" import { ExternalLink } from "@/components/external-link"
@@ -20,7 +20,6 @@ import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
import { CustomProviderForm } from "./dialog-custom-provider" import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller" import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
@@ -41,7 +40,7 @@ export function useProviderConnectController(options: { onBack?: () => void } =
} }
export const DialogConnectProvider: Component<{ export const DialogConnectProvider: Component<{
directory?: string directory?: Accessor<string | undefined>
controller?: ReturnType<typeof useProviderConnectController> controller?: ReturnType<typeof useProviderConnectController>
}> = (props) => { }> = (props) => {
const fallback = useProviderConnectController() const fallback = useProviderConnectController()
@@ -137,11 +136,15 @@ export const DialogConnectProvider: Component<{
) )
} }
function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) { function ProviderPicker(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const settings = useSettings() const settings = useSettings()
if (settings.general.newLayoutDesigns()) if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} /> return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const integrations = useIntegrations(() => props.directory) const providers = useProviders(() => props.directory?.())
const language = useLanguage() const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular") const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other") const otherGroup = () => language.t("dialog.provider.group.other")
@@ -163,7 +166,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
key={(x) => x?.id} key={(x) => x?.id}
items={() => { items={() => {
language.locale() language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...integrations.list()] return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
}} }}
filterKeys={["id", "name"]} filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -208,8 +211,12 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
) )
} }
function ProviderPickerV2(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) { function ProviderPickerV2(props: {
const integrations = useIntegrations(() => props.directory) directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const providers = useProviders(() => props.directory?.())
const language = useLanguage() const language = useLanguage()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
filter: "", filter: "",
@@ -221,7 +228,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
const all = createMemo(() => { const all = createMemo(() => {
language.locale() language.locale()
const query = store.filter.trim().toLowerCase() const query = store.filter.trim().toLowerCase()
const values = [custom(), ...integrations.list()] const values = [custom(), ...providers.all().values()]
if (!query) return values if (!query) return values
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
}) })
@@ -357,7 +364,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
function ProviderConnection(props: { function ProviderConnection(props: {
provider: string provider: string
directory?: string directory?: Accessor<string | undefined>
onBack: () => void onBack: () => void
setBack: (handler: () => void) => void setBack: (handler: () => void) => void
}) { }) {
@@ -367,9 +374,12 @@ function ProviderConnection(props: {
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(() => props.directory) const providers = useProviders(() => props.directory?.())
const directory = () => props.directory ?? decode64(params.dir) const directory = () => props.directory?.() ?? decode64(params.dir)
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
)
const controller = createProviderConnectionController({ const controller = createProviderConnectionController({
provider: () => props.provider, provider: () => props.provider,
directory, directory,
@@ -383,14 +393,6 @@ function ProviderConnection(props: {
}) })
}, },
}) })
const provider = createMemo(() => ({
id: props.provider,
name:
providers.all().get(props.provider)?.name ??
serverSync.data.provider.all.get(props.provider)?.name ??
controller.integration()?.name ??
props.provider,
}))
const methodLabel = (value?: { type?: string; label?: string }) => { const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return "" if (!value) return ""
if (value.type === "key") return language.t("provider.connect.method.apiKey") if (value.type === "key") return language.t("provider.connect.method.apiKey")
@@ -116,8 +116,8 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const output = validateCustomProvider({ const output = validateCustomProvider({
form, form,
t: language.t, t: language.t,
disabledProviders: serverSync.data.config.disabled_providers ?? [], disabledProviders: serverSync().data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync.data.provider.all.keys()), existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
}) })
batch(() => { batch(() => {
setForm("err", output.err) setForm("err", output.err)
@@ -1,238 +0,0 @@
.project-settings-v2-dialog [data-slot="dialog-container"] {
background: var(--v2-background-bg-base);
}
.project-settings-v2-dialog [data-slot="dialog-body"] {
padding: 0;
overflow: hidden;
}
.project-settings-v2 {
height: 100%;
}
.project-settings-v2-nav {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.project-settings-v2-panel {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
overflow: hidden !important;
user-select: none;
}
.project-settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.project-settings-v2-form {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.project-settings-v2-scroll {
display: flex;
flex: 1;
flex-direction: column;
gap: 24px;
min-height: 0;
padding: 40px;
overflow-y: auto;
}
.project-settings-page-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.project-settings-page-header h2 {
color: var(--v2-text-text-base);
font-size: 15px;
font-weight: 640;
line-height: 1;
}
.project-settings-page-header > span {
color: var(--v2-text-text-muted);
font-size: 11px;
font-weight: 440;
line-height: 1;
}
.project-settings-extensions {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 40px;
}
.project-settings-extension-tabs {
flex: 1;
height: auto;
min-height: 0;
margin-top: 24px;
overflow: visible;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: auto;
padding-inline: 0 !important;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
display: none;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"] {
border-radius: 6px;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger"] {
padding-inline: 8px;
}
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
overflow-y: auto;
}
.project-settings-extension-section {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 20px;
}
.project-settings-extension-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: 1;
}
.project-settings-extension-section-header > :last-child {
color: var(--v2-text-text-faint);
font-size: 11px;
font-weight: 440;
}
.project-settings-extension-link {
color: var(--v2-text-text-accent) !important;
text-decoration: none;
}
.project-settings-extension-link:hover {
text-decoration: underline;
}
.project-settings-extension-card {
padding-inline: 12px;
overflow: hidden;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 8px;
background: var(--v2-background-bg-base);
}
.project-settings-extension-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.project-settings-extension-row:last-child {
border-bottom: 0;
}
.project-settings-extension-row-main {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
}
.project-settings-extension-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.project-settings-extension-row-name {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-settings-extension-row-status {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 11px;
}
.project-settings-extension-row-status-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--v2-state-fg-warning);
}
.project-settings-shared {
display: flex;
flex-direction: column;
gap: 12px;
}
.project-settings-shared-trigger {
display: flex;
align-items: center;
align-self: flex-start;
gap: 6px;
color: var(--v2-text-text-base);
font-size: 11px;
font-weight: 530;
}
.project-settings-shared-chevron {
width: 12px;
height: 12px;
color: var(--v2-icon-icon-muted);
transition: transform 120ms ease;
}
.project-settings-shared-chevron.open {
transform: rotate(90deg);
}
.project-settings-shared-count {
padding: 1px 4px;
border-radius: 3px;
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-muted);
font-size: 9px;
}
@@ -1,223 +1,156 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2" import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Field } from "@opencode-ai/ui/v2/field-v2" import { Field } from "@opencode-ai/ui/v2/field-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2" import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2" import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { For, Show, createSignal, startTransition } from "solid-js" import { For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/server"
import { SDKProvider } from "@/context/sdk" import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project" import { createEditProjectModel } from "./edit-project"
import { ProjectSettingsExtensions } from "./project-settings-extensions"
import { SettingsServerDataScope } from "./settings-server-picker"
import "./settings-v2/settings-v2.css"
import "./dialog-edit-project-v2.css"
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) { export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
return (
<SettingsServerDataScope server={props.server}>
<SDKProvider directory={props.project.worktree}>
<ProjectSettingsDialog project={props.project} server={props.server} />
</SDKProvider>
</SettingsServerDataScope>
)
}
function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage() const language = useLanguage()
const model = createEditProjectModel(props) const model = createEditProjectModel(props)
const projectName = () => displayName(props.project)
const [tab, setTab] = createSignal("general")
const Footer = () => (
<DialogFooter>
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2>
</DialogFooter>
)
return ( return (
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog"> <Dialog fit>
<TabsV2 <form onSubmit={model.submit} class="contents">
orientation="vertical" <DialogHeader>
variant="settings" <DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
value={tab()} </DialogHeader>
onChange={(value) => void startTransition(() => setTab(value))} <DividerV2 />
class="project-settings-v2" <DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
> <Field>
<TabsV2.List> <Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<div class="project-settings-v2-nav"> <TextInputV2
<TabsV2.Trigger value="general"> autofocus
<ProjectAvatar appearance="large"
fallback={projectName()} class="!w-full"
variant={getProjectAvatarVariant(props.project.icon?.color)} value={model.store.name}
class="!size-4 shrink-0" placeholder={model.folderName()}
/> onInput={(event) => model.setStore("name", event.currentTarget.value)}
<span class="truncate">{projectName()}</span> />
</TabsV2.Trigger> </Field>
<TabsV2.Trigger value="scripts">
<Icon name="code" size="small" />
{language.t("project.settings.scripts")}
</TabsV2.Trigger>
<TabsV2.Trigger value="extensions">
<Icon name="extensions" size="small" />
{language.t("settings.tab.extensions")}
</TabsV2.Trigger>
</div>
</TabsV2.List>
<TabsV2.Content value="general" class="project-settings-v2-panel"> <div class="flex w-full flex-col gap-2">
<form onSubmit={model.submit} class="project-settings-v2-form"> <div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
<div class="project-settings-v2-scroll"> {language.t("dialog.project.edit.icon")}
<div class="project-settings-page-header"> </div>
<h2>{language.t("dialog.project.edit.title")}</h2> <div class="flex items-center gap-3">
<span>{language.t("project.settings.general.description")}</span> <button
</div> type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
<Field> class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label> classList={{
<TextInputV2 "bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
autofocus }}
appearance="large" onMouseEnter={() => model.setStore("iconHover", true)}
class="!w-full" onMouseLeave={() => model.setStore("iconHover", false)}
value={model.store.name} onDrop={model.drop}
placeholder={model.folderName()} onDragOver={model.dragOver}
onInput={(event) => model.setStore("name", event.currentTarget.value)} onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/> />
</Field> <span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
}}
>
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
</span>
</button>
<input
ref={(element) => {
model.setIconInput(element)
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<div class="flex w-full flex-col gap-2"> <Show when={!model.store.iconOverride}>
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base"> <div class="flex w-full flex-col gap-2">
{language.t("dialog.project.edit.icon")} <div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
</div> {language.t("dialog.project.edit.color")}
<div class="flex items-center gap-3"> </div>
<button <div class="-ml-1 flex gap-1.5">
type="button" <For each={PROJECT_AVATAR_VARIANTS}>
aria-label={language.t("dialog.project.edit.icon.alt")} {(color) => (
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus" <button
classList={{ type="button"
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver, aria-label={language.t("dialog.project.edit.color.select", { color })}
}} aria-pressed={getProjectAvatarVariant(model.store.color) === color}
onMouseEnter={() => model.setStore("iconHover", true)} class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{ classList={{
"opacity-100": model.store.iconHover, "bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
"opacity-0": !model.store.iconHover, getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}} }}
> >
<IconV2 name={model.store.iconOverride ? "close" : "share"} /> <ProjectAvatar
</span> fallback={model.store.name || model.defaultName()}
</button> variant={getProjectAvatarVariant(color)}
<input class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
ref={(element) => model.setIconInput(element)} />
type="file" </button>
accept="image/*" )}
class="hidden" </For>
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div> </div>
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
</div>
</Show>
</div> </div>
<Footer /> </Show>
</form>
</TabsV2.Content>
<TabsV2.Content value="scripts" class="project-settings-v2-panel"> <Field>
<form onSubmit={model.submit} class="project-settings-v2-form"> <Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<div class="project-settings-v2-scroll"> <Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<div class="project-settings-page-header"> <TextareaV2
<h2>{language.t("project.settings.scripts")}</h2> class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
<span>{language.t("project.settings.scripts.description")}</span> rows={3}
</div> value={model.store.startup}
<Field> placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label> spellcheck={false}
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix> onInput={(event) => model.setStore("startup", event.currentTarget.value)}
<TextareaV2 />
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono" </Field>
rows={5} </DialogBody>
value={model.store.startup} <DialogFooter>
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")} <ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
spellcheck={false} {language.t("common.cancel")}
onInput={(event) => model.setStore("startup", event.currentTarget.value)} </ButtonV2>
/> <ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
</Field> {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</div> </ButtonV2>
<Footer /> </DialogFooter>
</form> </form>
</TabsV2.Content>
<TabsV2.Content value="extensions" class="project-settings-v2-panel">
<ProjectSettingsExtensions />
</TabsV2.Content>
</TabsV2>
</Dialog> </Dialog>
) )
} }
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
const directory = () => decode64(local.slug()) const directory = () => decode64(local.slug())
const handleConnectProvider = () => { const handleConnectProvider = () => {
void dialog.show(() => <DialogConnectProvider directory={directory()} />) void dialog.show(() => <DialogConnectProvider directory={directory} />)
} }
const providerRank = (id: string) => popularProviders.indexOf(id) const providerRank = (id: string) => popularProviders.indexOf(id)
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
const directory = () => decode64(local.slug()) const directory = () => decode64(local.slug())
const handleConnectProvider = () => { const handleConnectProvider = () => {
void dialog.show(() => <DialogConnectProvider directory={directory()} />) void dialog.show(() => <DialogConnectProvider directory={directory} />)
} }
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) => const providerVisible = (providerID: string) =>
@@ -1,4 +1,4 @@
import { createSignal, Index, Show } from "solid-js" import { createSignal } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -83,71 +83,61 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
{/* Bottom section - buttons and indicators (fixed position) */} {/* Bottom section - buttons and indicators (fixed position) */}
<div class="flex flex-col gap-12"> <div class="flex flex-col gap-12">
<div class="flex flex-col items-start gap-3"> <div class="flex flex-col items-start gap-3">
<Show {isLast() ? (
when={isLast()}
fallback={
<Button variant="secondary" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")}
</Button>
}
>
<Button variant="primary" size="large" onClick={handleClose}> <Button variant="primary" size="large" onClick={handleClose}>
{language.t("dialog.releaseNotes.action.getStarted")} {language.t("dialog.releaseNotes.action.getStarted")}
</Button> </Button>
</Show> ) : (
<Button variant="secondary" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")}
</Button>
)}
<Button variant="ghost" size="small" onClick={handleDisable}> <Button variant="ghost" size="small" onClick={handleDisable}>
{language.t("dialog.releaseNotes.action.hideFuture")} {language.t("dialog.releaseNotes.action.hideFuture")}
</Button> </Button>
</div> </div>
<Show when={paged()}> {paged() && (
<div class="flex items-center gap-1.5 -my-2.5"> <div class="flex items-center gap-1.5 -my-2.5">
<Index each={props.highlights}> {props.highlights.map((_, i) => (
{(_, i) => ( <button
<button type="button"
type="button" class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200" classList={{
"w-8": i === index(),
"w-3": i !== index(),
}}
onClick={() => setIndex(i)}
>
<div
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
classList={{ classList={{
"w-8": i === index(), "bg-icon-strong-base": i === index(),
"w-3": i !== index(), "bg-icon-weak-base": i !== index(),
}} }}
onClick={() => setIndex(i)} />
> </button>
<div ))}
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
classList={{
"bg-icon-strong-base": i === index(),
"bg-icon-weak-base": i !== index(),
}}
/>
</button>
)}
</Index>
</div> </div>
</Show> )}
</div> </div>
</div> </div>
{/* Right side - Media content (edge to edge) */} {/* Right side - Media content (edge to edge) */}
<Show when={feature()?.media}> {feature()?.media && (
{(media) => ( <div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl"> {feature()!.media!.type === "image" ? (
<Show <img
when={media().type === "image"} src={feature()!.media!.src}
fallback={ alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
<video src={media().src} autoplay loop muted playsinline class="w-full h-full object-cover" /> class="w-full h-full object-cover"
} />
> ) : (
<img <video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
src={media().src} )}
alt={media().alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")} </div>
class="w-full h-full object-cover" )}
/>
</Show>
</div>
)}
</Show>
</div> </div>
</Dialog> </Dialog>
) )
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/server"
import type { Path } from "@/types" import type { Path } from "@/types"
import { import {
absoluteTreePath, absoluteTreePath,
@@ -6,7 +6,7 @@ import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createMemo, createResource, createSignal } from "solid-js" import { createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@/types" import type { Path } from "@/types"
@@ -9,6 +9,7 @@ import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
@@ -28,13 +29,14 @@ type DialogSelectFileMode = "all" | "files"
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings()
const filesOnly = () => props.mode === "files" const filesOnly = () => props.mode === "files"
if (!filesOnly()) { if (!filesOnly() && settings.general.newLayoutDesigns()) {
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} /> return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
} }
if (filesOnly() && platform.platform === "desktop") { if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} /> return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
} }
@@ -50,7 +52,7 @@ function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void
return ( return (
<DialogSelectFileV2 <DialogSelectFileV2
server={serverSDK.server} server={serverSDK().server}
mode="file" mode="file"
start={projectDirectory()} start={projectDirectory()}
title={language.t("session.header.searchFiles")} title={language.t("session.header.searchFiles")}
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme" import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useIntegrations } from "@/hooks/use-integrations" import { useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
@@ -22,7 +22,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
const dialog = useDialog() const dialog = useDialog()
const theme = useTheme() const theme = useTheme()
const directory = () => decode64(local.slug()) const directory = () => decode64(local.slug())
const integrations = useIntegrations(directory) const providers = useProviders(directory)
const language = useLanguage() const language = useLanguage()
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}` const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
const currentKey = createMemo(() => { const currentKey = createMemo(() => {
@@ -37,7 +37,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
void import("./dialog-connect-provider").then((x) => { void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController() const controller = x.useProviderConnectController()
controller.select(provider) controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />) void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
}) })
} }
@@ -127,8 +127,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
</div> </div>
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2"> <div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
<For <For
each={integrations each={[...providers.popular()]
.list()
.filter((provider) => featuredProviders.includes(provider.id)) .filter((provider) => featuredProviders.includes(provider.id))
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))} .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
> >
@@ -26,7 +26,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
void import("./dialog-connect-provider").then((x) => { void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController() const controller = x.useProviderConnectController()
controller.select(provider) controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />) void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
}) })
} }
@@ -148,7 +148,7 @@ export function ModelSelectorPopover(props: {
const handleConnectProvider = () => { const handleConnectProvider = () => {
close("provider") close("provider")
void import("./dialog-connect-provider").then((x) => { void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />) void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
}) })
} }
const language = useLanguage() const language = useLanguage()
@@ -240,7 +240,7 @@ export function ModelSelectorPopoverV2(props: {
trigger={props.trigger} trigger={props.trigger}
models={controller.models} models={controller.models}
groups={controller.groups} groups={controller.groups}
current={controller.current()} current={controller.current}
select={controller.select} select={controller.select}
onManage={() => { onManage={() => {
void import("./dialog-manage-models").then((module) => { void import("./dialog-manage-models").then((module) => {
@@ -295,7 +295,7 @@ function ModelSelectorPopoverV2View(props: {
trigger: ModelSelectorTrigger trigger: ModelSelectorTrigger
models: (search: string) => ModelItem[] models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[] groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: string | undefined current: () => string | undefined
select: (item: ModelItem) => void select: (item: ModelItem) => void
onManage: () => void onManage: () => void
onClose: () => void onClose: () => void
@@ -310,7 +310,7 @@ function ModelSelectorPopoverV2View(props: {
const groups = createMemo(() => props.groups(models())) const groups = createMemo(() => props.groups(models()))
const keys = () => [...models().map(modelKey), manageKey] const keys = () => [...models().map(modelKey), manageKey]
const initialActive = () => { const initialActive = () => {
const selected = props.current const selected = props.current()
const options = keys() const options = keys()
if (selected && options.includes(selected)) return selected if (selected && options.includes(selected)) return selected
return options[0] ?? "" return options[0] ?? ""
@@ -453,7 +453,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.GroupLabel class="gap-2 px-3"> <MenuV2.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span> <span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel> </MenuV2.GroupLabel>
<MenuV2.RadioGroup value={props.current}> <MenuV2.RadioGroup value={props.current()}>
<For each={group.items}> <For each={group.items}>
{(item) => ( {(item) => (
<TooltipV2 <TooltipV2
@@ -473,7 +473,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.RadioItem <MenuV2.RadioItem
value={modelKey(item)} value={modelKey(item)}
data-option-key={modelKey(item)} data-option-key={modelKey(item)}
data-selected-model={props.current === modelKey(item) ? true : undefined} data-selected-model={props.current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full" class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }} classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => { onMouseEnter={() => {
@@ -529,7 +529,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
const provider = () => { const provider = () => {
void import("./dialog-connect-provider").then((x) => { void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />) void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
}) })
} }
@@ -1,13 +1,17 @@
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { Show } from "solid-js" import { Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/server"
import { ServerCollectionController } from "@/components/server/server-management-controller" import { useSettings } from "@/context/settings"
import { type ServerDomainController } from "@/components/server/server-management-controller"
type ServerConnectionFormController = { type ServerConnectionFormController = {
state: { state: {
@@ -113,11 +117,12 @@ function ServerForm(props: ServerFormProps) {
} }
export function ServerConnectionList(props: { export function ServerConnectionList(props: {
domain: ServerCollectionController domain: ServerDomainController
onAdd: () => void onAdd: () => void
onEdit: (server: ServerConnection.Http) => void onEdit: (server: ServerConnection.Http) => void
}) { }) {
const language = useLanguage() const language = useLanguage()
const settings = useSettings()
return ( return (
<div class="flex flex-1 min-h-0 flex-col gap-4"> <div class="flex flex-1 min-h-0 flex-col gap-4">
@@ -131,6 +136,9 @@ export function ServerConnectionList(props: {
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={props.domain.collection.items} items={props.domain.collection.items}
key={(x) => x.http.url} key={(x) => x.http.url}
onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
}}
divider={true} divider={true}
> >
{(i) => { {(i) => {
@@ -155,6 +163,15 @@ export function ServerConnectionList(props: {
showCredentials showCredentials
/> />
<div class="flex items-center justify-center gap-4 pl-4"> <div class="flex items-center justify-center gap-4 pl-4">
<Show
when={
props.domain.collection.current() &&
ServerConnection.key(props.domain.collection.current()!) === key
}
>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}> <Show when={i.type === "http"}>
<DropdownMenu> <DropdownMenu>
<DropdownMenu.Trigger <DropdownMenu.Trigger
@@ -1,4 +1,4 @@
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/server"
import type { Platform } from "@/context/platform" import type { Platform } from "@/context/platform"
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) { export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
@@ -1,5 +1,5 @@
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/server"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { lazy } from "solid-js" import { lazy } from "solid-js"
+1 -1
View File
@@ -6,7 +6,7 @@ import { createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { type LocalProject } from "@/context/layout" import { type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/server"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) { export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const supported = !props.project.id || props.project.id === "global" const supported = !props.project.id || props.project.id === "global"
@@ -1,238 +0,0 @@
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
import { ExternalLink } from "./external-link"
type SkillItem = {
name: string
location: string
}
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
const ExtensionCard: Component<{ children: unknown }> = (props) => (
<div class="project-settings-extension-card">{props.children as any}</div>
)
const ExtensionRow: Component<{
icon: "mcp" | "cube" | "post-skill" | "code"
name: string
status?: string
children?: unknown
}> = (props) => (
<div class="project-settings-extension-row">
<div class="project-settings-extension-row-main">
<Icon name={props.icon} class="project-settings-extension-row-icon" />
<span class="project-settings-extension-row-name">{props.name}</span>
</div>
<Show when={props.status}>
{(status) => (
<span class="project-settings-extension-row-status">
<span class="project-settings-extension-row-status-dot" />
{status()}
</span>
)}
</Show>
{props.children as any}
</div>
)
const SharedSection: Component<{
count: number
children: unknown
}> = (props) => {
const language = useLanguage()
const [open, setOpen] = createSignal(false)
return (
<Show when={props.count > 0}>
<div class="project-settings-shared">
<button
type="button"
class="project-settings-shared-trigger"
aria-expanded={open()}
onClick={() => setOpen((value) => !value)}
>
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
<span>{language.t("project.settings.extensions.shared")}</span>
<span class="project-settings-shared-count">{props.count}</span>
</button>
<Show when={open()}>
<ExtensionCard>{props.children}</ExtensionCard>
</Show>
</div>
</Show>
)
}
export const ProjectSettingsExtensions: Component = () => {
const language = useLanguage()
const serverSDK = useServerSDK()
const directorySDK = useSDK()
const serverSync = useServerSync()
const sync = useSync()
const toggleMcp = useMcpToggle()
const [serverMcp] = createResource(
serverSDK,
(sdk) =>
sdk.api.mcp
.list()
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
.catch(() => ({})),
{ initialValue: {} },
)
const globalMcpNames = createMemo(() =>
[...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
)
const projectMcpNames = createMemo(() => {
const shared = new Set(globalMcpNames())
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
if (configured.length > 0) return configured.sort()
return Object.keys(sync().data.mcp ?? {})
.filter((name) => !shared.has(name))
.sort()
})
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
})
const [serverSkills] = createResource(
serverSDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const [directorySkills] = createResource(
directorySDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill
.list({ location: { directory: sdk.directory } })
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const projectSkills = createMemo(() => {
const shared = new Set(serverSkills.latest.map(skillKey))
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
})
const mcpRows = (items: string[]) => (
<For each={items}>
{(name) => (
<ExtensionRow icon="mcp" name={name}>
<Switch
checked={mcpEnabled(name)}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
hideLabel
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
>
{name}
</Switch>
</ExtensionRow>
)}
</For>
)
const pluginRows = (items: string[]) => <For each={items}>{(name) => <ExtensionRow icon="cube" name={name} />}</For>
const skillRows = (items: SkillItem[]) => (
<For each={items}>{(item) => <ExtensionRow icon="post-skill" name={item.name} />}</For>
)
return (
<div class="project-settings-extensions">
<div class="project-settings-page-header">
<h2>{language.t("settings.tab.extensions")}</h2>
<span>{language.t("project.settings.extensions.description")}</span>
</div>
<TabsV2 variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
<TabsV2.List>
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
</TabsV2.List>
<TabsV2.Content value="mcps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
</div>
<Show when={projectMcpNames().length > 0}>
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
</Show>
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="plugins">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
</div>
<Show when={projectPlugins().length > 0}>
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
</Show>
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="skills">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
{language.t("settings.extensions.addSkills")}
</ExternalLink>
</div>
<Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show>
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="lsps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
<span>{language.t("project.settings.extensions.lsp.description")}</span>
</div>
<Show when={sync().data.lsp.length > 0}>
<ExtensionCard>
<For each={sync().data.lsp}>
{(item) => (
<ExtensionRow
icon="code"
name={item.name || item.id}
status={
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
}
/>
)}
</For>
</ExtensionCard>
</Show>
</div>
</TabsV2.Content>
</TabsV2>
</div>
)
}
@@ -37,7 +37,6 @@ export type PromptInputV2ComposerProps = {
class?: string class?: string
controller: PromptInputV2ComposerController controller: PromptInputV2ComposerController
borderUnderlay?: boolean borderUnderlay?: boolean
accentSubmit?: boolean
} }
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission"> export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
@@ -54,7 +53,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<PromptInputV2 <PromptInputV2
controller={props.controller} controller={props.controller}
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay} borderUnderlay={props.borderUnderlay}
class={props.class} class={props.class}
variantControlVisible={!props.controller.model.loading} variantControlVisible={!props.controller.model.loading}
+2 -6
View File
@@ -45,6 +45,7 @@ import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -1517,7 +1518,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
role="textbox" role="textbox"
aria-multiline="true" aria-multiline="true"
aria-label={placeholder()} aria-label={placeholder()}
dir={store.mode === "normal" ? "auto" : "ltr"}
contenteditable="true" contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"} autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"} autocorrect={store.mode === "normal" ? "on" : "off"}
@@ -1539,11 +1539,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"[&_[data-type=agent]]:text-syntax-type": true, "[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell", "font-mono!": store.mode === "shell",
}} }}
style={{ style={{ "padding-bottom": space }}
"padding-bottom": space,
"unicode-bidi": store.mode === "normal" ? "plaintext" : undefined,
"text-align": "start",
}}
/> />
<div <div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate" class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
@@ -1,16 +1,18 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { Prompt, PromptStore } from "@/context/prompt" import type { Prompt, PromptStore } from "@/context/prompt"
import { ServerScope } from "@/utils/server-scope" import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
type SessionCreateInput = { const sessionCreateInputs: Array<{
agent?: string agent?: string
model?: { id: string; providerID: string; variant?: string } model?: { id: string; providerID: string; variant?: string }
location?: { directory: string } location?: { directory: string }
} }> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{ const optimistic: Array<{
directory?: string directory?: string
sessionID?: string sessionID?: string
@@ -20,9 +22,11 @@ const optimistic: Array<{
variant?: string variant?: string
} }
}> = [] }> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {} const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = [] const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = [] const sentPrompts: string[] = []
const promptInputs: unknown[] = [] const promptInputs: unknown[] = []
@@ -33,29 +37,15 @@ const switchedModels: Array<{
model: { id: string; providerID: string; variant?: string } model: { id: string; providerID: string; variant?: string }
}> = [] }> = []
const sessionRequestOrder: string[] = [] const sessionRequestOrder: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = [] const commands: Array<{ name: string }> = []
const syncedServers: string[] = []
const optimisticServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
let serverSessionSyncs = 0 let serverSessionSyncs = 0
let params: { id?: string } = {} let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
let selected = "/repo/worktree-a" let selected = "/repo/worktree-a"
let variant: string | undefined let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined let createSessionGate: Promise<void> | undefined
let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined
let locationFailure: Error | undefined
let worktreeCreates = 0
let activeSDK = "server-a"
let activeServerSync = "server-a"
let activeDirectorySync = "server-a"
let commands: Array<{ name: string }> = []
let worktreeDirectory = "/repo/new-0"
let worktreeID = 0
const draftServers: Record<string, string> = {}
const sessionDirectories: Record<string, string> = {}
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({ const [promptStore, setPromptStore] = createStore<PromptStore>({
@@ -83,25 +73,21 @@ const prompt = {
replaceComments: () => undefined, replaceComments: () => undefined,
items: () => [], items: () => [],
}, },
capture: (scope?: unknown, target?: unknown) => { capture: () => prompt,
promptCaptures.push({ scope, target })
return prompt
},
} }
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
const clientFor = (directory: string) => { const clientFor = (directory: string) => {
createdClients.push(directory)
return { return {
api: { api: {
session: { session: {
create: async (input: SessionCreateInput) => { create: async (input: (typeof sessionCreateInputs)[number]) => {
await createSessionGate await createSessionGate
const location = input.location?.directory ?? directory const location = input.location?.directory ?? directory
createdSessions.push(location) createdSessions.push(location)
const id = `session-${createdSessions.length}` sessionCreateInputs.push(input)
sessionDirectories[id] = location
return { return {
id, id: `session-${createdSessions.length}`,
projectID: "project", projectID: "project",
agent: input.agent, agent: input.agent,
model: input.model, model: input.model,
@@ -114,7 +100,7 @@ const clientFor = (directory: string) => {
}, },
prompt: async (input: unknown) => { prompt: async (input: unknown) => {
sessionRequestOrder.push("prompt") sessionRequestOrder.push("prompt")
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory) sentPrompts.push(directory)
promptInputs.push(input) promptInputs.push(input)
return { data: undefined } return { data: undefined }
}, },
@@ -134,21 +120,6 @@ const clientFor = (directory: string) => {
}, },
shell: async (input: { sessionID: string; id?: string; command: string }) => { shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input) sentShell.push(input)
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
},
},
worktree: {
create: async (_input: unknown) => {
worktreeCreates++
await createWorktreeGate
if (worktreeFailure) throw worktreeFailure
return { directory: worktreeDirectory }
},
},
location: {
get: async () => {
if (locationFailure) throw locationFailure
return { directory: worktreeDirectory }
}, },
}, },
}, },
@@ -156,6 +127,9 @@ const clientFor = (directory: string) => {
command: async () => ({ data: undefined }), command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }), abort: async () => ({ data: undefined }),
}, },
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
} }
} }
@@ -171,7 +145,6 @@ beforeAll(async () => {
mock.module("@opencode-ai/ui/toast", () => ({ mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null }, Toast: { Region: () => null },
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
showToast: () => 0, showToast: () => 0,
})) }))
@@ -189,13 +162,20 @@ beforeAll(async () => {
current: () => ({ name: "agent" }), current: () => ({ name: "agent" }),
}, },
session: { session: {
promote: () => undefined, promote(directory: string, sessionID: string) {
promoted.push({ directory, sessionID })
},
}, },
}), }),
})) }))
mock.module("@/context/permission", () => { mock.module("@/context/permission", () => {
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) } const state = (server: string) => ({
enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ server, sessionID, directory })
},
})
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
}) })
mock.module("@/context/server", () => ({ mock.module("@/context/server", () => ({
@@ -204,10 +184,7 @@ beforeAll(async () => {
mock.module("@/context/tabs", () => ({ mock.module("@/context/tabs", () => ({
useTabs: () => ({ useTabs: () => ({
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }), draft: () => ({ server: "project-server" }),
updateDraft: (draftID: string, draft: { worktree?: string }) => {
updatedDrafts.push({ draftID, ...draft })
},
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => { promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
promotedDrafts.push({ draftID, ...session }) promotedDrafts.push({ draftID, ...session })
}, },
@@ -218,72 +195,78 @@ beforeAll(async () => {
usePrompt: () => prompt, usePrompt: () => prompt,
})) }))
mock.module("@/context/layout", () => ({
useLayout: () => ({
handoff: {
setTabs: () => undefined,
},
}),
}))
mock.module("@/context/sdk", () => ({ mock.module("@/context/sdk", () => ({
useSDK: () => { useSDK: () => {
return () => ({ const sdk = {
scope: activeSDK === "server-a" ? ServerScope.local : "server-b", scope: "local",
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other", directory: "/repo/main",
api: rootClient.api, api: rootClient.api,
url: "http://localhost:4096", url: "http://localhost:4096",
}) }
return () => sdk
}, },
})) }))
mock.module("@/context/sync", () => ({ mock.module("@/context/sync", () => ({
useSync: () => () => { useSync: () => () => ({
const server = activeDirectorySync data: { command: commands },
return { session: {
data: { command: commands, project: "project" }, optimistic: {
session: { add: (value: {
optimistic: { directory?: string
add: (value: { sessionID?: string
directory?: string message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
sessionID?: string }) => {
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } optimistic.push(value)
}) => { optimisticSeeded.push(
optimisticServers.push(server) !!value.directory &&
optimistic.push(value) !!value.sessionID &&
}, !!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
remove: () => undefined, )
}, },
remove: () => undefined,
}, },
set: () => undefined, },
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" }, set: () => undefined,
} }),
},
})) }))
mock.module("@/context/server-sync", () => ({ mock.module("@/context/server-sync", () => ({
useServerSync: () => { useServerSync: () => () => ({
const server = activeServerSync session: {
return { remember: () => undefined,
session: { set: () => undefined,
remember: () => undefined, sync: async () => {
set: () => undefined, serverSessionSyncs++
sync: async () => { },
serverSessionSyncs++ },
child: (directory: string) => {
syncedDirectories.push(directory)
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
}, },
}, ]
child: (directory: string) => { },
syncedServers.push(server) }),
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
},
]
},
}
},
})) }))
mock.module("@/context/platform", () => ({ mock.module("@/context/platform", () => ({
@@ -303,141 +286,205 @@ beforeAll(async () => {
}) })
beforeEach(() => { beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0 createdSessions.length = 0
sessionCreateInputs.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0 optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
promotedDrafts.length = 0 promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
sentPrompts.length = 0 sentPrompts.length = 0
promptInputs.length = 0 promptInputs.length = 0
sentCommands.length = 0
switchedAgents.length = 0 switchedAgents.length = 0
switchedModels.length = 0 switchedModels.length = 0
sessionRequestOrder.length = 0 sessionRequestOrder.length = 0
syncedServers.length = 0 commands.length = 0
optimisticServers.length = 0 promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
promptCaptures.length = 0
params = {} params = {}
search = {} search = {}
sentShell.length = 0 sentShell.length = 0
sentShellDirectories.length = 0 syncedDirectories.length = 0
selected = "/repo/worktree-a" selected = "/repo/worktree-a"
variant = undefined variant = undefined
activeSDK = "server-a" permissionServer = "server-a"
activeServerSync = "server-a"
activeDirectorySync = "server-a"
commands = []
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
worktreeDirectory = `/repo/new-${++worktreeID}`
createSessionGate = undefined createSessionGate = undefined
serverSessionSyncs = 0 serverSessionSyncs = 0
createWorktreeGate = undefined
worktreeFailure = undefined
locationFailure = undefined
worktreeCreates = 0
for (const key of Object.keys(draftServers)) delete draftServers[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
for (const key of Object.keys(storedSessions)) delete storedSessions[key] for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
const event = { preventDefault: () => undefined } as unknown as Event
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
...overrides,
})
describe("prompt submit worktree selection", () => { describe("prompt submit worktree selection", () => {
test("admits only one concurrent new-workspace submission", async () => { test("reads the latest worktree accessor value per submit", async () => {
selected = "create" const submit = createPromptSubmit({
let release = () => {} prompt,
createWorktreeGate = new Promise<void>((resolve) => { info: () => undefined,
release = resolve imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
}) })
const submit = makeSubmit()
const first = submit.handleSubmit(event) const event = { preventDefault: () => undefined } as unknown as Event
const duplicate = submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
release() await submit.handleSubmit(event)
await Promise.all([first, duplicate]) selected = "/repo/worktree-b"
expect(createdSessions).toEqual([worktreeDirectory]) await submit.handleSubmit(event)
await settle()
expect(worktreeCreates).toBe(1) expect(createdClients).toEqual([])
expect(createdSessions).toHaveLength(1) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentPrompts).toEqual([worktreeDirectory]) expect(sessionCreateInputs).toEqual([
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-a" },
},
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-b" },
},
])
expect(sentShell).toEqual([
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(serverSessionSyncs).toBe(0)
expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" },
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
}) })
test("stops when the created workspace cannot initialize", async () => { test("applies auto-accept to newly created sessions", async () => {
selected = "create" const submit = createPromptSubmit({
locationFailure = new Error("initialization failed") prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
await makeSubmit().handleSubmit(event) const event = { preventDefault: () => undefined } as unknown as Event
expect(worktreeCreates).toBe(1) await submit.handleSubmit(event)
expect(createdSessions).toEqual([])
expect(sentPrompts).toEqual([]) expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
}) })
test("keeps async submission effects bound to the initiating context", async () => { test("keeps auto-accept bound to the submission server", async () => {
search = { draftId: "draft-1" }
draftServers["draft-1"] = "project-server-a"
draftServers["draft-2"] = "project-server-b"
let release = () => {} let release = () => {}
createSessionGate = new Promise<void>((resolve) => { createSessionGate = new Promise<void>((resolve) => {
release = resolve release = resolve
}) })
let submitted = 0 const submit = createPromptSubmit({
const submit = makeSubmit({ prompt,
onSubmit: () => submitted++, info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
}) })
const result = submit.handleSubmit(event) const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
activeSDK = "server-b" permissionServer = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
search.draftId = "draft-2"
release() release()
await result await result
await settle()
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }]) expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }]) })
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
expect(optimisticServers).toEqual(["server-a"]) test("promotes drafts using the selected project's server", async () => {
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local }) search = { draftId: "draft-1" }
expect(submitted).toBe(0) const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
}) })
test("switches the selected agent and model before prompting", async () => { test("switches the selected agent and model before prompting", async () => {
params = { id: "session-1" } params = { id: "session-1" }
variant = "high" variant = "high"
const submit = makeSubmit({ const submit = createPromptSubmit({
prompt,
info: () => ({ info: () => ({
id: "session-1", id: "session-1",
agent: "old-agent", agent: "old-agent",
model: { id: "old-model", providerID: "old-provider" }, model: { id: "old-model", providerID: "old-provider" },
}), }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
onSubmit: () => undefined,
}) })
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event) await submit.handleSubmit(event)
await Bun.sleep(0) await Bun.sleep(0)
@@ -472,12 +519,24 @@ describe("prompt submit worktree selection", () => {
commands.push({ name: "review" }) commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }] promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = makeSubmit({ const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }), info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
}) })
await submit.handleSubmit(event) await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
await settle()
expect(sentCommands).toEqual([ expect(sentCommands).toEqual([
{ {
@@ -493,19 +552,66 @@ describe("prompt submit worktree selection", () => {
expect(serverSessionSyncs).toBe(0) expect(serverSessionSyncs).toBe(0)
}) })
test("sends an initial shell after synchronous workspace creation", async () => { test("uses an injected model selection", async () => {
selected = "create" params = { id: "session-1" }
const submit = makeSubmit({ const model = {
mode: () => "shell", current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
variant: { current: () => "draft-variant" },
} as unknown as ModelSelection
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
model,
}) })
await submit.handleSubmit(event) await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
await settle()
expect(sentShellDirectories).toEqual([worktreeDirectory]) expect(optimistic[0]).toMatchObject({
expect(sentShell[0]).toMatchObject({ message: {
sessionID: "session-1", model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
command: "ls", },
}) })
}) })
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
expect(optimisticSeeded).toEqual([true])
})
}) })
+289 -216
View File
@@ -8,12 +8,14 @@ import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync" import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal, type ModelSelection } from "@/context/local" import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk" import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync" import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { getDirectory } from "@opencode-ai/core/util/path" import { getDirectory } from "@opencode-ai/core/util/path"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
@@ -23,7 +25,12 @@ import { createPromptSubmissionState } from "./submission-state"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { blobDataUrl } from "@/utils/draft-store" import { blobDataUrl } from "@/utils/draft-store"
const submitting = new Set<string>() type PendingPrompt = {
abort: AbortController
cleanup: VoidFunction
}
const pending = new Map<string, PendingPrompt>()
export type FollowupDraft = { export type FollowupDraft = {
sessionID: string sessionID: string
@@ -43,6 +50,7 @@ type FollowupSendInput = {
draft: FollowupDraft draft: FollowupDraft
messageID?: string messageID?: string
optimisticBusy?: boolean optimisticBusy?: boolean
before?: () => Promise<boolean> | boolean
} }
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("") const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
@@ -62,11 +70,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" }) input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
} }
const wait = async () => {
const ok = await input.before?.()
if (ok === false) return false
return true
}
const [head, ...tail] = text.split(" ") const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) { if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
setBusy() setBusy()
try { try {
if (!(await wait())) {
setIdle()
return false
}
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
await input.api.command({ await input.api.command({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
@@ -140,6 +159,14 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}) })
try { try {
if (!(await wait())) {
batch(() => {
setIdle()
remove()
})
return false
}
const session = input.session() const session = input.session()
if (session?.agent !== input.draft.agent) { if (session?.agent !== input.draft.agent) {
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent }) await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
@@ -231,10 +258,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const local = useLocal() const local = useLocal()
const permission = usePermission() const permission = usePermission()
const prompt = input.prompt const prompt = input.prompt
const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs() const tabs = useTabs()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => { const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
if (err && typeof err === "object" && "data" in err) { if (err && typeof err === "object" && "data" in err) {
@@ -248,10 +278,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const abort = async () => { const abort = async () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return Promise.resolve() if (!sessionID) return Promise.resolve()
serverSync.session.set("todo", sessionID, [])
serverSync().session.set("todo", sessionID, [])
input.onAbort?.() input.onAbort?.()
const key = pendingKey(sessionID)
const queued = pending.get(key)
if (queued) {
queued.abort.abort()
queued.cleanup()
pending.delete(key)
return Promise.resolve()
}
return sdk() return sdk()
.api.session.interrupt({ sessionID }) .api.session.interrupt({ sessionID })
.catch(() => {}) .catch(() => {})
@@ -280,9 +319,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const seed = (target: ServerSync, dir: string, info: SessionInfo) => { const seed = (dir: string, info: SessionInfo) => {
target.session.remember(info) serverSync().session.remember(info)
const [, setStore] = target.child(dir) const [, setStore] = serverSync().child(dir)
setStore("session", (list: SessionInfo[]) => { setStore("session", (list: SessionInfo[]) => {
const result = Binary.search(list, info.id, (item) => item.id) const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list] const next = [...list]
@@ -314,6 +353,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (input.working()) void abort() if (input.working()) void abort()
return return
} }
const modelSelection = input.model ?? local.model const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current() const currentModel = modelSelection.current()
const currentAgent = local.agent.current() const currentAgent = local.agent.current()
@@ -326,251 +366,284 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return return
} }
const submissionSDK = sdk() input.addToHistory(currentPrompt, mode)
const submissionSync = sync() input.resetHistoryNavigation()
const submissionServerSync = serverSync
const submissionScope = submissionSDK.scope const projectDirectory = sdk().directory
const projectDirectory = submissionSDK.directory const permissionState = permission.currentServerState()
const sessionID = params.id const isNewSession = !params.id
const isNewSession = !sessionID
const currentSession = input.info()
const draftID = search.draftId
const draftServer = draftID ? tabs.draft(draftID).server : undefined
const capturePrompt = prompt.capture
const localSession = local.session
const resetWorktree = input.onNewSessionWorktreeReset
const onSubmit = input.onSubmit
const permissionState = permission
const shouldAutoAccept = isNewSession && input.autoAccept() const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main" const worktreeSelection = input.newSessionWorktree?.() || "main"
const submissionKey = ScopedKey.from(
submissionScope,
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
)
if (submitting.has(submissionKey)) return
submitting.add(submissionKey)
try { let sessionDirectory = projectDirectory
input.addToHistory(currentPrompt, mode) if (isNewSession) {
input.resetHistoryNavigation() if (worktreeSelection === "create") {
const createdWorktree = await sdk()
let sessionDirectory = projectDirectory .api.projectCopy.create({
if (isNewSession) { projectID: sync().data.project,
if (worktreeSelection === "create") { strategy: "git_worktree",
const createdWorktree = await submissionSDK.api.worktree directory: getDirectory(projectDirectory),
.create({ location: { directory: projectDirectory },
projectID: submissionSync.data.project,
strategy: "git",
directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory),
})
.then(async (created) => {
await submissionSDK.api.location.get({ location: { directory: created.directory } })
return created
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
})
})
if (!createdWorktree) return
sessionDirectory = createdWorktree.directory
}
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
if (sessionDirectory !== projectDirectory) {
submissionServerSync.child(sessionDirectory)
}
}
let session = currentSession
if (!session && isNewSession) {
const created = await submissionSDK.api.session
.create({
agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
}) })
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"), title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err), description: errorMessage(err),
}) })
return undefined return undefined
}) })
if (created) { if (!createdWorktree) return
seed(submissionServerSync, sessionDirectory, created) WorktreeState.pending(sdk().scope, createdWorktree.directory)
session = created sessionDirectory = createdWorktree.directory
await startTransition(() => { }
if (!session) return
if (draftID) tabs.updateDraft(draftID, { worktree: undefined }) if (worktreeSelection !== "main" && worktreeSelection !== "create") {
if (!draftID) resetWorktree?.() sessionDirectory = worktreeSelection
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory) }
localSession.promote(sessionDirectory, session.id, {
agent: currentAgent.name, if (sessionDirectory !== projectDirectory) {
model: { providerID: currentModel.provider.id, modelID: currentModel.id }, serverSync().child(sessionDirectory)
variant: variant ?? null, }
})
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id }) input.onNewSessionWorktreeReset?.()
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) }
submission.retarget(
capturePrompt( let session = input.info()
{ dir: base64Encode(sessionDirectory), id: session.id }, if (!session && isNewSession) {
{ server: draftServer, scope: submissionScope }, const created = await sdk()
), .api.session.create({
) agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
}) })
} return undefined
}
if (!session) {
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: language.t("prompt.toast.promptSendFailed.description"),
}) })
return if (created) {
} seed(sessionDirectory, created)
session = created
const model = { await startTransition(() => {
modelID: currentModel.id, if (!session) return
providerID: currentModel.provider.id, if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
} local.session.promote(sessionDirectory, session.id, {
const agent = currentAgent.name agent: currentAgent.name,
const draft: FollowupDraft = { model: { providerID: currentModel.provider.id, modelID: currentModel.id },
sessionID: session.id, variant: variant ?? null,
sessionDirectory, })
prompt: currentPrompt, layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
context, const draftID = search.draftId
agent, if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
model, else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
variant, submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
}
const clearInput = () => {
submission.clear()
input.setMode("normal")
input.setPopover(null)
}
const restoreInput = () => {
const restored = submission.restore()
if (!restored) return false
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
if (!submission.current(prompt.capture())) return true
input.setMode(mode)
input.setPopover(null)
requestAnimationFrame(() => {
const editor = input.editor()
if (!editor) return
editor.focus()
setCursorPosition(editor, input.promptLength(currentPrompt))
input.queueScroll()
}) })
return true
} }
}
if (!session) {
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: language.t("prompt.toast.promptSendFailed.description"),
})
return
}
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { const model = {
input.onQueue?.(draft) modelID: currentModel.id,
clearContext(submission.target()) providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const draft: FollowupDraft = {
sessionID: session.id,
sessionDirectory,
prompt: currentPrompt,
context,
agent,
model,
variant,
}
const clearInput = () => {
submission.clear()
input.setMode("normal")
input.setPopover(null)
}
const restoreInput = () => {
const restored = submission.restore()
if (!restored) return false
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
if (!submission.current(prompt.capture())) return true
input.setMode(mode)
input.setPopover(null)
requestAnimationFrame(() => {
const editor = input.editor()
if (!editor) return
editor.focus()
setCursorPosition(editor, input.promptLength(currentPrompt))
input.queueScroll()
})
return true
}
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext(submission.target())
clearInput()
return
}
input.onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
sdk()
.api.session.shell({
sessionID: session.id,
id: eventID,
command: text,
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
})
restoreInput()
})
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync().data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput() clearInput()
return const messageID = Identifier.ascending("message")
} serverSync().session.set("session_status", session.id, { type: "busy" })
sdk()
if (!draftID || search.draftId === draftID) onSubmit?.() .api.session.command({
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
void submissionSDK.api.session
.shell({
sessionID: session.id, sessionID: session.id,
id: eventID, id: messageID,
command: text, command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
}) })
.catch((err) => { .catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
showToast({ showToast({
title: language.t("prompt.toast.shellSendFailed.title"), title: language.t("prompt.toast.commandSendFailed.title"),
description: errorMessage(err), description: formatServerError(err, language.t, language.t("common.requestFailed")),
}) })
restoreInput() restoreInput()
}) })
return return
} }
}
if (text.startsWith("/")) { const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const [cmdName, ...args] = text.split(" ") const messageID = Identifier.ascending("message")
const commandName = cmdName.slice(1)
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
const messageID = Identifier.ascending("message")
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
void submissionSDK.api.session
.command({
sessionID: session.id,
id: messageID,
command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
})
.catch((err) => {
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
})
restoreInput()
})
return
}
}
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) const removeOptimisticMessage = () => {
const messageID = Identifier.ascending("message") sync().session.optimistic.remove({
directory: sessionDirectory,
const removeOptimisticMessage = () => { sessionID: session.id,
submissionSync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
void sendFollowupDraft({
api: submissionSDK.api.session,
sync: submissionSync,
serverSync: submissionServerSync,
session: () => session,
draft,
messageID, messageID,
optimisticBusy: sessionDirectory === projectDirectory, })
}).catch((err) => { }
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) {
sync().set("session_status", session.id, { type: "busy" })
}
const controller = new AbortController()
const cleanup = () => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
submissionSync.set("session_status", session.id, { type: "idle" }) sync().set("session_status", session.id, { type: "idle" })
} }
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
removeOptimisticMessage() removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems) if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
}
pending.set(pendingKey(session.id), { abort: controller, cleanup })
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
resolve({ status: "failed", message: "aborted" })
return
}
controller.signal.addEventListener(
"abort",
() => {
resolve({ status: "failed", message: "aborted" })
},
{ once: true },
)
}) })
} finally {
submitting.delete(submissionKey) const timeoutMs = 5 * 60 * 1000
const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => {
resolve({
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs)
})
const result = await Promise.race([
WorktreeState.wait(sdk().scope, sessionDirectory),
abortWait,
timeout,
]).finally(() => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
pending.delete(pendingKey(session.id))
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
} }
void sendFollowupDraft({
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
session: () => input.info() ?? session,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
before: waitForWorktree,
}).catch((err) => {
pending.delete(pendingKey(session.id))
if (sessionDirectory === projectDirectory) {
sync().set("session_status", session.id, { type: "idle" })
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
})
} }
return { return {
@@ -1,52 +1,35 @@
import { createMemo, createSignal, For, Show } from "solid-js" import { For, Show } from "solid-js"
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 { Icon } from "@opencode-ai/ui/v2/icon" 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 { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { sameDirectory } from "@/utils/workspace"
export function PromptWorkspaceSelector(props: { export function PromptWorkspaceSelector(props: {
value: string value: string
projectRoot: string projectRoot: string
workspaces: string[] workspaces: string[]
branch?: string branch?: string
onboarding?: boolean
onChange: (value: string) => void onChange: (value: string) => void
onDone: () => void onDone: () => void
onViewAll: () => void
}) { }) {
const language = useLanguage() const language = useLanguage()
const [search, setSearch] = createSignal("") let pending: string | undefined
let searchInput: HTMLInputElement | undefined const selected = () => (props.value === props.projectRoot ? "main" : props.value)
let focusSearch = false
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
const workspaces = createMemo(() => {
const query = search().trim().toLowerCase()
if (!query) return props.workspaces
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
})
const icon = () => { const icon = () => {
if (selected() === "main") return "monitor" if (selected() === "main") return "monitor"
if (selected() === "create") return "workspace-new" if (selected() === "create") return "workspace-new"
return "workspace-isolated" return "workspace"
} }
const select = (value: string) => { const select = (value: string) => {
pending = { type: "select", value } pending = value
} }
const onOpenChange = (open: boolean) => { const onOpenChange = (open: boolean) => {
if (open) { if (open) return
setSearch("") const value = pending
return
}
const action = pending
pending = undefined pending = undefined
if (action?.type === "select") props.onChange(action.value) if (value) props.onChange(value)
if (action?.type === "viewAll") {
props.onViewAll()
return
}
props.onDone() props.onDone()
} }
const label = () => { const label = () => {
@@ -58,214 +41,87 @@ export function PromptWorkspaceSelector(props: {
return ( return (
<> <>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span> <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2 <MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
placement="top" <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">
openDelay={800} <IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
value={ <span class="min-w-0 truncate">{label()}</span>
props.onboarding ? ( <Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<div class="flex flex-col gap-1 text-start"> </MenuV2.Trigger>
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base"> <MenuV2.Portal>
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" /> <MenuV2.Content class="w-[180px]">
<span>{language.t("workspace.onboarding.title")}</span> <MenuV2.Group>
</div> <MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span> <MenuV2.Item onSelect={() => select("main")}>
</div> <IconV2 name="monitor" />
) : ( <span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
language.t("session.new.workspace.trigger.tooltip") <Show when={selected() === "main"}>
) <Icon name="check" size="small" class="shrink-0" />
} </Show>
contentClass={props.onboarding ? "max-w-[280px]" : undefined} </MenuV2.Item>
class="min-w-0" <MenuV2.Item onSelect={() => select("create")}>
> <IconV2 name="workspace-new" />
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}> <span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<MenuV2.Trigger <Show when={selected() === "create"}>
aria-description={language.t("session.new.workspace.trigger.tooltip")} <Icon name="check" size="small" class="shrink-0" />
class="flex h-6 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" </Show>
> </MenuV2.Item>
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" /> </MenuV2.Group>
<span class="min-w-0 truncate">{label()}</span> <Show when={props.workspaces.length > 0}>
<Show when={props.onboarding}> <MenuV2.Separator />
<span <MenuV2.Sub gutter={0} overlap overflowPadding={8}>
data-slot="workspace-onboarding-dot" <MenuV2.SubTrigger>
aria-hidden="true" <IconV2 name="workspace" />
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent" {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> </Show>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" /> </MenuV2.Content>
</MenuV2.Trigger> </MenuV2.Portal>
<MenuV2.Portal> </MenuV2>
<MenuV2.Content class="w-[200px]"> <PromptGitStatus branch={props.branch} />
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<Icon name="monitor" />
<TooltipV2
placement="right"
openDelay={800}
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("session.new.workspace.local")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.local.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
</TooltipV2>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
<MenuV2.Item onSelect={() => select("create")}>
<Icon name="workspace-new" />
<TooltipV2
placement="right"
openDelay={800}
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("workspace.new")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.new.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</TooltipV2>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show
when={props.workspaces.length > 0}
fallback={
<>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</>
}
>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Sub
gutter={0}
overlap
overflowPadding={8}
onOpenChange={(open) => {
if (!open) {
focusSearch = false
return
}
if (!focusSearch || props.workspaces.length < 10) return
focusSearch = false
requestAnimationFrame(() => searchInput?.focus())
}}
>
<MenuV2.SubTrigger
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
event.key === "ArrowLeft" ||
event.key === "Enter" ||
event.key === " "
)
focusSearch = true
}}
>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<Show when={props.workspaces.length >= 10}>
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
searchInput = element
}}
value={search()}
placeholder={language.t("session.new.workspace.search.placeholder")}
aria-label={language.t("session.new.workspace.search.placeholder")}
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) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter"
)
return
event.stopPropagation()
}}
/>
</div>
</Show>
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<Icon 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.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
</> </>
) )
} }
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) { export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
const language = useLanguage() const language = useLanguage()
const label = () => { const label = () => {
if (props.noGit) return language.t("session.new.git.none") if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
return props.branch return props.branch
} }
const icon = () => {
if (props.noGit) return "monitor"
if (props.from) return "branch-out"
return "branch"
}
return ( return (
<Show when={label()}> <Show when={label()}>
{(value) => ( {(value) => (
<TooltipV2 <>
placement="top" <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
value={value()} <TooltipV2
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`} placement="top"
contentClass="max-w-[calc(100vw-32px)] break-all" value={value()}
> class="min-w-0 max-w-[220px]"
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint"> contentClass="max-w-[calc(100vw-32px)] break-all"
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" /> >
<span class="min-w-0 truncate">{value()}</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]">
</div> <Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</TooltipV2> <span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
</>
)} )}
</Show> </Show>
) )
@@ -27,8 +27,8 @@ export function createProviderConnectionController(options: {
const [integration] = createResource( const [integration] = createResource(
() => ({ provider: options.provider(), directory: options.directory() }), () => ({ provider: options.provider(), directory: options.directory() }),
(input) => (input) =>
serverSDK.api.integration serverSDK()
.get({ integrationID: input.provider, location: location() }) .api.integration.get({ integrationID: input.provider, location: location() })
.then((result) => result.data), .then((result) => result.data),
) )
const methods = createMemo<ProviderConnectMethod[]>(() => { const methods = createMemo<ProviderConnectMethod[]>(() => {
@@ -120,14 +120,14 @@ export function createProviderConnectionController(options: {
cancelPolling() cancelPolling()
const directory = options.directory() const directory = options.directory()
await queryClient await queryClient
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null)) .refetchQueries(serverSync().queryOptions.providers(directory ? pathKey(directory) : null))
.catch(() => undefined) .catch(() => undefined)
if (polling.disposed) return if (polling.disposed) return
options.onComplete() options.onComplete()
} }
const poll = async (authorization: Authorization, generation: number) => { const poll = async (authorization: Authorization, generation: number) => {
const result = await serverSDK.api.integration.oauth const result = await serverSDK()
.status({ .api.integration.oauth.status({
integrationID: options.provider(), integrationID: options.provider(),
attemptID: authorization.attemptID, attemptID: authorization.attemptID,
location: location(), location: location(),
@@ -175,8 +175,8 @@ export function createProviderConnectionController(options: {
return return
} }
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
const result = await serverSDK.api.integration.oauth const result = await serverSDK()
.connect({ .api.integration.oauth.connect({
integrationID: options.provider(), integrationID: options.provider(),
methodID: selected.id, methodID: selected.id,
...(answer ? { answer } : {}), ...(answer ? { answer } : {}),
@@ -197,7 +197,7 @@ export function createProviderConnectionController(options: {
dispatch({ type: "method.reset" }) dispatch({ type: "method.reset" })
} }
const connectKey = async (key: string) => { const connectKey = async (key: string) => {
await serverSDK.api.integration.connect.key({ await serverSDK().api.integration.connect.key({
integrationID: options.provider(), integrationID: options.provider(),
location: location(), location: location(),
key, key,
@@ -208,8 +208,8 @@ export function createProviderConnectionController(options: {
const completeCode = async (code: string) => { const completeCode = async (code: string) => {
const authorization = store.authorization const authorization = store.authorization
if (!authorization) return language.t("provider.connect.oauth.code.invalid") if (!authorization) return language.t("provider.connect.oauth.code.invalid")
const result = await serverSDK.api.integration.oauth const result = await serverSDK()
.complete({ .api.integration.oauth.complete({
integrationID: options.provider(), integrationID: options.provider(),
attemptID: authorization.attemptID, attemptID: authorization.attemptID,
location: location(), location: location(),
@@ -238,7 +238,6 @@ export function createProviderConnectionController(options: {
return { return {
loading: () => integration.loading, loading: () => integration.loading,
integration: () => integration.latest,
methods, methods,
currentMethod, currentMethod,
methodIndex: () => store.methodIndex, methodIndex: () => store.methodIndex,
@@ -3,7 +3,7 @@ import { createMemo, createResource } from "solid-js"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { ServerConnection, useServers } from "@/context/servers" import { ServerConnection, useServer } from "@/context/server"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
@@ -49,7 +49,7 @@ function useDefaultServer() {
} }
export function useServerActionsController() { export function useServerActionsController() {
const server = useServers() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
@@ -72,16 +72,28 @@ export function useServerActionsController() {
export type ServerActionsController = ReturnType<typeof useServerActionsController> export type ServerActionsController = ReturnType<typeof useServerActionsController>
export function useServerCollectionController() { export function useServerCollectionController() {
const server = useServers() const server = useServer()
const global = useGlobal() const global = useGlobal()
const settings = useSettings() const settings = useSettings()
const actions = useServerActionsController() const actions = useServerActionsController()
const items = createMemo(() => server.list) const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
})
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
)
const sorted = createMemo(() => { const sorted = createMemo(() => {
const raw = items() const raw = items()
const list = raw const list = raw
if (!list.length) return list if (!list.length) return list
const active = current()
const order = new Map(list.map((item, index) => [item, index] as const)) const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => { const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0 if (value?.healthy === true) return 0
@@ -89,6 +101,8 @@ export function useServerCollectionController() {
return 1 return 1
} }
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)]) rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff if (diff !== 0) return diff
@@ -99,6 +113,7 @@ export function useServerCollectionController() {
return { return {
collection: { collection: {
items: sorted, items: sorted,
current,
health: () => global.servers.health, health: () => global.servers.health,
}, },
...actions, ...actions,
@@ -106,3 +121,21 @@ export function useServerCollectionController() {
} }
export type ServerCollectionController = ReturnType<typeof useServerCollectionController> export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const collection = useServerCollectionController()
const select = async (connection: ServerConnection.Any) => {
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
options.onSelect?.()
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
}
return { ...collection, selection: { select } }
}
export type ServerDomainController = ReturnType<typeof useServerDomainController>
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/server"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management" import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
function deferred<T>() { function deferred<T>() {
@@ -65,17 +65,20 @@ describe("replaceServerConnection", () => {
const calls: string[] = [] const calls: string[] = []
replaceServerConnection(ServerConnection.key(original), next, { replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: (key) => calls.push(`tabs:${key}`), removeTabs: (key) => calls.push(`tabs:${key}`),
add: (server) => { add: (server) => {
calls.push(`add:${ServerConnection.key(server)}`) calls.push(`add:${ServerConnection.key(server)}`)
return server return server
}, },
setActive: (key) => calls.push(`active:${key}`),
remove: (key) => calls.push(`remove:${key}`), remove: (key) => calls.push(`remove:${key}`),
}) })
expect(calls).toEqual([ expect(calls).toEqual([
"tabs:https://old.example.com", "tabs:https://old.example.com",
"add:https://new.example.com", "add:https://new.example.com",
"active:https://new.example.com",
"remove:https://old.example.com", "remove:https://old.example.com",
]) ])
}) })
@@ -84,8 +87,10 @@ describe("replaceServerConnection", () => {
const removed: ServerConnection.Key[] = [] const removed: ServerConnection.Key[] = []
replaceServerConnection(ServerConnection.key(original), next, { replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: () => {}, removeTabs: () => {},
add: () => undefined, add: () => undefined,
setActive: () => {},
remove: (key) => removed.push(key), remove: (key) => removed.push(key),
}) })
@@ -1,4 +1,4 @@
import { normalizeServerUrl, ServerConnection } from "@/context/servers" import { normalizeServerUrl, ServerConnection } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health" import type { ServerHealth } from "@/utils/server-health"
export type ServerFormValues = { export type ServerFormValues = {
@@ -42,13 +42,18 @@ export function replaceServerConnection(
originalKey: ServerConnection.Key, originalKey: ServerConnection.Key,
next: ServerConnection.Http, next: ServerConnection.Http,
operations: { operations: {
active: () => ServerConnection.Key | undefined
removeTabs: (key: ServerConnection.Key) => void removeTabs: (key: ServerConnection.Key) => void
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
setActive: (key: ServerConnection.Key) => void
remove: (key: ServerConnection.Key) => void remove: (key: ServerConnection.Key) => void
}, },
) { ) {
const active = operations.active()
operations.removeTabs(originalKey) operations.removeTabs(originalKey)
const added = operations.add(next) const added = operations.add(next)
if (!added) return if (!added) return
const nextActive = active === originalKey ? ServerConnection.key(added) : active
if (nextActive) operations.setActive(nextActive)
operations.remove(originalKey) operations.remove(originalKey)
} }
@@ -4,7 +4,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js" import { type Component, Show } from "solid-js"
import type { ServerActionsController } from "@/components/server/server-management-controller" import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{ export const ServerRowMenu: Component<{
server: ServerConnection.Any server: ServerConnection.Any
@@ -11,7 +11,7 @@ import {
Show, Show,
} from "solid-js" } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type ServerConnection, serverName } from "@/context/servers" import { type ServerConnection, serverName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health" import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps { interface ServerRowProps extends ParentProps {
@@ -96,16 +96,13 @@ export function ServerRow(props: ServerRowProps) {
{(conn) => ( {(conn) => (
<div class="flex flex-row gap-3"> <div class="flex flex-row gap-3">
<span> <span>
<Show {conn().http.username ? (
when={conn().http.username}
fallback={<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>}
>
<span class="text-text-weak">{conn().http.username}</span> <span class="text-text-weak">{conn().http.username}</span>
</Show> ) : (
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
)}
</span> </span>
<Show when={conn().http.password}> {conn().http.password && <span class="text-text-weak"></span>}
<span class="text-text-weak"></span>
</Show>
</div> </div>
)} )}
</Show> </Show>
@@ -39,7 +39,8 @@ function openSessionContext(args: {
}) { }) {
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button") args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
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").then(() => args.tabs.setActive("context")) void args.tabs.open("context")
args.tabs.setActive("context")
} }
export function SessionContextUsage(props: SessionContextUsageProps) { export function SessionContextUsage(props: SessionContextUsageProps) {
@@ -1,136 +0,0 @@
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/types"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSettingsDialog } from "@/components/settings-dialog"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/utils/workspace"
export function SessionWorkspaceMenu(props: {
eligible?: boolean
sessionID: string
project: Project
directory: string
placement?: ComponentProps<typeof MenuV2>["placement"]
gutter?: number
class?: string
contentClass?: string
children: JSX.Element
onOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
const workspaces = () =>
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
const onOpenChange = (open: boolean) => {
props.onOpenChange?.(open)
if (!open) return
const sdk = serverSDK
void sdk.api.worktree
.refresh({ projectID: props.project.id })
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
.then((items) =>
setDirectories(
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
),
)
.catch(() => undefined)
}
const move = async (selection: "create" | string) => {
if (store.selected || blocked()) return
const sdk = serverSDK
const sessionID = props.sessionID
setStore("selected", selection)
try {
const destination = selection === "create" ? await createWorkspace(props.project, sdk) : selection
if (!destination) return
await sdk.api.session.move({ sessionID, directory: destination })
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.move.failed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
} finally {
setStore("selected", undefined)
}
}
return (
<MenuV2
placement={props.placement ?? "bottom-end"}
gutter={props.gutter ?? 4}
modal={false}
onOpenChange={onOpenChange}
>
<MenuV2.Trigger class={props.class} disabled={blocked()}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
<Icon name="monitor" />
{language.t("session.new.workspace.local")}
</MenuV2.Item>
</Show>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</MenuV2.Item>
<Show when={workspaces().length > 0}>
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Group>
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
<MenuV2.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
async function createWorkspace(project: Project, serverSDK: ReturnType<typeof useServerSDK>) {
const created = await serverSDK.api.worktree.create({
projectID: project.id,
strategy: "git",
directory: getDirectory(project.worktree),
})
await serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
}
@@ -2,9 +2,9 @@ import { createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
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 { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useServer } from "@/context/server"
export const OPEN_APPS = [ export const OPEN_APPS = [
"vscode", "vscode",
@@ -169,7 +169,7 @@ export function useOpenInApp(input: { directory: () => string }) {
app: undefined as OpenApp | undefined, app: undefined as OpenApp | undefined,
}) })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo( const current = createMemo(
() => () =>
options().find((o) => o.id === prefs.app) ?? options().find((o) => o.id === prefs.app) ??
@@ -1,12 +1,32 @@
import { Tooltip } from "@opencode-ai/ui/tooltip" import { AppIcon } from "@opencode-ai/ui/app-icon"
import { createMemo, Show } from "solid-js" import { Button } from "@opencode-ai/ui/button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@/utils/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { StatusPopoverV2 } from "../status-popover" import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { fileManagerApp } from "@/utils/file-manager"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
@@ -14,15 +34,206 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { reviewTooltipKeybind } from "../command-tooltip-keybind" import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar" import { useTitlebarRightMount } from "../titlebar"
const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"warp",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
const MAC_APPS = [
{
id: "vscode",
label: "session.header.open.app.vscode",
icon: "vscode",
openWith: "Visual Studio Code",
},
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
{
id: "antigravity",
label: "session.header.open.app.antigravity",
icon: "antigravity",
openWith: "Antigravity",
},
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
{
id: "android-studio",
label: "session.header.open.app.androidStudio",
icon: "android-studio",
openWith: "Android Studio",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "powershell",
label: "session.header.open.app.powershell",
icon: "powershell",
openWith: "powershell",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const LINUX_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
}
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
export function SessionHeader() { export function SessionHeader() {
const layout = useLayout()
const command = useCommand() const command = useCommand()
const server = useServer()
const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const { view } = useSessionLayout() const sync = useSync()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const name = createMemo(() => {
const current = project()
if (current) return current.name || getFilename(current.worktree)
return getFilename(projectDirectory())
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isV2 = settings.general.newLayoutDesigns
const search = settings.visibility.search
const status = settings.visibility.status const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
})
const apps = createMemo(() => {
if (os() === "macos") return MAC_APPS
if (os() === "windows") return WINDOWS_APPS
return LINUX_APPS
})
const fileManager = createMemo(() => fileManagerApp(os()))
createEffect(() => {
if (platform.platform !== "desktop") return
if (!platform.checkAppExists) return
const list = apps()
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
})
const options = createMemo(() => {
return [
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
...apps()
.filter((app) => exists[app.id])
.map((app) => ({ ...app, label: language.t(app.label) })),
] as const
})
const toggleTerminal = () => {
const next = !view().terminal.opened()
view().terminal.toggle()
if (!next) return
const id = terminal.active()
if (!id) return
focusTerminalById(id)
}
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
})
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
options()[0] ??
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
const opening = createMemo(() => openRequest.app !== undefined)
const tint = createMemo(() =>
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
)
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({ const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(), statusVisible: status(),
statusLabel: language.t("status.popover.trigger"), statusLabel: language.t("status.popover.trigger"),
@@ -33,16 +244,275 @@ export function SessionHeader() {
onReviewToggle: () => view().reviewPanel.toggle(), onReviewToggle: () => view().reviewPanel.toggle(),
})) }))
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
}
const openDir = (app: OpenApp) => {
if (opening() || !canOpen() || !platform.openPath) return
const directory = projectDirectory()
if (!directory) return
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
platform
.openPath(directory, openWith)
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = () => {
const directory = projectDirectory()
if (!directory) return
navigator.clipboard
.writeText(directory)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: directory,
})
})
.catch((err: unknown) => showRequestError(language, err))
}
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
const rightMount = useTitlebarRightMount() const rightMount = useTitlebarRightMount()
onMount(() => {
setCenterMount(document.getElementById("opencode-titlebar-center"))
})
return ( return (
<Show when={rightMount()} keyed> <>
{(mount) => ( <Show when={search() && centerMount()} keyed>
<Portal mount={mount}> {(mount) => (
<SessionHeaderV2Actions state={v2ActionsState()} /> <Portal mount={mount}>
</Portal> <Button
)} type="button"
</Show> variant="ghost"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
<div class="flex min-w-0 flex-1 items-center overflow-visible">
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", {
project: name(),
})}
</span>
</div>
<Show when={hotkey()} keyed>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind}
</Keybind>
)}
</Show>
</Button>
</Portal>
)}
</Show>
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<Show
when={isV2}
fallback={
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
onClick={() => openDir(current().id)}
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
</div>
</Button>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
}}
>
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
disabled={opening()}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-1">
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</div>
</div>
</div>
}
>
<SessionHeaderV2Actions state={v2ActionsState()} />
</Show>
</Portal>
)}
</Show>
</>
) )
} }
@@ -57,6 +527,8 @@ type SessionHeaderV2ActionsState = {
} }
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
const language = useLanguage()
return ( return (
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Show when={props.state.statusVisible}> <Show when={props.state.statusVisible}>
@@ -12,7 +12,7 @@ import { FileVisual } from "./session-sortable-tab"
export function SortableTabV2(props: { export function SortableTabV2(props: {
tab: string tab: string
index: number index: () => number
temporary?: boolean temporary?: boolean
onTabClose: (tab: string) => void onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void onTabDoubleClick?: (tab: string) => void
@@ -26,7 +26,7 @@ export function SortableTabV2(props: {
return props.tab return props.tab
}, },
get index() { get index() {
return props.index return props.index()
}, },
}) })
const path = createMemo(() => file.pathFromTab(props.tab)) const path = createMemo(() => file.pathFromTab(props.tab))
@@ -14,7 +14,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
export function SortableTerminalTabV2(props: { export function SortableTerminalTabV2(props: {
terminal: LocalPTY terminal: LocalPTY
index: number index: () => number
newLayout: boolean newLayout: boolean
onClose?: () => void onClose?: () => void
}): JSX.Element { }): JSX.Element {
@@ -25,7 +25,7 @@ export function SortableTerminalTabV2(props: {
return props.terminal.id return props.terminal.id
}, },
get index() { get index() {
return props.index return props.index()
}, },
}) })
const [store, setStore] = createStore({ const [store, setStore] = createStore({
+329 -13
View File
@@ -1,6 +1,10 @@
import { For, Show, createMemo, lazy, onCleanup } from "solid-js" import { Component, For, Show, createMemo, lazy, 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 { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
@@ -9,6 +13,7 @@ import fuzzysort from "fuzzysort"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command" import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list"
import { SettingsListV2 } from "./settings-v2/parts/list" import { SettingsListV2 } from "./settings-v2/parts/list"
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon }))) const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
@@ -196,6 +201,69 @@ function filteredFor(
return out return out
} }
function useKeyCapture(input: {
active: () => string | null
stop: () => void
set: (id: string, keybind: string) => void
used: () => Map<string, { id: string; title: string }[]>
language: ReturnType<typeof useLanguage>
}) {
onMount(() => {
const handle = (event: KeyboardEvent) => {
const id = input.active()
if (!id) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
input.stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
input.set(id, "none")
input.stop()
return
}
const next = recordKeybind(event)
if (!next) return
const conflicts = new Map<string, string>()
for (const sig of signatures(next)) {
for (const item of input.used().get(sig) ?? []) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
showToast({
title: input.language.t("settings.shortcuts.conflict.title"),
description: input.language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next, input.language.t),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
input.set(id, next)
input.stop()
}
makeEventListener(document, "keydown", handle, { capture: true })
})
}
export function createKeybindSettingsController( export function createKeybindSettingsController(
input: { input: {
command: Pick<CommandContext, "catalog" | "options" | "keybinds"> command: Pick<CommandContext, "catalog" | "options" | "keybinds">
@@ -342,7 +410,7 @@ export function createKeybindSettingsController(
} }
} }
export function SettingsKeybinds() { function SettingsKeybindsV2() {
const command = useCommand() const command = useCommand()
const settings = useSettings() const settings = useSettings()
const controller = createKeybindSettingsController({ const controller = createKeybindSettingsController({
@@ -356,9 +424,9 @@ export function SettingsKeybinds() {
filtered={controller.catalog.filtered} filtered={controller.catalog.filtered}
title={controller.catalog.title} title={controller.catalog.title}
keybind={controller.catalog.keybind} keybind={controller.catalog.keybind}
active={controller.capture.active()} active={controller.capture.active}
onCapture={controller.capture.toggle} onCapture={controller.capture.toggle}
hasOverrides={controller.settings.hasOverrides()} hasOverrides={controller.settings.hasOverrides}
onReset={controller.settings.reset} onReset={controller.settings.reset}
/> />
) )
@@ -369,9 +437,9 @@ function SettingsKeybindsV2View(props: {
filtered: (query: string) => Map<KeybindGroup, string[]> filtered: (query: string) => Map<KeybindGroup, string[]>
title: (id: string) => string title: (id: string) => string
keybind: (id: string) => string keybind: (id: string) => string
active: string | null active: () => string | null
onCapture: (id: string) => void onCapture: (id: string) => void
hasOverrides: boolean hasOverrides: () => boolean
onReset: () => void onReset: () => void
}) { }) {
const language = useLanguage() const language = useLanguage()
@@ -383,11 +451,8 @@ function SettingsKeybindsV2View(props: {
<> <>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked"> <div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row"> <div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1"> <h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2> <ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.shortcuts.description")}</span>
</div>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides}>
{language.t("settings.shortcuts.reset.button")} {language.t("settings.shortcuts.reset.button")}
</ButtonV2> </ButtonV2>
</div> </div>
@@ -433,12 +498,12 @@ function SettingsKeybindsV2View(props: {
data-keybind-id={id} data-keybind-id={id}
classList={{ classList={{
"settings-v2-keybind-button": true, "settings-v2-keybind-button": true,
"settings-v2-keybind-button--active": props.active === id, "settings-v2-keybind-button--active": props.active() === id,
}} }}
onClick={() => props.onCapture(id)} onClick={() => props.onCapture(id)}
> >
<Show <Show
when={props.active === id} when={props.active() === id}
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")} fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
> >
{language.t("settings.shortcuts.pressKeys")} {language.t("settings.shortcuts.pressKeys")}
@@ -463,3 +528,254 @@ function SettingsKeybindsV2View(props: {
</> </>
) )
} }
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
if (props.v2) return <SettingsKeybindsV2 />
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const [store, setStore] = createStore({
active: null as string | null,
filter: "",
})
const stop = () => {
if (!store.active) return
setStore("active", null)
command.keybinds(true)
}
const start = (id: string) => {
if (store.active === id) {
stop()
return
}
if (store.active) stop()
setStore("active", id)
command.keybinds(false)
}
const map = createMemo(() => keybinds(settings.current.keybinds))
const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string"))
const resetAll = () => {
stop()
settings.keybinds.resetAll()
showToast({
title: language.t("settings.shortcuts.reset.toast.title"),
description: language.t("settings.shortcuts.reset.toast.description"),
})
}
const list = createMemo(() => {
language.locale()
return listFor(command, map(), language.t("command.palette"))
})
const title = (id: string) => list().get(id)?.title ?? ""
const grouped = createMemo(() => groupedFor(list()))
const filtered = createMemo(() => {
return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "")
})
const hasResults = createMemo(() => {
for (const group of GROUPS) {
const ids = filtered().get(group) ?? []
if (ids.length > 0) return true
}
return false
})
const used = createMemo(() => {
const map = new Map<string, { id: string; title: string }[]>()
const add = (key: string, value: { id: string; title: string }) => {
const list = map.get(key)
if (!list) {
map.set(key, [value])
return
}
list.push(value)
}
const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
for (const sig of signatures(palette)) {
add(sig, { id: PALETTE_ID, title: title(PALETTE_ID) })
}
const valueFor = (id: string) => {
const custom = settings.keybinds.get(id)
if (typeof custom === "string") return custom
const live = command.options.find((x) => x.id === id)
if (live?.keybind) return live.keybind
const meta = command.catalog.find((x) => x.id === id)
return meta?.keybind
}
for (const id of list().keys()) {
if (id === PALETTE_ID) continue
for (const sig of signatures(valueFor(id))) {
add(sig, { id, title: title(id) })
}
}
return map
})
const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind)
useKeyCapture({
active: () => store.active,
stop,
set: setKeybind,
used,
language,
})
onCleanup(() => {
if (store.active) command.keybinds(true)
})
const emptyResults = (
<Show when={store.filter && !hasResults()}>
<div
classList={{
"flex flex-col items-center justify-center py-12 text-center": !props.v2,
"settings-v2-shortcuts-status": props.v2,
}}
>
<span
classList={{
"text-14-regular text-text-weak": !props.v2,
}}
>
{language.t("settings.shortcuts.search.empty")}
</span>
<Show when={store.filter}>
<span
classList={{
"text-14-regular text-text-strong mt-1": !props.v2,
"settings-v2-shortcuts-status-filter": props.v2,
}}
>
&quot;{store.filter}&quot;
</span>
</Show>
</div>
</Show>
)
const List = props.v2 ? SettingsListV2 : SettingsList
const groups = (
<div
classList={{
"settings-v2-shortcuts flex flex-col gap-8": props.v2,
"flex flex-col gap-8 max-w-[720px]": !props.v2,
}}
>
<For each={GROUPS}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div
classList={{
"settings-v2-section": props.v2,
"flex flex-col gap-1": !props.v2,
}}
>
<h3
classList={{
"settings-v2-section-title": props.v2,
"text-14-medium text-text-strong pb-2": !props.v2,
}}
>
{language.t(groupKey[group])}
</h3>
<List>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span
classList={{
"text-14-regular text-text-strong": !props.v2,
}}
>
{title(id)}
</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-v2-keybind-button": props.v2,
"settings-v2-keybind-button--active": props.v2 && store.active === id,
"h-8 px-3 rounded-md text-12-regular": !props.v2,
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
!props.v2 && store.active !== id,
"border border-border-weak-base bg-surface-inset-base text-text-weak":
!props.v2 && store.active === id,
}}
onClick={() => start(id)}
>
<Show
when={store.active === id}
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</List>
</div>
</Show>
)}
</For>
{emptyResults}
</div>
)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={store.filter}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
</div>
{groups}
</div>
)
}
@@ -1,26 +0,0 @@
import { type ParentProps, Show } from "solid-js"
import { useGlobal } from "@/context/global"
import { ModelsProvider } from "@/context/models"
import { ServerProvider } from "@/context/server"
import { ServerConnection } from "@/context/servers"
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
const global = useGlobal()
return (
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
{(server) => (
<SettingsServerDataScope server={server} directory={props.directory}>
{props.children}
</SettingsServerDataScope>
)}
</Show>
)
}
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
return (
<ServerProvider conn={props.server}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</ServerProvider>
)
}
@@ -1,134 +0,0 @@
import { Component, createMemo } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useLanguage } from "@/context/language"
import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { createAppearanceSettingsController, type AppearanceSettingsController } from "./general-controllers"
import "./settings-v2.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = {
ui: {
action: "settings-ui-font",
title: "settings.general.row.uiFont.title",
description: "settings.general.row.uiFont.description",
font: "ui",
input: "setUI",
},
code: {
action: "settings-code-font",
title: "settings.general.row.font.title",
description: "settings.general.row.font.description",
font: "code",
input: "setCode",
},
terminal: {
action: "settings-terminal-font",
title: "settings.general.row.terminalFont.title",
description: "settings.general.row.terminalFont.description",
font: "terminal",
input: "setTerminal",
},
} as const
const FontSetting: Component<{
kind: "ui" | "code" | "terminal"
fonts: AppearanceSettingsController["fonts"]
}> = (props) => {
const language = useLanguage()
const config = () => fontSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action={config().action}
type="text"
appearance="base"
value={props.fonts[config().font]().value}
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
placeholder={props.fonts[config().font]().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t(config().title)}
style={{ "font-family": props.fonts[config().font]().family }}
/>
</div>
</SettingsRowV2>
)
}
export const SettingsAppearanceV2: Component = () => {
const language = useLanguage()
const appearance = createAppearanceSettingsController()
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.appearance")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.appearance.description")}</span>
</div>
</div>
</div>
<div class="settings-v2-tab-body">
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={schemeOptions}
current={schemeOptions.find((option) => option === appearance.scheme.current())}
placement="bottom-end"
gutter={6}
label={(option) => {
if (option === "system") return language.t("theme.scheme.system")
if (option === "light") return language.t("theme.scheme.light")
return language.t("theme.scheme.dark")
}}
onSelect={(option) => option && appearance.scheme.select(option)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</ExternalLink>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={appearance.theme.options()}
current={appearance.theme.current()}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => option.name}
onSelect={appearance.theme.select}
/>
</SettingsRowV2>
<FontSetting kind="ui" fonts={appearance.fonts} />
<FontSetting kind="code" fonts={appearance.fonts} />
<FontSetting kind="terminal" fonts={appearance.fonts} />
</SettingsListV2>
</div>
</div>
</>
)
}
@@ -13,7 +13,7 @@ import {
} from "@/components/server/server-management" } from "@/components/server/server-management"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { normalizeServerUrl, ServerConnection, useServers } from "@/context/servers" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useCheckServerHealth } from "@/utils/server-health" import { useCheckServerHealth } from "@/utils/server-health"
import "./settings-v2.css" import "./settings-v2.css"
@@ -146,7 +146,7 @@ export const DialogServerV2: Component<{
} }
function createFormController(options: { onSelect?: () => void } = {}) { function createFormController(options: { onSelect?: () => void } = {}) {
const server = useServers() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const global = useGlobal() const global = useGlobal()
const language = useLanguage() const language = useLanguage()
@@ -173,7 +173,8 @@ function createFormController(options: { onSelect?: () => void } = {}) {
}) })
} }
const allServers = () => { const allServers = () => {
return server.list if (!server.current || server.list.includes(server.current)) return server.list
return [server.current, ...server.list]
} }
const editing = createMemo(() => const editing = createMemo(() =>
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl), allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
@@ -181,8 +182,10 @@ function createFormController(options: { onSelect?: () => void } = {}) {
const add = (connection: ServerConnection.Http) => server.add(connection) const add = (connection: ServerConnection.Http) => server.add(connection)
const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) => const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
replaceServerConnection(originalKey, next, { replaceServerConnection(originalKey, next, {
active: () => server.key,
removeTabs: (key) => tabs.removeServer(key), removeTabs: (key) => tabs.removeServer(key),
add, add,
setActive: (key) => server.setActive(key),
remove: (key) => server.remove(key), remove: (key) => server.remove(key),
}) })
@@ -1,26 +1,19 @@
import { Component, createEffect, createMemo, createSignal, startTransition } from "solid-js" import { Component, createMemo, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2" import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { SettingsGeneral } from "./general" import { SettingsGeneralV2 } from "./general"
import { SettingsAppearanceV2 } from "./appearance"
import { SettingsKeybinds } from "../settings-keybinds" import { SettingsKeybinds } from "../settings-keybinds"
import { SettingsNotificationsV2 } from "./notifications"
import { SettingsProvidersV2 } from "./providers" import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models" import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServersV2 } from "./servers" import { SettingsServersV2 } from "./servers"
import { SettingsWorkspacesV2 } from "./workspaces"
import { SettingsProjectsV2 } from "./projects"
import { SettingsExtensionsV2 } from "./extensions"
import { SettingsServerScope } from "../settings-server-picker"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useGlobal, useServerCtx } from "@/context/global" import { useServerSync } from "@/context/server-sync"
import { ServerConnection, useServers } from "@/context/servers"
import "./settings-v2.css"
export const DialogSettings: Component<{ export const DialogSettings: Component<{
sessionID?: string sessionID?: string
@@ -30,40 +23,17 @@ export const DialogSettings: Component<{
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const layout = useLayout() const layout = useLayout()
const servers = useServers()
const tabs = useTabs() const tabs = useTabs()
const global = useGlobal() const serverSync = useServerSync()
const [tab, setTab] = createSignal(props.defaultValue ?? "general") const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const server = createMemo(() => {
const route = layout.route()
switch (route.type) {
case "draft": {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return servers.list.find((item) => ServerConnection.key(item) === draft?.server)
}
case "session":
return servers.list.find((item) => ServerConnection.key(item) === route.server)
case "home":
return servers.list.find((item) => ServerConnection.key(item) === layout.home.selection().server)
}
})
const serverCtx = useServerCtx(server)
createEffect(() => {
const current = server()
if (current) global.settings.server.set(ServerConnection.key(current))
})
const directory = createMemo(() => { const directory = createMemo(() => {
const selected = global.settings.server.selected()
const current = server()
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
const route = layout.route() const route = layout.route()
if (route.type === "dir-new-sesssion") return route.dir
if (route.type === "draft") { if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined return draft?.type === "draft" ? draft.directory : undefined
} }
if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory
return undefined return undefined
}) })
@@ -82,99 +52,62 @@ export const DialogSettings: Component<{
> >
<TabsV2.List> <TabsV2.List>
<div class="flex flex-col justify-between h-full w-full"> <div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-4 w-full"> <div class="flex flex-col gap-3 w-full">
{/* Group 1: Preferences */} <div class="flex flex-col gap-3">
<div class="flex flex-col gap-1 w-full"> <div class="flex flex-col gap-1.5">
<TabsV2.Trigger value="general"> <TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
<Icon name="sliders" /> <div class="flex flex-col gap-1.5 w-full">
{language.t("settings.tab.preferences")} <TabsV2.Trigger value="general">
</TabsV2.Trigger> <Icon name="sliders" />
<TabsV2.Trigger value="appearance"> {language.t("settings.tab.general")}
<Icon name="appearance" /> </TabsV2.Trigger>
{language.t("settings.general.section.appearance")} <TabsV2.Trigger value="shortcuts">
</TabsV2.Trigger> <Icon name="keyboard" />
<TabsV2.Trigger value="notifications"> {language.t("settings.tab.shortcuts")}
<Icon name="notifications" /> </TabsV2.Trigger>
{language.t("settings.tab.notifications")} </div>
</TabsV2.Trigger> </div>
<TabsV2.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</TabsV2.Trigger>
</div>
{/* Group 2: Environment & Workspaces */} <div class="flex flex-col gap-1.5">
<div class="flex flex-col gap-1 w-full"> <TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<TabsV2.Trigger value="servers"> <div class="flex flex-col gap-1.5 w-full">
<Icon name="server" /> <TabsV2.Trigger value="servers">
{language.t("status.popover.tab.servers")} <Icon name="server" />
</TabsV2.Trigger> {language.t("status.popover.tab.servers")}
<TabsV2.Trigger value="projects"> </TabsV2.Trigger>
<Icon name="folder" /> <TabsV2.Trigger value="providers">
{language.t("settings.tab.projects")} <Icon name="providers" />
</TabsV2.Trigger> {language.t("settings.providers.title")}
<TabsV2.Trigger value="workspaces"> </TabsV2.Trigger>
<Icon name="workspace-isolated" /> <TabsV2.Trigger value="models">
{language.t("settings.tab.workspaces")} <Icon name="models" />
</TabsV2.Trigger> {language.t("settings.models.title")}
</div> </TabsV2.Trigger>
</div>
{/* Group 3: Capabilities & Extensions */} </div>
<div class="flex flex-col gap-1 w-full">
<TabsV2.Trigger value="providers">
<Icon name="providers" />
{language.t("settings.providers.title")}
</TabsV2.Trigger>
<TabsV2.Trigger value="models">
<Icon name="models" />
{language.t("settings.models.title")}
</TabsV2.Trigger>
<TabsV2.Trigger value="extensions">
<Icon name="extensions" />
{language.t("settings.tab.extensions")}
</TabsV2.Trigger>
</div> </div>
</div> </div>
<div class="settings-v2-nav-footer"> <div class="settings-v2-nav-footer">
<span>{language.t("app.name.desktop")}</span> <span>{language.t("app.name.desktop")}</span>
<span>v{platform.version}</span> <span>v{platform.version}</span>
</div> </div>
</div> </div>
</TabsV2.List> </TabsV2.List>
<TabsV2.Content value="general" class="settings-v2-panel"> <TabsV2.Content value="general" class="settings-v2-panel">
<SettingsGeneral server={server()} sessionID={props.sessionID} /> <SettingsGeneralV2 sessionID={props.sessionID} />
</TabsV2.Content>
<TabsV2.Content value="appearance" class="settings-v2-panel">
<SettingsAppearanceV2 />
</TabsV2.Content>
<TabsV2.Content value="notifications" class="settings-v2-panel">
<SettingsNotificationsV2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="shortcuts" class="settings-v2-panel"> <TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds /> <SettingsKeybinds v2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel"> <TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 /> <SettingsServersV2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="projects" class="settings-v2-panel"> <TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProjectsV2 /> <SettingsProvidersV2 directory={directory} onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
</TabsV2.Content> </TabsV2.Content>
<SettingsServerScope directory={directory()}>
<TabsV2.Content value="workspaces" class="settings-v2-panel">
<SettingsWorkspacesV2 activeDirectory={directory()} />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
</TabsV2.Content>
<TabsV2.Content value="extensions" class="settings-v2-panel">
<SettingsExtensionsV2 />
</TabsV2.Content>
</SettingsServerScope>
</TabsV2> </TabsV2>
</Dialog> </Dialog>
) )
@@ -1,154 +0,0 @@
import { Component, For, createMemo, createResource } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css"
interface McpRowItem {
name: string
enabled: boolean
}
interface PluginRowItem {
name: string
}
export const SettingsExtensionsV2: Component = () => {
const language = useLanguage()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const mcps = createMemo<McpRowItem[]>(() => {
const configMcp = serverSync.data.config.mcp ?? {}
return Object.entries(configMcp).map(([name, config]) => ({
name,
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
}))
})
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
const before = serverSync.data.config.mcp ?? {}
const config = before[item.name]
if (typeof config !== "object" || config === null) return
const next = { ...before, [item.name]: { ...config, enabled: checked } }
serverSync.set("config", "mcp", next)
void serverSync.updateConfig({ mcp: next }).catch(() => serverSync.set("config", "mcp", before))
}
const plugins = createMemo<PluginRowItem[]>(() => {
const raw = serverSync.data.config.plugin ?? []
return raw.map((item) => {
const name = typeof item === "string" ? item : item[0]
return { name }
})
})
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
initialValue: [],
})
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-v2-tab-body">
<TabsV2 variant="pill" defaultValue="mcps" class="settings-v2-extensions-tabs">
<TabsV2.List>
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
</TabsV2.List>
<TabsV2.Content value="mcps">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={mcps()}>
{(item) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
</div>
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
{item.name}
</Switch>
</div>
)}
</For>
</div>
</div>
</TabsV2.Content>
<TabsV2.Content value="plugins">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={plugins()}>
{(plugin) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
</div>
</div>
)}
</For>
</div>
</div>
</TabsV2.Content>
<TabsV2.Content value="skills">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<ExternalLink
class="text-13-regular text-v2-text-accent hover:underline"
href="https://opencode.ai/docs/skills/"
>
{language.t("settings.extensions.addSkills")}
</ExternalLink>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={skills()}>
{(skill) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
</div>
</div>
)}
</For>
</div>
</div>
</TabsV2.Content>
</TabsV2>
</div>
</>
)
}
@@ -17,24 +17,17 @@ import {
} from "@/context/settings" } from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior" import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
import { ServerConnection } from "@/context/servers"
import { useGlobal, useServerCtx } from "@/context/global"
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior" export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
export function createPermissionScopeController( export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
server: Accessor<ServerConnection.Any | undefined>, const permission = usePermission()
sessionID: Accessor<string | undefined>, const serverSync = useServerSync()
) {
const serverCtx = useServerCtx(server)
const permission = () => serverCtx()?.permission
const directory = createMemo(() => { const directory = createMemo(() => {
const s = server()
const id = sessionID() const id = sessionID()
if (!s || !id) return undefined if (!id) return undefined
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory return serverSync().session.lineage.peek(id)?.session.location.directory
}) })
return { return {
@@ -42,21 +35,21 @@ export function createPermissionScopeController(
const id = sessionID() const id = sessionID()
const dir = directory() const dir = directory()
if (!id || !dir) return false if (!id || !dir) return false
return permission()?.isAutoAccepting(id, dir) return permission.isAutoAccepting(id, dir)
}), }),
enabled: createMemo(() => !!directory()), enabled: createMemo(() => !!directory()),
set: (checked: boolean) => { set: (checked: boolean) => {
const id = sessionID() const id = sessionID()
const dir = directory() const dir = directory()
if (!id || !dir) return if (!id || !dir) return
if (checked) return permission()?.enableAutoAccept(id, dir) if (checked) return permission.enableAutoAccept(id, dir)
permission()?.disableAutoAccept(id, dir) permission.disableAutoAccept(id, dir)
}, },
} }
} }
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) { export function createShellSettingsController() {
const serverCtx = useServerCtx(server) const serverSync = useServerSync()
const [shells] = createResource( const [shells] = createResource(
async () => { async () => {
// TODO: Dax is considering the V2 shell discovery and config update APIs. // TODO: Dax is considering the V2 shell discovery and config update APIs.
@@ -65,7 +58,7 @@ export function createShellSettingsController(server: Accessor<ServerConnection.
}, },
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
) )
const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "") const current = createMemo(() => serverSync().data.config.shell ?? "")
return { return {
shells: () => shells.latest, shells: () => shells.latest,
@@ -73,7 +66,7 @@ export function createShellSettingsController(server: Accessor<ServerConnection.
select: (value: string) => { select: (value: string) => {
if (value === current()) return if (value === current()) return
// TODO: Dax is considering the V2 shell discovery and config update APIs. // TODO: Dax is considering the V2 shell discovery and config update APIs.
// void serverSync.updateConfig({ shell: value }) // void serverSync().updateConfig({ shell: value })
}, },
} }
} }
@@ -7,7 +7,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useUpdaterAction } from "../updater-action" import { useUpdaterAction } from "../updater-action"
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { ExternalLink } from "../external-link" import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
@@ -24,7 +24,6 @@ import {
type SoundSettingsController, type SoundSettingsController,
} from "./general-controllers" } from "./general-controllers"
import "./settings-v2.css" import "./settings-v2.css"
import { ServerConnection } from "@/context/servers"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = { const fontSettings = {
@@ -86,34 +85,6 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
) )
} }
const WorkspaceDestinationSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
{ value: "local", label: language.t("settings.workspaces.default.local") },
{ value: "new", label: language.t("settings.workspaces.default.new") },
])
return (
<SettingsRowV2
title={language.t("settings.workspaces.default.title")}
description={language.t("settings.workspaces.default.description")}
>
<SelectV2
appearance="inline"
options={options()}
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
/>
</SettingsRowV2>
)
}
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage() const language = useLanguage()
const options = createMemo(() => const options = createMemo(() =>
@@ -298,20 +269,18 @@ const LanguageSetting = () => {
) )
} }
export const SettingsGeneral: Component<{ export const SettingsGeneralV2: Component<{
sessionID?: string sessionID?: string
server?: ServerConnection.Any
}> = (props) => { }> = (props) => {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings() const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction() const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController( const permissionScope = createPermissionScopeController(() => props.sessionID)
() => props.server, const shell = createShellSettingsController()
() => props.sessionID, const appearance = createAppearanceSettingsController()
) const sounds = createSoundSettingsController()
const shell = createShellSettingsController(() => props.server)
const desktop = createMemo(() => platform.platform === "desktop") const desktop = createMemo(() => platform.platform === "desktop")
const [pinchZoom, { mutate: setPinchZoom }] = createResource( const [pinchZoom, { mutate: setPinchZoom }] = createResource(
@@ -329,11 +298,9 @@ export const SettingsGeneral: Component<{
const GeneralSection = () => ( const GeneralSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.general")}</h3>
<SettingsListV2> <SettingsListV2>
<LanguageSetting /> <LanguageSetting />
<WorkspaceDestinationSetting />
<PermissionScopeSetting controller={permissionScope} /> <PermissionScopeSetting controller={permissionScope} />
<ShellSetting controller={shell} /> <ShellSetting controller={shell} />
@@ -396,6 +363,18 @@ export const SettingsGeneral: Component<{
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3> <h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
<SettingsListV2> <SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.showSearch.title")} title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")} description={language.t("settings.general.row.showSearch.description")}
@@ -531,18 +510,18 @@ export const SettingsGeneral: Component<{
return ( return (
<> <>
<div class="settings-v2-tab-header"> <div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row"> <h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.preferences")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("settings.preferences.description")}
</span>
</div>
</div>
</div> </div>
<div class="settings-v2-tab-body"> <div class="settings-v2-tab-body">
<GeneralSection /> <GeneralSection />
<AppearanceSection controller={appearance} />
<NotificationsSection />
<SoundsSection controller={sounds} />
<Show when={desktop()}> <Show when={desktop()}>
<UpdatesSection /> <UpdatesSection />
</Show> </Show>
@@ -11,7 +11,6 @@ import { useModels } from "@/context/models"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { InlineServerSelect } from "./parts/server-select"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css" import "./settings-v2.css"
@@ -25,7 +24,7 @@ export const SettingsModelsV2: Component = () => {
const models = useModels() const models = useModels()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const [store, setStore] = persisted( const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"), Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record<string, boolean> }), createStore({ collapsed: {} as Record<string, boolean> }),
) )
@@ -54,13 +53,7 @@ export const SettingsModelsV2: Component = () => {
return ( return (
<> <>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked"> <div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row"> <h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
</div>
<InlineServerSelect />
</div>
<div class="settings-v2-tab-search"> <div class="settings-v2-tab-search">
<TextInputV2 <TextInputV2
type="search" type="search"
@@ -1,124 +0,0 @@
import { Component } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { createSoundSettingsController, soundOptions, type SoundSettingsController } from "./general-controllers"
import "./settings-v2.css"
const soundSettings = {
agent: {
action: "settings-sounds-agent",
title: "settings.general.sounds.agent.title",
description: "settings.general.sounds.agent.description",
},
permissions: {
action: "settings-sounds-permissions",
title: "settings.general.sounds.permissions.title",
description: "settings.general.sounds.permissions.description",
},
errors: {
action: "settings-sounds-errors",
title: "settings.general.sounds.errors.title",
description: "settings.general.sounds.errors.description",
},
} as const
const SoundSetting: Component<{
kind: "agent" | "permissions" | "errors"
channel: SoundSettingsController["agent"]
}> = (props) => {
const language = useLanguage()
const config = () => soundSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<SelectV2
appearance="inline"
data-action={config().action}
options={soundOptions}
current={props.channel.current()}
value={(option) => option.id}
label={(option) => language.t(option.label)}
onHighlight={props.channel.highlight}
onSelect={props.channel.select}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
)
}
export const SettingsNotificationsV2: Component = () => {
const language = useLanguage()
const settings = useSettings()
const sounds = createSoundSettingsController()
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.notifications")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("settings.notifications.description")}
</span>
</div>
</div>
</div>
<div class="settings-v2-tab-body">
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsListV2>
<SoundSetting kind="agent" channel={sounds.agent} />
<SoundSetting kind="permissions" channel={sounds.permissions} />
<SoundSetting kind="errors" channel={sounds.errors} />
</SettingsListV2>
</div>
</div>
</>
)
}
@@ -1,49 +0,0 @@
import { Show, createMemo, type Component } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/servers"
const allServers = { type: "all" } as const
type ServerOption = ServerConnection.Any | typeof allServers
export const InlineServerSelect: Component<{
all?: {
label: string
selected: () => boolean
onSelect: () => void
}
onServerSelect?: () => void
}> = (props) => {
const global = useGlobal()
const options = createMemo<ServerOption[]>(() => [...(props.all ? [allServers] : []), ...global.servers.list()])
const current = () => (props.all?.selected() ? allServers : global.settings.server.selected())
return (
<Show when={options().length > 1}>
<SelectV2
appearance="inline"
data-action="settings-server-select"
options={options()}
current={current()}
value={(server) => (server.type === "all" ? server.type : ServerConnection.key(server))}
label={(server) =>
server.type === "all" ? (props.all?.label ?? "") : serverName(server) || ServerConnection.key(server)
}
optionDisabled={(server) =>
server.type === "all" ? false : global.servers.health[ServerConnection.key(server)]?.healthy === false
}
placement="bottom-end"
gutter={6}
onSelect={(server) => {
if (!server) return
if (server.type === "all") {
props.all?.onSelect()
return
}
global.settings.server.set(ServerConnection.key(server))
props.onServerSelect?.()
}}
/>
</Show>
)
}
@@ -1,139 +0,0 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useGlobal } from "@/context/global"
import { getProjectAvatarVariant } from "@/context/layout"
import { ServerConnection, serverName } from "@/context/servers"
import { displayName } from "@/pages/layout/helpers"
import { InlineServerSelect } from "./parts/server-select"
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
import "./settings-v2.css"
export const SettingsProjectsV2: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const global = useGlobal()
const [allServers, setAllServers] = createSignal(true)
const selected = global.settings.server.selected
const projects = createMemo(() => {
const server = selected()
if (!server) return []
return global.ensureServerCtx(server).projects.list()
})
type ProjectItem = ReturnType<typeof projects>[number]
const groups = createMemo(() =>
global.servers
.list()
.map((server) => ({ server, projects: global.ensureServerCtx(server).projects.list() }))
.filter((group) => group.projects.length > 0),
)
const openProjectSettings = (project: ProjectItem, server = selected()) => {
if (!server) return
dialog.push(() => <DialogEditProjectV2 project={project} server={server} />)
}
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
const name = () => displayName(props.project)
const color = () => getProjectAvatarVariant(props.project.icon?.color)
return (
<div
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
onClick={() => openProjectSettings(props.project, props.server)}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectAvatar fallback={name()} variant={color()} class="shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
</div>
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
icon={<IconV2 name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
onClick={(event: MouseEvent) => {
event.stopPropagation()
openProjectSettings(props.project, props.server)
}}
/>
</div>
</div>
)
}
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.projects.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
</div>
<InlineServerSelect
all={{
label: language.t("settings.projects.server.all"),
selected: allServers,
onSelect: () => setAllServers(true),
}}
onServerSelect={() => setAllServers(false)}
/>
</div>
</div>
<div class="settings-v2-tab-body">
<Show
when={allServers()}
fallback={
<div class="flex flex-col gap-2 w-full">
<Show
when={projects().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<Show when={selected()} keyed>
{(server) => (
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
)}
</Show>
</Show>
</div>
}
>
<div class="flex flex-col gap-8 w-full">
<Show
when={groups().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<For each={groups()}>
{(group) => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">
{serverName(group.server) || ServerConnection.key(group.server)}
</h3>
<div class="flex flex-col gap-2 w-full">
<For each={group.projects}>
{(project) => <ProjectRow project={project} server={group.server} />}
</For>
</div>
</div>
)}
</For>
</Show>
</div>
</Show>
</div>
</>
)
}
@@ -4,14 +4,12 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js" import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider" import { DialogCustomProvider } from "../dialog-custom-provider"
import { SettingsServerScope } from "../settings-server-picker"
import { InlineServerSelect } from "./parts/server-select"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css" import "./settings-v2.css"
@@ -32,35 +30,25 @@ const PROVIDER_NOTES = [
const PROVIDER_ICON_SIZE = 16 const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component<{ export const SettingsProvidersV2: Component<{
directory: string | undefined directory: Accessor<string | undefined>
onBack?: () => void onBack?: () => void
}> = (props) => { }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders(() => props.directory) const providers = useProviders(props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => { const connect = (provider?: string) => {
providerConnect.select(provider) providerConnect.select(provider)
void dialog.show(() => ( void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
<SettingsServerScope directory={props.directory}>
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
</SettingsServerScope>
))
} }
const connected = createMemo(() => { const connected = createMemo(() => {
return providers.connected().filter( return providers
(provider) => .connected()
provider.id !== "opencode" || .filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
Object.values(provider.models).some((model) => {
if (typeof model !== "object" || model === null || !("cost" in model)) return false
const cost = model.cost
return typeof cost === "object" && cost !== null && "input" in cost
}),
)
}) })
const popular = createMemo(() => { const popular = createMemo(() => {
@@ -97,22 +85,45 @@ export const SettingsProvidersV2: Component<{
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => { const isConfigCustom = (providerID: string) => {
const provider = serverSync.data.config.provider?.[providerID] const provider = serverSync().data.config.provider?.[providerID]
if (!provider) return false if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false if (!provider.models || Object.keys(provider.models).length === 0) return false
return true return true
} }
const disableProvider = async (providerID: string, name: string) => {
return
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
await serverSync()
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
serverSync().set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
const disconnect = async (providerID: string, name: string) => { const disconnect = async (providerID: string, name: string) => {
const location = props.directory ? { directory: props.directory } : undefined const location = props.directory() ? { directory: props.directory() } : undefined
await serverSdk.api.integration await serverSdk()
.get({ integrationID: providerID, location }) .api.integration.get({ integrationID: providerID, location })
.then(async (integration) => { .then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? [] const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`) if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all( await Promise.all(
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id, location })), credentials.map((credential) => serverSdk().api.credential.remove({ credentialID: credential.id, location })),
) )
showToast({ showToast({
variant: "success", variant: "success",
@@ -130,13 +141,7 @@ export const SettingsProvidersV2: Component<{
return ( return (
<> <>
<div class="settings-v2-tab-header"> <div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row"> <h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
</div>
<InlineServerSelect />
</div>
</div> </div>
<div class="settings-v2-tab-body settings-v2-providers"> <div class="settings-v2-tab-body settings-v2-providers">
@@ -239,11 +244,7 @@ export const SettingsProvidersV2: Component<{
variant="neutral" variant="neutral"
icon="plus" icon="plus"
onClick={() => { onClick={() => {
dialog.show(() => ( dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
<SettingsServerScope directory={props.directory}>
<DialogCustomProvider onBack={dialog.close} />
</SettingsServerScope>
))
}} }}
> >
{language.t("common.connect")} {language.t("common.connect")}
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/servers" import { ServerConnection, serverName } from "@/context/server"
import { useServerCollectionController } from "../server/server-management-controller" import { useServerCollectionController } from "../server/server-management-controller"
import { DialogServerV2 } from "./dialog-server-v2" import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
@@ -53,10 +53,7 @@ export const SettingsServersV2: Component = () => {
classList={{ "settings-v2-tab-header--stacked": showSearch() }} classList={{ "settings-v2-tab-header--stacked": showSearch() }}
> >
<div class="settings-v2-tab-header-row"> <div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1"> <h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
</div>
<AddServerMenu onAddServer={openAdd} /> <AddServerMenu onAddServer={openAdd} />
</div> </div>
<Show when={showSearch()}> <Show when={showSearch()}>
@@ -39,14 +39,6 @@
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent); background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
} }
.settings-v2-tab-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.settings-v2-tab-title { .settings-v2-tab-title {
font-size: 15px; font-size: 15px;
font-weight: 640; font-weight: 640;
@@ -182,13 +174,13 @@
max-width: 100%; max-width: 100%;
} }
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] > [data-slot="tabs-v2-list"] { [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
background-color: var(--v2-background-bg-layer-01); background-color: var(--v2-background-bg-layer-01);
} }
@media (max-width: 639px) { @media (max-width: 639px) {
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] .settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] { [data-slot="tabs-v2-list"] {
width: 144px; width: 144px;
min-width: 144px; min-width: 144px;
padding-inline: 8px; padding-inline: 8px;
@@ -692,223 +684,6 @@
color: var(--v2-text-text-base); color: var(--v2-text-text-base);
} }
.settings-v2-tab-header.settings-v2-workspaces-header {
padding-bottom: 24px;
}
.settings-v2-workspaces-header .settings-v2-tab-title {
font-weight: 610;
}
.settings-v2-tab-body.settings-v2-workspaces {
gap: 16px;
}
.settings-v2-workspaces-toolbar {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-workspaces-count {
font-size: 15px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-toolbar-actions {
display: flex;
align-items: center;
gap: 4px;
}
.settings-v2-workspaces-delete-all {
color: var(--v2-state-fg-danger);
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
}
.settings-v2-workspaces-row {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
.settings-v2-workspaces-row:not(:last-child) {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-row-header {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.settings-v2-workspaces-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
.settings-v2-workspaces-main {
display: flex;
min-width: 0;
}
.settings-v2-workspaces-row-actions {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
min-width: 0;
}
.settings-v2-workspaces-path {
display: block;
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-family: inherit;
font-size: 13px;
font-weight: 530;
line-height: 1;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
text-align: left;
cursor: default;
}
.settings-v2-workspaces-meta {
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-workspaces-active,
.settings-v2-workspaces-more {
flex-shrink: 0;
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-workspaces-sessions {
display: flex;
flex-direction: column;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 4px;
background-color: var(--v2-background-bg-base);
overflow: hidden;
}
.settings-v2-workspaces-session {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
font-size: 13px;
font-weight: 440;
line-height: 16px;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-session:not(:last-child) {
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-session > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-v2-workspaces-session-time {
flex-shrink: 0;
font-size: 11px;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-workspaces-empty {
display: flex;
align-items: center;
justify-content: center;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
@media (max-width: 639px) {
.settings-v2-workspaces-header {
padding: 24px 20px 20px;
}
.settings-v2-tab-body.settings-v2-workspaces {
padding: 0 20px 24px;
}
.settings-v2-workspaces-toolbar,
.settings-v2-workspaces-main {
align-items: flex-start;
}
.settings-v2-workspaces-toolbar {
flex-wrap: wrap;
}
.settings-v2-workspaces-toolbar-actions {
width: 100%;
flex-wrap: wrap;
justify-content: space-between;
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
padding: 14px;
}
.settings-v2-workspaces-path {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
.settings-v2-workspaces-active {
display: none;
}
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] { [data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
width: 480px; width: 480px;
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
@@ -952,24 +727,3 @@
line-height: 1; line-height: 1;
color: var(--v2-state-fg-danger); color: var(--v2-state-fg-danger);
} }
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: 280px;
padding-inline: 0 !important;
}
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
display: none;
}
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"] {
border-radius: 6px;
}
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger"] {
padding-inline: 8px;
}
@@ -1,502 +0,0 @@
import type { Component } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { createStore, produce } from "solid-js/store"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useQuery } from "@tanstack/solid-query"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { showToast } from "@/utils/toast"
import { getRelativeTime } from "@/utils/time"
import { pathKey } from "@/utils/path-key"
import { SettingsListV2 } from "./parts/list"
import { InlineServerSelect } from "./parts/server-select"
import { useTabs } from "@/context/tabs"
import { usePlatform } from "@/context/platform"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { ServerConnection } from "@/context/servers"
import type { Project } from "@/types"
import {
containsDirectory,
filterWorkspaceInventory,
inspectWorkspaceDeletion,
managedWorkspaceDirectories,
mergeWorkspaceSessionInventory,
removeWorkspacesSequentially,
sessionsForWorkspace,
type WorkspaceDeleteInspection,
workspaceInventory,
} from "@/utils/workspace"
import { listAllSessions } from "@/utils/session"
import type { ServerScope } from "@/utils/server-scope"
import "./settings-v2.css"
type Workspace = {
directory: string
project: Project
}
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const tabs = useTabs()
const platform = usePlatform()
const [store, setStore] = createStore({
project: "all",
transaction: undefined as "confirm" | "running" | undefined,
})
const workspaces = createMemo(() => workspaceInventory(serverSync.data.project))
const projects = createMemo(() =>
serverSync.data.project.filter((project) => managedWorkspaceDirectories(project).length > 0),
)
const projectName = (project: Project) => project.name || getFilename(project.worktree)
const projectOptions = createMemo(() => [
{ id: "all", label: language.t("settings.workspaces.filter.all") },
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
])
const selectedProject = createMemo(() =>
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
)
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => {
const sdk = serverSDK
return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
}
const loadSessions = async (context = captureDeleteContext()) => {
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
return mergeWorkspaceSessionInventory(
fetched,
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
)
}
const sessionQuery = useQuery(() => ({
queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const,
queryFn: () => loadSessions(),
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(
() =>
new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionQuery.isSuccess ? sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) : [],
]),
),
)
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
if (sessionQuery.isError) return language.t("common.requestFailed")
const count = workspaceSessions(workspace).length
return language.plural("settings.workspaces.sessions", count, {
count,
project: projectName(workspace.project),
})
}
const lastActive = (workspace: Workspace) => {
const updated = workspaceSessions(workspace)[0]?.time.updated
if (!updated) return undefined
return getRelativeTime(new Date(updated).toISOString(), language.t)
}
const sessionTime = (session: SessionInfo) => {
if (!session.time.updated) return undefined
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
}
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
const [working, branch, sessions] = await Promise.all([
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
loadSessions(context),
])
const result = inspectWorkspaceDeletion({
workspace: workspace.directory,
activeDirectory: context.activeDirectory,
sessions,
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
})
return { result, sessions }
}
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
const messages = [
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
result.dirty ? language.t("workspace.status.dirty") : undefined,
].filter((message): message is string => message !== undefined)
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
}
const blocked = (result: WorkspaceDeleteInspection) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: inspectionMessages(result)[0],
})
}
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
const preflight = await inspect(workspace, context)
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
projectID: workspace.project.id,
directory: workspace.directory,
force,
})
.then(() => true)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
if (!removed) return
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
})
clearWorkspaceTerminals(
workspace.directory,
preflight.sessions.map((session) => session.id),
platform,
context.sdk.scope,
)
context.sync.set(
"project",
produce((draft) => {
const project = draft.find((item) => item.id === workspace.project.id)
if (!project) return
project.sandboxes = (project.sandboxes ?? []).filter(
(directory) => pathKey(directory) !== pathKey(workspace.directory),
)
project.worktrees = project.worktrees.filter(
(worktree) => pathKey(worktree.directory) !== pathKey(workspace.directory),
)
}),
)
}
let inspectionID = 0
const releaseConfirmation = () => {
if (store.transaction === "confirm") setStore("transaction", undefined)
}
const transact = async (task: () => Promise<void>) => {
if (store.transaction !== "confirm") return
setStore("transaction", "running")
try {
await task()
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
} finally {
setStore("transaction", undefined)
}
}
const confirmDelete = (workspace: Workspace) => {
if (store.transaction) return
const context = captureDeleteContext()
const current = ++inspectionID
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspace
workspace={workspace}
scope={context.sdk.scope}
inspectionID={current}
inspect={() => inspect(workspace, context)}
inspectionMessages={inspectionMessages}
onDelete={() => transact(() => remove(workspace, true, context))}
/>
),
releaseConfirmation,
)
}
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
}
const confirmDeleteAll = () => {
if (store.transaction) return
const context = captureDeleteContext()
const inventory = [...filtered()]
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteAllWorkspaces
count={inventory.length}
project={project}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
releaseConfirmation,
)
}
return (
<>
<div class="settings-v2-tab-header settings-v2-workspaces-header">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
<InlineServerSelect />
</div>
</div>
<div class="settings-v2-tab-body settings-v2-workspaces">
<div class="settings-v2-workspaces-toolbar">
<span class="settings-v2-workspaces-count">
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-v2-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<MenuV2 placement="bottom-end" gutter={6}>
<MenuV2.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium 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">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<For each={projectOptions()}>
{(option) => (
<MenuV2.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
<Show when={filtered().length > 0}>
<MenuV2 placement="bottom-end" gutter={4}>
<MenuV2.Trigger
as={IconButtonV2}
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("common.moreOptions")}
disabled={!!store.transaction}
icon={<Icon name="outline-dots" size="small" />}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={confirmDeleteAll}>
<span class="settings-v2-workspaces-delete-all">
{language.t("settings.workspaces.deleteAll")}
</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
</div>
</div>
<div class="settings-v2-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
>
<SettingsListV2>
<For each={filtered()}>
{(workspace) => {
const linked = () => workspaceSessions(workspace)
return (
<div class="settings-v2-workspaces-row">
<div class="settings-v2-workspaces-row-header">
<div class="settings-v2-workspaces-copy">
<div class="settings-v2-workspaces-main">
<TooltipV2
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
dir="ltr"
aria-label={workspace.directory}
class="settings-v2-workspaces-path"
>
{workspace.directory}
</span>
</TooltipV2>
</div>
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
</div>
<div class="settings-v2-workspaces-row-actions">
<Show when={lastActive(workspace)}>
{(value) => (
<TooltipV2
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<span tabIndex={0} class="settings-v2-workspaces-active">
{value()}
</span>
</TooltipV2>
)}
</Show>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace.directory),
})}
disabled={!!store.transaction}
icon={<Icon name="trash" size="small" />}
onClick={() => confirmDelete(workspace)}
/>
</div>
</div>
<Show when={linked().length > 0}>
<div class="settings-v2-workspaces-sessions">
<For each={linked()}>
{(session) => (
<div class="settings-v2-workspaces-session">
<span>{session.title}</span>
<Show when={sessionTime(session)}>
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
)
}}
</For>
</SettingsListV2>
</Show>
</div>
</div>
</>
)
}
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
const dialog = useDialog()
const language = useLanguage()
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("settings.workspaces.deleteAll")}
description={
<>
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
<br />
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.deleteAll")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
function DialogDeleteWorkspace(props: {
workspace: Workspace
scope: ServerScope
inspectionID: number
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: SessionInfo[] }>
inspectionMessages: (result: WorkspaceDeleteInspection) => string[]
onDelete: () => Promise<void>
}) {
const dialog = useDialog()
const language = useLanguage()
const status = useQuery(() => ({
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
queryFn: props.inspect,
staleTime: 0,
}))
const descriptions = () => {
if (status.isPending) return [language.t("workspace.status.checking")]
if (status.isError) return [language.t("workspace.status.error")]
if (!status.data) return []
return props.inspectionMessages(status.data.result)
}
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("workspace.delete.title")}
description={
<>
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
<br />
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
<br />
{language.t("settings.workspaces.delete.warning")}
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
type="button"
variant="danger"
disabled={status.isPending || status.isError || status.data?.result.active}
onClick={remove}
>
{language.t("workspace.delete.button")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
@@ -1,17 +1,25 @@
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js" import {
type Accessor,
createEffect,
createMemo,
createResource,
For,
type JSXElement,
onCleanup,
Show,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { ServerConnection, useServers } from "@/context/servers" import { ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
const pluginEmptyMessage = (value: string, file: string): JSXElement => { const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file) const parts = value.split(file)
@@ -101,9 +109,12 @@ type ServerStatusItem = {
onSelect: () => void onSelect: () => void
} }
export function StatusPopoverBody(props: { shown: boolean }) { export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const global = useGlobal()
const server = useServer()
const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const fail = (err: unknown) => { const fail = (err: unknown) => {
@@ -114,18 +125,29 @@ export function StatusPopoverBody(props: { shown: boolean }) {
}) })
} }
createEffect(() => {
if (!props.shown()) return
})
let dialogRun = 0 let dialogRun = 0
let dialogDead = false
onCleanup(() => { onCleanup(() => {
dialogDead = true
dialogRun += 1 dialogRun += 1
}) })
const sortedServers = createMemo(() => {
const list = global.servers.list()
return listServersByHealth(list, server.key, global.servers.health)
})
const toggleMcp = useMcpToggle() const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync().data.lsp ?? []) const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length) const lspCount = createMemo(() => lspItems().length)
const [pluginList] = createResource( const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined), () => (props.shown() ? sdk().directory : undefined),
(directory) => (directory) =>
sdk() sdk()
.api.plugin.list({ location: { directory } }) .api.plugin.list({ location: { directory } })
@@ -5,7 +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, useServers } from "@/context/servers" import { ServerConnection, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk" 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"
@@ -14,7 +14,6 @@ import {
hasServiceNeedingAttention, hasServiceNeedingAttention,
serverStatusDotClass, serverStatusDotClass,
} from "./status-popover-indicator" } from "./status-popover-indicator"
import { useServer } from "@/context/server"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
@@ -75,7 +74,7 @@ export function StatusPopover() {
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" /> <div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
} }
> >
<Body shown={shown()} /> <Body shown={shown} />
</Suspense> </Suspense>
</Show> </Show>
</Popover> </Popover>
@@ -83,12 +82,16 @@ export function StatusPopover() {
} }
export function StatusPopoverV2() { export function StatusPopoverV2() {
return <DirectoryStatusPopover />
}
function DirectoryStatusPopover() {
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServerSDK()
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[server.key]?.healthy const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() => const attention = createMemo(() =>
hasServiceNeedingAttention({ hasServiceNeedingAttention({
@@ -111,7 +114,7 @@ export function StatusPopoverV2() {
onOpenChange: setShown, onOpenChange: setShown,
body: () => ( body: () => (
<StatusPopoverBody shown={shown()}> <StatusPopoverBody shown={shown()}>
<Body shown={shown()} /> <Body shown={shown} />
</StatusPopoverBody> </StatusPopoverBody>
), ),
})) }))
-1
View File
@@ -175,7 +175,6 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
const directory = sdk().directory const directory = sdk().directory
const url = sdk().url const url = sdk().url
let container!: HTMLDivElement let container!: HTMLDivElement
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ServerConnection } from "@/context/servers" import type { ServerConnection } from "@/context/server"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
const remote = "remote" as ServerConnection.Key const remote = "remote" as ServerConnection.Key
@@ -1,9 +1,9 @@
import { ServerConnection } from "@/context/servers" import type { ServerConnection } from "@/context/server"
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed" export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
export type SessionTabsRemovedDetail = { export type SessionTabsRemovedDetail = {
server: ServerConnection.Key server?: ServerConnection.Key
directory: string directory: string
sessionIDs: string[] sessionIDs: string[]
} }
@@ -19,15 +19,16 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe
if (!detail || typeof detail !== "object") return undefined if (!detail || typeof detail !== "object") return undefined
if (!("directory" in detail)) return undefined if (!("directory" in detail)) return undefined
if (!("sessionIDs" in detail)) return undefined if (!("sessionIDs" in detail)) return undefined
if (!("server" in detail) || typeof detail.server !== "string") return undefined
if (typeof detail.directory !== "string") return undefined if (typeof detail.directory !== "string") return undefined
if (!Array.isArray(detail.sessionIDs)) return undefined if (!Array.isArray(detail.sessionIDs)) return undefined
if ("server" in detail && detail.server !== undefined && typeof detail.server !== "string") return undefined
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string") const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
if (sessionIDs.length === 0) return undefined if (sessionIDs.length === 0) return undefined
return { return {
server: ServerConnection.Key.make(detail.server), server:
"server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined,
directory: detail.directory, directory: detail.directory,
sessionIDs, sessionIDs,
} }
@@ -4,9 +4,9 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query" import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal, useServerCtx } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, serverName, useServers } from "@/context/servers" import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { SessionInfo } from "@opencode-ai/client/promise"
@@ -22,14 +22,14 @@ export function TabNavItem(props: {
ref?: Ref<HTMLDivElement> ref?: Ref<HTMLDivElement>
href: string href: string
server: ServerConnection.Key server: ServerConnection.Key
session: SessionInfo | undefined session: () => SessionInfo | undefined
fallbackTitle?: string fallbackTitle?: string
onRename: (title: string) => Promise<void> onRename: (title: string) => Promise<void>
onClose: () => void onClose: () => void
onNavigate: () => void onNavigate: () => void
active?: boolean active?: boolean
forceTruncate?: boolean forceTruncate?: boolean
suppressNavigation?: boolean suppressNavigation?: () => boolean
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
@@ -46,38 +46,41 @@ export function TabNavItem(props: {
event.stopPropagation() event.stopPropagation()
props.onClose() props.onClose()
} }
const servers = useServers() const global = useGlobal()
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server)) const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.ensureServerCtx(conn)
})
const project = createMemo(() => { const project = createMemo(() => {
const session = props.session const session = props.session()
if (!session) return if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? []) return projectForSession(session, serverCtx()?.projects.list() ?? [])
}) })
const title = createMemo(() => { const title = createMemo(() => {
const session = props.session const session = props.session()
return session ? sessionLabel(session) : props.fallbackTitle return session ? sessionLabel(session) : props.fallbackTitle
}) })
const projectName = createMemo(() => { const projectName = createMemo(() => {
const session = props.session const session = props.session()
if (!session) return if (!session) return
return displayName(project() ?? { worktree: session.location.directory }) return displayName(project() ?? { worktree: session.location.directory })
}) })
const previewPath = createMemo(() => { const previewPath = createMemo(() => {
const session = props.session const session = props.session()
if (!session) return if (!session) return
const home = serverCtx()?.sync.data.path.home const home = serverCtx()?.sync.data.path.home
return home ? session.location.directory.replace(home, "~") : session.location.directory return home ? session.location.directory.replace(home, "~") : session.location.directory
}) })
// Only label the server when multiple servers are connected. // Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => { const serverLabel = createMemo(() => {
if (servers.list.length <= 1) return if (global.servers.list().length <= 1) return
const conn = servers.list.find((item) => ServerConnection.key(item) === props.server) const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
return conn ? serverName(conn) : undefined return conn ? serverName(conn) : undefined
}) })
const [popoverOpen, setPopoverOpen] = createSignal(false) const [popoverOpen, setPopoverOpen] = createSignal(false)
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
const measureTitleOverflow = () => { const measureTitleOverflow = () => {
if (!titleEl || editing()) { if (!titleEl || editing()) {
@@ -118,7 +121,7 @@ export function TabNavItem(props: {
const closeRename = async (save: boolean) => { const closeRename = async (save: boolean) => {
if (rename.isPending || !editing()) return if (rename.isPending || !editing()) return
const original = props.session?.title ?? "" const original = props.session()?.title ?? ""
const next = (titleEl.textContent ?? "").trim() const next = (titleEl.textContent ?? "").trim()
titleEl.scrollLeft = 0 titleEl.scrollLeft = 0
@@ -143,7 +146,7 @@ export function TabNavItem(props: {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
const session = props.session const session = props.session()
if (!session) return if (!session) return
titleEl.textContent = session.title ?? "" titleEl.textContent = session.title ?? ""
setEditing(true) setEditing(true)
@@ -210,7 +213,7 @@ export function TabNavItem(props: {
// Navigate on mousedown to shave the press-release delay off tab switches. // Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return if (event.button !== 0) return
if (editing()) return if (editing()) return
if (props.suppressNavigation) return if (props.suppressNavigation?.()) return
props.onNavigate() props.onNavigate()
}} }}
onClick={(event) => { onClick={(event) => {
@@ -218,14 +221,14 @@ export function TabNavItem(props: {
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation. // Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return if (event.detail > 0) return
if (editing()) return if (editing()) return
if (props.suppressNavigation) return if (props.suppressNavigation?.()) return
props.onNavigate() 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]" class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
> >
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center"> <span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show <Show
when={props.session} when={props.session()}
keyed keyed
fallback={ fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" /> <span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
@@ -248,7 +251,6 @@ export function TabNavItem(props: {
}} }}
data-slot="tab-title" data-slot="tab-title"
data-titlebar-tab-title data-titlebar-tab-title
dir="auto"
class="min-w-0 flex-1 outline-none leading-4" class="min-w-0 flex-1 outline-none leading-4"
classList={{ classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(), "overflow-hidden text-clip whitespace-nowrap": !editing(),
@@ -265,7 +267,7 @@ export function TabNavItem(props: {
} }
if (event.key !== "Escape") return if (event.key !== "Escape") return
event.preventDefault() event.preventDefault()
titleEl.textContent = props.session?.title ?? "" titleEl.textContent = props.session()?.title ?? ""
void closeRename(false) void closeRename(false)
}} }}
onBlur={() => void closeRename(true)} onBlur={() => void closeRename(true)}
@@ -306,7 +308,7 @@ export function TabNavItem(props: {
}} }}
data={{ data={{
projectName: projectName(), projectName: projectName(),
title: props.session?.title, title: props.session()?.title,
path: previewPath(), path: previewPath(),
serverName: serverLabel(), serverName: serverLabel(),
}} }}
@@ -321,7 +323,7 @@ export function DraftTabItem(props: {
active?: boolean active?: boolean
onNavigate: () => void onNavigate: () => void
onClose: () => void onClose: () => void
suppressNavigation?: boolean suppressNavigation?: () => boolean
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
@@ -364,14 +366,14 @@ export function DraftTabItem(props: {
onMouseDown={(event) => { onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches. // Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return if (event.button !== 0) return
if (props.suppressNavigation) return if (props.suppressNavigation?.()) return
props.onNavigate() props.onNavigate()
}} }}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation. // Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return if (event.detail > 0) return
if (props.suppressNavigation) return if (props.suppressNavigation?.()) return
props.onNavigate() 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]" 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]"
@@ -8,9 +8,9 @@ import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers" import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers" 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/servers" import { ServerConnection } from "@/context/server"
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav" import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
import { useGlobal, useServerCtx, type ServerCtx } from "@/context/global" import { useGlobal, type ServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
@@ -24,10 +24,10 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
function SessionTabSlot(props: { function SessionTabSlot(props: {
tab: SessionTab tab: SessionTab
id: string id: string
index: number index: () => number
active: boolean active: () => boolean
forceTruncate: boolean forceTruncate: boolean
session: SessionInfo | undefined session: () => SessionInfo | undefined
fallbackTitle?: string fallbackTitle?: string
onRename: (title: string) => Promise<void> onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
@@ -38,7 +38,7 @@ function SessionTabSlot(props: {
return props.id return props.id
}, },
get index() { get index() {
return props.index return props.index()
}, },
}) })
let ref!: HTMLDivElement let ref!: HTMLDivElement
@@ -48,7 +48,7 @@ function SessionTabSlot(props: {
ref={sortable.ref} ref={sortable.ref}
data-titlebar-tab-slot data-titlebar-tab-slot
data-tab-key={props.id} data-tab-key={props.id}
data-active={props.active} data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink" class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
> >
<TabNavItem <TabNavItem
@@ -62,7 +62,7 @@ function SessionTabSlot(props: {
onRename={props.onRename} onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)} onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose} onClose={props.onClose}
active={props.active} active={props.active()}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()} dragging={sortable.isDragSource()}
/> />
@@ -73,34 +73,34 @@ function SessionTabSlot(props: {
function SessionTabEntry(props: { function SessionTabEntry(props: {
tab: SessionTab tab: SessionTab
id: string id: string
index: number index: () => number
active: boolean active: () => boolean
forceTruncate: boolean forceTruncate: boolean
serverCtx: ServerCtx | undefined serverCtx: () => ServerCtx | undefined
onVisibleChange: (visible: boolean) => void onVisibleChange: (visible: boolean) => void
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
onClose: () => void onClose: () => void
}) { }) {
const tabs = useTabs() const tabs = useTabs()
const language = useLanguage() const language = useLanguage()
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))
const persisted = createMemo(() => tabs.info[props.id]) const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource( const [loadedSession] = createResource(
() => { () => {
const ctx = props.serverCtx const ctx = props.serverCtx()
return ctx ? { id: props.tab.sessionId, ctx } : null return ctx ? { id: props.tab.sessionId, ctx } : null
}, },
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined), ({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
) )
const session = createMemo(() => cachedSession() ?? loadedSession()) const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session()) const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title) const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
let prefetched = false let prefetched = false
const rename = async (title: string) => { const rename = async (title: string) => {
const value = session() const value = session()
const ctx = props.serverCtx const ctx = props.serverCtx()
if (!value || !ctx) return if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title }) ctx.sync.session.remember({ ...value, title })
@@ -108,7 +108,7 @@ function SessionTabEntry(props: {
await ctx.sdk.api.session.rename({ sessionID: value.id, title }) await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) { } catch (err) {
const current = session() const current = session()
const currentCtx = props.serverCtx const currentCtx = props.serverCtx()
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title }) if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
showToast({ showToast({
title: language.t("common.requestFailed"), title: language.t("common.requestFailed"),
@@ -120,7 +120,7 @@ function SessionTabEntry(props: {
createEffect(() => props.onVisibleChange(visible())) createEffect(() => props.onVisibleChange(visible()))
createEffect(() => { createEffect(() => {
const ctx = props.serverCtx const ctx = props.serverCtx()
const value = session() const value = session()
if (!ctx || !value || prefetched) return if (!ctx || !value || prefetched) return
prefetched = true prefetched = true
@@ -157,7 +157,7 @@ function SessionTabEntry(props: {
index={props.index} index={props.index}
active={props.active} active={props.active}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
session={session()} session={session}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)} fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onRename={rename} onRename={rename}
onNavigate={props.onNavigate} onNavigate={props.onNavigate}
@@ -170,8 +170,8 @@ function SessionTabEntry(props: {
function DraftTabSlot(props: { function DraftTabSlot(props: {
tab: Extract<Tab, { type: "draft" }> tab: Extract<Tab, { type: "draft" }>
id: string id: string
index: number index: () => number
active: boolean active: () => boolean
title: string title: string
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
onClose: () => void onClose: () => void
@@ -181,7 +181,7 @@ function DraftTabSlot(props: {
return props.id return props.id
}, },
get index() { get index() {
return props.index return props.index()
}, },
}) })
let ref!: HTMLDivElement let ref!: HTMLDivElement
@@ -191,7 +191,7 @@ function DraftTabSlot(props: {
ref={sortable.ref} ref={sortable.ref}
data-titlebar-tab-slot data-titlebar-tab-slot
data-tab-key={props.id} data-tab-key={props.id}
data-active={props.active} data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink" class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
> >
<DraftTabItem <DraftTabItem
@@ -202,7 +202,7 @@ function DraftTabSlot(props: {
title={props.title} title={props.title}
onNavigate={() => props.onNavigate(ref)} onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose} onClose={props.onClose}
active={props.active} active={props.active()}
dragging={sortable.isDragSource()} dragging={sortable.isDragSource()}
/> />
</div> </div>
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
export function TitlebarTabStrip(props: { export function TitlebarTabStrip(props: {
tabs: Tab[] tabs: Tab[]
currentTab: Tab | undefined currentTab: () => Tab | undefined
forceTruncate: boolean forceTruncate: boolean
onNavigate: (tab: Tab, el?: HTMLDivElement) => void onNavigate: (tab: Tab, el?: HTMLDivElement) => void
onClose: (tab: Tab) => void onClose: (tab: Tab) => void
@@ -248,7 +248,7 @@ export function TitlebarTabStrip(props: {
]) ])
function selectAdjacentTab(offset: -1 | 1) { function selectAdjacentTab(offset: -1 | 1) {
const current = props.currentTab const current = props.currentTab()
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset) const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
const next = props.tabs.find((tab) => tabKey(tab) === key) const next = props.tabs.find((tab) => tabKey(tab) === key)
if (next) props.onNavigate(next) if (next) props.onNavigate(next)
@@ -339,9 +339,10 @@ export function TitlebarTabStrip(props: {
let ref!: HTMLDivElement let ref!: HTMLDivElement
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id) const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref)) useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
const serverCtx = useServerCtx(() => { const serverCtx = createMemo(() => {
if (tab.type !== "session") return if (tab.type !== "session") return
return global.servers.list().find((item) => ServerConnection.key(item) === tab.server) const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
if (conn) return global.ensureServerCtx(conn)
}) })
if (tab.type === "session") { if (tab.type === "session") {
@@ -349,10 +350,10 @@ export function TitlebarTabStrip(props: {
<SessionTabEntry <SessionTabEntry
tab={tab} tab={tab}
id={id} id={id}
index={visibleIndex()} index={visibleIndex}
active={props.currentTab === tab} active={() => props.currentTab() === tab}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
serverCtx={serverCtx()} serverCtx={serverCtx}
onVisibleChange={(visible) => setVisibility(id, visible)} onVisibleChange={(visible) => setVisibility(id, visible)}
onNavigate={(element) => { onNavigate={(element) => {
ref = element ref = element
@@ -367,8 +368,8 @@ export function TitlebarTabStrip(props: {
<DraftTabSlot <DraftTabSlot
tab={tab} tab={tab}
id={id} id={id}
index={visibleIndex()} index={visibleIndex}
active={props.currentTab === tab} active={() => props.currentTab() === tab}
title={language.t("command.session.new")} title={language.t("command.session.new")}
onNavigate={(element) => { onNavigate={(element) => {
ref = element ref = element
+230 -48
View File
@@ -32,8 +32,8 @@ import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal, useServerCtx } from "@/context/global" import { useGlobal } from "@/context/global"
import { ServerConnection, useServers } from "@/context/servers" import { ServerConnection, useServer } from "@/context/server"
import { tabKey, useTabs } from "@/context/tabs" import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt" import type { PromptSession } from "@/context/prompt"
import "./titlebar.css" import "./titlebar.css"
@@ -46,8 +46,8 @@ const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px
const macTrafficLightsBaseWidth = 84 const macTrafficLightsBaseWidth = 84
export type TitlebarUpdate = { export type TitlebarUpdate = {
version: string | undefined version: () => string | undefined
installing: boolean installing: () => boolean
install: () => void install: () => void
} }
@@ -61,13 +61,15 @@ export function useTitlebarRightMount() {
} }
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) { export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const servers = useServers() const server = useServer()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns()) const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom") const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
@@ -75,9 +77,11 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web")
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.()) const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
const zoom = () => platform.webviewZoom?.() ?? 1 const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => { const minHeight = () => {
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
if (mac()) return `${height / zoom()}px` if (mac()) return `${height / zoom()}px`
@@ -93,6 +97,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}) })
const path = () => `${location.pathname}${location.search}${location.hash}` const path = () => `${location.pathname}${location.search}${location.hash}`
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.id) return false
const parts = location.pathname.replace(/\/+$/, "").split("/")
return parts.at(-1) === "session"
})
createEffect(() => { createEffect(() => {
const current = path() const current = path()
@@ -104,9 +116,13 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}) })
}) })
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => { const updateState = createMemo<TitlebarUpdatePillState>(() => {
const installing = props.update?.installing ?? false const installing = props.update?.installing() ?? false
const version = props.update?.version const version = props.update?.version()
return { return {
visible: version !== undefined || installing, visible: version !== undefined || installing,
installing, installing,
@@ -172,7 +188,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
data-tauri-drag-region data-tauri-drag-region
> >
<Switch> <Switch>
<Match when> <Match when={useV2Titlebar()}>
{(_) => { {(_) => {
const layout = useLayout() const layout = useLayout()
const global = useGlobal() const global = useGlobal()
@@ -184,7 +200,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
() => { () => {
const route = layout.route() const route = layout.route()
if (route.type !== "session") return undefined if (route.type !== "session") return undefined
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server) const conn = global.servers
.list()
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
}, },
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}), ({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
@@ -227,7 +245,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const s = session() const s = session()
if (!s) return if (!s) return
const sessionId = s.parentID ?? s.id const sessionId = s.parentID ?? s.id
const next = { server: route.server, sessionId } const next = { server: route.server ?? server.key, sessionId }
tabsStoreActions.addSessionTab(next) tabsStoreActions.addSessionTab(next)
} }
}) })
@@ -240,43 +258,53 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const openNewTab = () => { const openNewTab = () => {
const route = layout.route() const route = layout.route()
switch (route.type) { const activeSession = session()
case "session": { if (route.type === "session" && activeSession) {
const activeSession = session() const sessionTab = {
if (!activeSession) return type: "session" as const,
server: route.server ?? server.key,
const sessionTab = { sessionId: activeSession.id,
type: "session" as const,
server: route.server,
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
return
} }
case "draft": { const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
const activeTab = currentTab() tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
if (activeTab?.type !== "draft") return return
}
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current() const activeTab = currentTab()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model) if (activeTab?.type === "draft") {
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return
}
if (route.type === "home") {
const selection = layout.home.selection()
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
const project = conn
? global
.ensureServerCtx(conn)
.projects.list()
.find((item) => item.worktree === selection.directory)
: undefined
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
return return
} }
case "home": {
const selection = layout.home.selection()
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
const project = conn
? global
.ensureServerCtx(conn)
.projects.list()
.find((item) => item.worktree === selection.directory)
: undefined
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
return
}
}
} }
const current = layout.projects.list()[0]
if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
return
}
const fallback = global.servers.list().flatMap((conn) => {
const project = global.ensureServerCtx(conn).projects.list()[0]
return project ? [{ server: ServerConnection.key(conn), project }] : []
})[0]
if (!fallback) return
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
} }
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() }) const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
@@ -364,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
<TitlebarTabStrip <TitlebarTabStrip
tabs={tabsStore} tabs={tabsStore}
currentTab={currentTab()} currentTab={currentTab}
forceTruncate={tabsAreOverflowing()} forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing} onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => { onNavigate={(tab, el) => {
@@ -402,6 +430,158 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
) )
}} }}
</Match> </Match>
<Match when>
<div
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ zoom: counterZoom() }}
>
<div
classList={{
"flex items-center min-w-0": true,
"pl-2": !macTrafficLights(),
}}
>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={mac()}>
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<Show when={!mac()}>
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
>
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={800}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
>
<IconV2 name="edit" size="small" />
</Button>
</TooltipKeybind>
</div>
</div>
</Show>
<div
class="flex items-center shrink-0"
classList={{
"ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={800}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
</div>
</div>
<ChannelIndicator debugTools={props.debugTools} />
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div
id="opencode-titlebar-center"
class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full"
/>
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
<div class="shrink-0" style={{ width: windowsControlsWidth() }} />
</Show>
</div>
</div>
</Match>
</Switch> </Switch>
</header> </header>
) )
@@ -477,10 +657,12 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
} }
return ( return (
<Show when={["local", "beta", "dev"].includes(channel)}> <>
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono"> {["local", "beta", "dev"].includes(channel) && (
{channel.toUpperCase()} <div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
</div> {channel.toUpperCase()}
</Show> </div>
)}
</>
) )
} }
@@ -1,4 +1,4 @@
import { For, Show, type JSX } from "solid-js" import { Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
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"
@@ -50,20 +50,7 @@ export function WindowsAppMenu(props: {
return ( return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start"> <DropdownMenu gutter={4} modal={false} placement="bottom-start">
<Show {props.variant === "v2" ? (
when={props.variant === "v2"}
fallback={
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
}
>
<div <div
data-component="desktop-icon-button" data-component="desktop-icon-button"
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1" class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
@@ -78,31 +65,39 @@ export function WindowsAppMenu(props: {
onKeyDown={rememberFocus} onKeyDown={rememberFocus}
/> />
</div> </div>
</Show> ) : (
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
)}
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu"> <DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel> <DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
<For each={DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows"))}> {DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
{(menu) => ( <DesktopMenuSubmenu label={language.t(menu.labelKey)}>
<DesktopMenuSubmenu label={language.t(menu.labelKey)}> {menu.items
<For each={menu.items?.filter((entry) => desktopMenuVisible(entry, "windows"))}> ?.filter((entry) => desktopMenuVisible(entry, "windows"))
{(entry) => { .map((entry) =>
// Static menu data: an early return keeps the union narrowing a Show fallback would lose. entry.type === "separator" ? (
if (entry.type === "separator") return <DropdownMenu.Separator /> <DropdownMenu.Separator />
return ( ) : (
<DesktopMenuItem <DesktopMenuItem
label={entry.labelKey ? language.t(entry.labelKey) : ""} label={entry.labelKey ? language.t(entry.labelKey) : ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows} keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false} disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)} onSelect={() => runEntry(entry)}
/> />
) ),
}} )}
</For> </DesktopMenuSubmenu>
</DesktopMenuSubmenu> ))}
)}
</For>
</DropdownMenu.Group> </DropdownMenu.Group>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
+1 -1
View File
@@ -219,7 +219,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
const decoded = decodeSessionKey(key) const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({ return createRoot((dispose) => ({
value: createCommentSession( value: createCommentSession(
serverSDK.scope, serverSDK().scope,
decoded.dir, decoded.dir,
decoded.id === WORKSPACE_KEY ? undefined : decoded.id, decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
), ),
+4 -4
View File
@@ -66,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const scope = createMemo(() => sdk().directory) const scope = createMemo(() => sdk().directory)
const path = createPathHelpers(scope) const path = createPathHelpers(scope)
const tabs = layout.tabs(() => const tabs = layout.tabs(() =>
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)), SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
) )
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
@@ -123,7 +123,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}) })
}) })
const viewCache = createFileViewCache(serverSDK.scope) const viewCache = createFileViewCache(serverSDK().scope)
const view = createMemo(() => viewCache.load(scope(), params.id)) const view = createMemo(() => viewCache.load(scope(), params.id))
const ensure = (file: string) => { const ensure = (file: string) => {
@@ -211,8 +211,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
} }
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
serverSDK.api.file serverSDK()
.find( .api.file.find(
{ {
location: { directory: sdk().directory }, location: { directory: sdk().directory },
query, query,
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
const refresh: string[] = [] const refresh: string[] = []
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "filesystem.changed", type: "file.watcher.updated",
properties: { properties: {
file: "src/new.ts", file: "src/new.ts",
event: "add", event: "add",
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "filesystem.changed", type: "file.watcher.updated",
properties: { properties: {
file: "src/open.ts", file: "src/open.ts",
event: "change", event: "change",
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "filesystem.changed", type: "file.watcher.updated",
properties: { properties: {
file: "src", file: "src",
event: "change", event: "change",
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "filesystem.changed", type: "file.watcher.updated",
properties: { properties: {
file: "src/file.ts", file: "src/file.ts",
event: "change", event: "change",
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "filesystem.changed", type: "file.watcher.updated",
properties: { properties: {
file: ".git/index.lock", file: ".git/index.lock",
event: "change", event: "change",
+1 -1
View File
@@ -16,7 +16,7 @@ type WatcherOps = {
} }
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "filesystem.changed") return if (event.type !== "file.watcher.updated") return
const props = const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined const rawPath = typeof props?.file === "string" ? props.file : undefined
@@ -14,7 +14,6 @@ import { ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
type ProjectApi = ServerApi["project"] type ProjectApi = ServerApi["project"]
type WorktreeApi = ServerApi["worktree"]
describe("query keys", () => { describe("query keys", () => {
test("partitions identical directories by server scope", () => { test("partitions identical directories by server scope", () => {
@@ -107,58 +106,16 @@ describe("query keys", () => {
}) })
test("loads projects from the current endpoint", async () => { test("loads projects from the current endpoint", async () => {
const calls: string[] = [] const api = {
const projects = {
list: async () => [ list: async () => [
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, { id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, { id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
], ],
} as unknown as ProjectApi } as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
calls.push(projectID)
return [
{ directory: `/${projectID}` },
{ directory: `/${projectID}/clone` },
{ directory: `/${projectID}/copy`, strategy: "git" },
]
},
} as unknown as WorktreeApi
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees)) const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
expect(result.map((project) => project.id)).toEqual(["a", "b"]) expect(result.map((project) => project.id)).toEqual(["a", "b"])
expect(result.map((project) => project.sandboxes)).toEqual([
["/a/clone", "/a/copy"],
["/b/clone", "/b/copy"],
])
expect(result.map((project) => project.worktrees)).toEqual([
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
])
expect(calls.toSorted()).toEqual(["a", "b"])
})
test("keeps projects whose directory inventory cannot load", async () => {
const projects = {
list: async () => [
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
if (projectID === "b") throw new Error("unavailable")
return [{ directory: "/a/copy", strategy: "git" as const }]
},
} as unknown as WorktreeApi
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
{ id: "a", sandboxes: ["/a/copy"] },
{ id: "b", sandboxes: [] },
])
}) })
test("loads references from the current location-scoped endpoint", async () => { test("loads references from the current location-scoped endpoint", async () => {
+121 -163
View File
@@ -6,8 +6,6 @@ import type {
CommandInfo, CommandInfo,
CommandListInput, CommandListInput,
CommandListOutput, CommandListOutput,
IntegrationListInput,
IntegrationListOutput,
LocationGetInput, LocationGetInput,
LocationGetOutput, LocationGetOutput,
PermissionRequest, PermissionRequest,
@@ -26,16 +24,15 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js" import { batch } from "solid-js"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State } from "./types" import type { State, VcsCache } from "./types"
import type { ServerSession } from "../server-session" import type { ServerSession } from "../server-session"
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils" import { cmp, normalizeAgentList, normalizeProjectInfo, 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, loadMcpResourcesQuery } 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"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
import { sameDirectory } from "@/utils/workspace"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -107,7 +104,6 @@ type ProjectApi = {
readonly list: () => Promise<ProjectListOutput> readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput> readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
} }
type WorktreeApi = Pick<ServerApi["worktree"], "list">
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> } type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
type McpApi = ServerApi["mcp"] type McpApi = ServerApi["mcp"]
@@ -115,35 +111,15 @@ type PermissionApi = ServerApi["permission"]
type QuestionApi = ServerApi["question"] type QuestionApi = ServerApi["question"]
type VcsApi = ServerApi["vcs"] type VcsApi = ServerApi["vcs"]
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) => export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
queryOptions({ queryOptions({
queryKey: [scope, "project"], queryKey: [scope, "project"],
queryFn: () => queryFn: () =>
retry(() => retry(() =>
projects.list().then(async (items) => { api.list().then((projects) => {
return ( return projects
await Promise.all( .filter((p) => !!p?.id)
items .map(normalizeProjectInfo)
.filter((project) => !!project?.id)
.map(async (project) => {
const directories = await worktrees
.list({ projectID: project.id })
.catch(() => [
{ directory: project.canonical },
...(project.sandboxes ?? [])
.filter((directory) => !sameDirectory(project.canonical, directory))
.map((directory) => ({ directory })),
])
return normalizeProjectInfo({
...project,
sandboxes: directories
.map((item) => item.directory)
.filter((directory) => !sameDirectory(project.canonical, directory)),
worktrees: directories,
})
}),
)
)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice() .slice()
.sort((a, b) => cmp(a.id, b.id)) .sort((a, b) => cmp(a.id, b.id))
@@ -152,11 +128,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work
}) })
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
serverAPI: CatalogApi & { serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
readonly location: LocationApi
readonly project: ProjectApi
readonly worktree: WorktreeApi
}
scope: ServerScope scope: ServerScope
requestFailedTitle: string requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
@@ -170,7 +142,7 @@ export async function bootstrapGlobal(input: {
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)), () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() => () =>
input.queryClient input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree)) .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
.then((data) => input.setGlobalStore("project", data)), .then((data) => input.setGlobalStore("project", data)),
] ]
await runAll(slow) await runAll(slow)
@@ -249,10 +221,6 @@ type CommandListApi = {
readonly list: (input?: CommandListInput) => Promise<CommandListOutput> readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
} }
type IntegrationListApi = {
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
}
type ReferenceListApi = { type ReferenceListApi = {
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput> readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
} }
@@ -263,13 +231,6 @@ export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: Agen
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))), queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
}) })
export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) =>
queryOptions({
queryKey: [scope, directory, "integrations"] as const,
queryFn: () =>
retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)),
})
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> => export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
retry(() => api.list({ location: { directory } }).then((result) => result.data)) retry(() => api.list({ location: { directory } }).then((result) => result.data))
@@ -311,6 +272,7 @@ export async function bootstrapDirectory(input: {
} }
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
global: { global: {
@@ -335,109 +297,104 @@ export async function bootstrapDirectory(input: {
const revKey = ScopedKey.from(input.scope, input.directory) const revKey = ScopedKey.from(input.scope, input.directory)
const rev = (providerRev.get(revKey) ?? 0) + 1 const rev = (providerRev.get(revKey) ?? 0) + 1
providerRev.set(revKey, rev) providerRev.set(revKey, rev)
const slow = [ ;(async () => {
() => Promise.resolve(input.loadSessions(input.directory)), const slow = [
() => () => Promise.resolve(input.loadSessions(input.directory)),
input.queryClient () =>
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
.then((data) => input.setStore("agent", data)),
!seededProject &&
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.setStore("project", project.id),
)),
!seededPath &&
(() =>
input.queryClient input.queryClient
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location)) .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
.then((data) => { .then((data) => input.setStore("agent", data)),
const next = projectID(data.directory ?? input.directory, input.global.project) !seededProject &&
if (next) input.setStore("project", next) (() =>
})), retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.mcp && input.setStore("project", project.id),
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))), )),
() => !seededPath &&
input.queryClient.fetchQuery( (() =>
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference), input.queryClient
), .ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
() => .then((data) => {
retry(() => const next = projectID(data.directory ?? input.directory, input.global.project)
input.api.permission.request if (next) input.setStore("project", next)
.list({ location: { directory: input.directory } }) })),
.then((result) => result.data) input.mcp &&
.then((permissions) => { (() =>
const ids = permissions.map((permission) => permission.sessionID) loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
const grouped = groupBySession( () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.api.reference)),
permissions.filter((permission) => !!permission.id && !!permission.sessionID), () =>
) retry(() =>
const warm = input.session input.api.permission.request
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) .list({ location: { directory: input.directory } })
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) .then((result) => result.data)
return warm.then(() => .then((permissions) => {
batch(() => { const ids = permissions.map((permission) => permission.sessionID)
const current = input.session?.data.permission ?? input.store.permission const grouped = groupBySession(
for (const sessionID of Object.keys(current)) { permissions.filter((permission) => !!permission.id && !!permission.sessionID),
if (grouped[sessionID]) continue )
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue const warm = input.session
if (input.session) input.session.set("permission", sessionID, []) ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
if (!input.session) input.setStore("permission", sessionID, []) : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
} return warm.then(() =>
for (const [sessionID, permissions] of Object.entries(grouped)) { batch(() => {
const value = reconcile( const current = input.session?.data.permission ?? input.store.permission
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), for (const sessionID of Object.keys(current)) {
{ key: "id" }, if (grouped[sessionID]) continue
) if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, value) if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, value) if (!input.session) input.setStore("permission", sessionID, [])
} }
}), for (const [sessionID, permissions] of Object.entries(grouped)) {
) const value = reconcile(
}), permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
), { key: "id" },
() => )
retry(() => if (input.session) input.session.set("permission", sessionID, value)
input.api.question.request if (!input.session) input.setStore("permission", sessionID, value)
.list({ location: { directory: input.directory } }) }
.then((result) => result.data) }),
.then((questions) => { )
const ids = questions.map((question) => question.sessionID) }),
const grouped = groupBySession( ),
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], () =>
) retry(() =>
const warm = input.session input.api.question.request
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) .list({ location: { directory: input.directory } })
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) .then((result) => result.data)
return warm.then(() => .then((questions) => {
batch(() => { const ids = questions.map((question) => question.sessionID)
const current = input.session?.data.question ?? input.store.question const grouped = groupBySession(
for (const sessionID of Object.keys(current)) { questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
if (grouped[sessionID]) continue )
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue const warm = input.session
if (input.session) input.session.set("question", sessionID, []) ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
if (!input.session) input.setStore("question", sessionID, []) : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
} return warm.then(() =>
for (const [sessionID, questions] of Object.entries(grouped)) { batch(() => {
const value = reconcile( const current = input.session?.data.question ?? input.store.question
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), for (const sessionID of Object.keys(current)) {
{ key: "id" }, if (grouped[sessionID]) continue
) if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, value) if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, value) if (!input.session) input.setStore("question", sessionID, [])
} }
}), for (const [sessionID, questions] of Object.entries(grouped)) {
) const value = reconcile(
}), questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
), { key: "id" },
() => Promise.resolve(input.loadSessions(input.directory)), )
input.mcp && if (input.session) input.session.set("question", sessionID, value)
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))), if (!input.session) input.setStore("question", sessionID, value)
input.mcp && }
(() => }),
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))), )
() => }),
input.queryClient ),
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api)) () => Promise.resolve(input.loadSessions(input.directory)),
.catch((err) => { input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
() =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api)).catch((err) => {
const project = getFilename(input.directory) const project = getFilename(input.directory)
showToast({ showToast({
variant: "error", variant: "error",
@@ -445,19 +402,20 @@ export async function bootstrapDirectory(input: {
description: formatServerError(err, input.translate), description: formatServerError(err, input.translate),
}) })
}), }),
].filter(Boolean) as (() => Promise<any>)[] ].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint() await waitForPaint()
const slowErrs = errors(await runAll(slow)) const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) { if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0]) console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory) const project = getFilename(input.directory)
showToast({ showToast({
variant: "error", variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }), title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate), description: formatServerError(slowErrs[0], input.translate),
}) })
} }
if (loading && slowErrs.length === 0) input.setStore("status", "complete") if (loading && slowErrs.length === 0) input.setStore("status", "complete")
})()
} }
@@ -8,7 +8,6 @@ import { ServerScope } from "@/utils/server-scope"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = [] const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
let providerQuerySuccess = true
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [ const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
store[0], store[0],
store[1], store[1],
@@ -59,17 +58,11 @@ beforeAll(async () => {
get isLoading() { get isLoading() {
return options().queryKey?.[1] === "path" return options().queryKey?.[1] === "path"
}, },
get isSuccess() {
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess
},
get isRefetchError() {
return false
},
get data() { get data() {
if (options().queryKey?.[1] === "path") throw new Error("pending path data read") if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
if (options().queryKey?.[1] === "lsp") return [] if (options().queryKey?.[1] === "lsp") return []
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined if (options().queryKey?.[1] === "providers") return provider
return undefined return undefined
}, },
} }
@@ -90,7 +83,6 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({ const manager = createChildStoreManager({
owner, owner,
connected: () => true,
scope: ServerScope.local, scope: ServerScope.local,
persist, persist,
isBooting: () => false, isBooting: () => false,
@@ -122,7 +114,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
owner, owner,
connected: () => true,
scope: ServerScope.local, scope: ServerScope.local,
persist, persist,
isBooting: () => false, isBooting: () => false,
@@ -157,7 +148,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
owner, owner,
connected: () => true,
scope: ServerScope.local, scope: ServerScope.local,
persist, persist,
isBooting: () => false, isBooting: () => false,
@@ -183,37 +173,6 @@ describe("createChildStoreManager", () => {
} }
}) })
test("writes refreshed VCS data to the child store", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project", { bootstrap: false })
manager.vcs("/project", { branch: "feature", default_branch: "main" })
expect(store.vcs).toEqual({ branch: "feature", default_branch: "main" })
} finally {
dispose()
}
})
test("enables MCP only when requested for the directory", () => { test("enables MCP only when requested for the directory", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = querySingles.length const offset = querySingles.length
@@ -222,7 +181,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
owner, owner,
connected: () => true,
scope: ServerScope.local, scope: ServerScope.local,
persist, persist,
isBooting: () => false, isBooting: () => false,
@@ -272,7 +230,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
owner, owner,
connected: () => true,
scope: ServerScope.local, scope: ServerScope.local,
persist, persist,
isBooting: () => false, isBooting: () => false,
@@ -316,71 +273,4 @@ describe("createChildStoreManager", () => {
dispose() dispose()
} }
}) })
test("does not mark a cancelled provider query as ready", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
providerQuerySuccess = false
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/cancelled")
expect(store.provider_ready).toBe(false)
} finally {
providerQuerySuccess = true
dispose()
}
})
test("does not enable location queries before the event handshake", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
let connected = false
const offset = querySingles.length
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => connected,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
manager.child("/handshake")
const queries = querySingles.slice(offset)
expect(queries[0]?.().enabled).toBe(false)
expect(queries[4]?.().enabled).toBe(false)
connected = true
expect(queries[0]?.().enabled).toBe(true)
expect(queries[4]?.().enabled).toBe(true)
} finally {
dispose()
}
})
}) })
@@ -1,4 +1,4 @@
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js" import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@/types" import type { VcsInfo } from "@/types"
@@ -22,7 +22,6 @@ import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
owner: Owner owner: Owner
connected: Accessor<boolean>
scope: ServerScope scope: ServerScope
persist: typeof persisted persist: typeof persisted
isBooting: (directory: string) => boolean isBooting: (directory: string) => boolean
@@ -189,29 +188,17 @@ export function createChildStoreManager(input: {
const [mcpEnabled, setMcpEnabled] = createSignal(false) const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false) const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const pathQuery = useQuery(() => ({ const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
...input.queryOptions.path(key), const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
enabled: input.connected() && instanceQueriesEnabled(), const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
})) const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({
...input.queryOptions.mcp(key),
enabled: input.connected() && mcpEnabled(),
}))
const mcpResourceQuery = useQuery(() => ({
...input.queryOptions.mcpResources(key),
enabled: input.connected() && mcpEnabled(),
}))
const lspQuery = useQuery(() => ({
...input.queryOptions.lsp(key),
enabled: input.connected() && instanceQueriesEnabled(),
}))
const providerQuery = useQuery(() => ({ const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key), ...input.queryOptions.providers(key),
enabled: input.connected() && instanceQueriesEnabled(), enabled: instanceQueriesEnabled(),
})) }))
const referenceQuery = useQuery(() => ({ const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key), ...input.queryOptions.references(key),
enabled: input.connected() && instanceQueriesEnabled(), enabled: instanceQueriesEnabled(),
})) }))
const child = createStore<State>({ const child = createStore<State>({
@@ -219,14 +206,13 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta, projectMeta: initialMeta,
icon: initialIcon, icon: initialIcon,
get provider_ready() { get provider_ready() {
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError) return instanceQueriesEnabled() && !providerQuery.isLoading
}, },
get provider() { get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} } const EMPTY = { all: new Map(), connected: [], default: {} }
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY if (providerQuery.isLoading) return EMPTY
const provider = providerQuery.data if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider return providerQuery.data ?? EMPTY
return provider
}, },
config: {}, config: {},
get path() { get path() {
@@ -387,15 +373,6 @@ export function createChildStoreManager(input: {
setStore("icon", value) setStore("icon", value)
} }
function vcs(directory: string, value: VcsInfo) {
const key = directoryKey(directory)
const child = ensureChild(directory)
const cached = vcsCache.get(key)
if (!cached) return
cached.setStore("value", value)
child[1]("vcs", value)
}
return { return {
children, children,
ensureChild, ensureChild,
@@ -403,7 +380,6 @@ export function createChildStoreManager(input: {
peek, peek,
projectMeta, projectMeta,
projectIcon, projectIcon,
vcs,
mark, mark,
pin, pin,
unpin, unpin,
@@ -120,7 +120,7 @@ describe("applyGlobalEvent", () => {
expect(refreshCount).toBe(1) expect(refreshCount).toBe(1)
}) })
test("leaves server.connected refresh to the connection sync", () => { test("handles server.connected by triggering refresh", () => {
let refreshCount = 0 let refreshCount = 0
applyGlobalEvent({ applyGlobalEvent({
event: { type: "server.connected" }, event: { type: "server.connected" },
@@ -131,7 +131,7 @@ describe("applyGlobalEvent", () => {
setGlobalProject() {}, setGlobalProject() {},
}) })
expect(refreshCount).toBe(0) expect(refreshCount).toBe(1)
}) })
}) })
@@ -1,5 +1,4 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { Worktree } from "@opencode-ai/schema/worktree"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { Message, Part, Project, Todo } from "@/types" import type { Message, Part, Project, Todo } from "@/types"
import type { import type {
@@ -38,10 +37,11 @@ export function applyGlobalEvent(input: {
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void refresh: () => void
}) { }) {
if (input.event.type === "global.disposed") { if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
input.refresh() input.refresh()
return return
} }
if (input.event.type !== "project.updated") return if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (s) => s.id) const result = Binary.search(input.project, properties.id, (s) => s.id)
@@ -187,18 +187,6 @@ export function applyDirectoryEvent(input: {
input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break break
} }
case "worktree.resolved": {
const properties = event.properties as { projectID: string; directory: string; previous: string }
input.store.session.forEach((session, index) => {
const adopted = Worktree.adopt(
{ projectID: session.projectID, directory: session.location.directory },
properties,
)
if (!adopted) return
input.setStore("session", index, (current) => ({ ...current, ...adopted }))
})
break
}
case "session.renamed": { case "session.renamed": {
const properties = event.properties as { sessionID: string; title: string } const properties = event.properties as { sessionID: string; title: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message, Part, Todo } from "@/types" import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -34,7 +34,6 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined> question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined> part_text_accum_delta: Record<string, string | undefined>
} = { } = {
session_status: { ses_1: { type: "busy" } as SessionStatus }, session_status: { ses_1: { type: "busy" } as SessionStatus },
@@ -45,7 +44,6 @@ describe("app session cache", () => {
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] }, permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] }, question: { ses_1: [] as QuestionRequest[] },
form: { ses_1: [] as FormInfo[] },
part_text_accum_delta: { prt_1: "streamed text" }, part_text_accum_delta: { prt_1: "streamed text" },
} }
@@ -59,7 +57,6 @@ describe("app session cache", () => {
expect(store.session_status.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined() expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined() expect(store.question.ses_1).toBeUndefined()
expect(store.form.ses_1).toBeUndefined()
}) })
test("dropSessionCaches clears message-backed parts", () => { test("dropSessionCaches clears message-backed parts", () => {
@@ -73,7 +70,6 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined> question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined> part_text_accum_delta: Record<string, string | undefined>
} = { } = {
session_status: {}, session_status: {},
@@ -84,7 +80,6 @@ describe("app session cache", () => {
part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {}, permission: {},
question: {}, question: {},
form: {},
part_text_accum_delta: {}, part_text_accum_delta: {},
} }
@@ -1,5 +1,5 @@
import type { Message, Part, Todo } from "@/types" import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -14,7 +14,6 @@ type SessionCache = {
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined> question: Record<string, QuestionRequest[] | undefined>
form?: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined> part_text_accum_delta: Record<string, string | undefined>
} }
@@ -39,7 +38,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
delete store.session_status[sessionID] delete store.session_status[sessionID]
delete store.permission[sessionID] delete store.permission[sessionID]
delete store.question[sessionID] delete store.question[sessionID]
if (store.form) delete store.form[sessionID]
} }
} }
@@ -124,11 +124,9 @@ export function normalizeProviderList(
} }
export function normalizeProjectInfo(project: Project | CurrentProject): Project { export function normalizeProjectInfo(project: Project | CurrentProject): Project {
const worktree = "canonical" in project ? project.canonical : project.worktree
return { return {
...project, ...project,
worktree, worktree: "canonical" in project ? project.canonical : project.worktree,
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
vcs: project.vcs === "git" ? "git" : undefined, vcs: project.vcs === "git" ? "git" : undefined,
} }
} }

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