Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 047d9255ce fix(tui): hide background badge on interrupted shells 2026-08-12 15:08:36 +00:00
850 changed files with 7816 additions and 163393 deletions
-1
View File
@@ -10,7 +10,6 @@ kitlangton
kommander
ludvigrask
MrMushrooooom
neriousy
nexxeln
R44VC0RP
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:
- ci
- dev
- v2
- beta
- fix/npm-native-binary-install
- snapshot-*
+1 -7
View File
@@ -55,12 +55,6 @@ jobs:
git config --global user.email "bot@opencode.ai"
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
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -72,7 +66,7 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
run: GITHUB_ACTIONS=false bun turbo test
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
+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,
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
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
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
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.
@@ -114,8 +114,8 @@ completion are automatic.
You can see some example scripts here:
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/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/simple.ts
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
## Prune
+1 -1
View File
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "neriousy", "nexxeln", "kitlangton"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"],
} as const
+2 -3
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.
- 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.
- The default branch in this repo is `v2`.
- 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 `v2` or `origin/v2` for diffs.
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
## Live V2 TUI Testing
+512 -1072
View File
File diff suppressed because it is too large Load Diff
+7 -10
View File
@@ -33,7 +33,6 @@
"packages": [
"packages/*",
"packages/console/*",
"packages/lab/*",
"packages/stats/*",
"packages/slack"
],
@@ -47,9 +46,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.2",
"@opentui/keymap": "0.5.2",
"@opentui/solid": "0.5.2",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -121,8 +120,7 @@
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "catalog:",
"turbo": "2.10.2",
"vitest": "4.1.10"
"turbo": "2.10.2"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
@@ -152,9 +150,9 @@
"electron"
],
"overrides": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:"
@@ -174,7 +172,6 @@
"@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",
"@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"
}
}
@@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { Event } from "@opencode-ai/schema/event"
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
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 { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
@@ -90,8 +90,6 @@ export async function setupTimeline(
page: Page,
input: {
messages?: TimelineMessage[]
currentMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean>
sessions?: Session[]
cpuRate?: number
@@ -104,22 +102,13 @@ export async function setupTimeline(
} = {},
) {
const sessions = input.sessions ?? [session()]
const messages =
input.currentMessages ??
validateTimelineMessages([
...(input.seedHistory ? historyMessages(18) : []),
...(input.messages ?? [userMessage(), assistantMessage()]),
])
const active = messages.findLast((message) =>
"info" in message ? message.info.role === "assistant" : message.type === "assistant",
)
const messages = validateTimelineMessages([
...(input.seedHistory ? historyMessages(18) : []),
...(input.messages ?? [userMessage(), assistantMessage()]),
])
const active = messages.findLast((message) => message.info.role === "assistant")
const initialStatus = decodeStatus(
active &&
("info" in active
? active.info.role === "assistant" && active.info.time.completed === undefined
: active.type === "assistant" && active.time.completed === undefined)
? { type: "busy" }
: { type: "idle" },
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
decodeOptions,
)
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(),
provider: provider(),
sessions,
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus },
sessionStatus: { [sessionID]: initialStatus },
pageMessages: () => ({
items: messages,
}),
@@ -122,7 +122,6 @@ test("applies message latency after a list response gate is released", async ()
const gate = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
@@ -1,7 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const serverA = "http://127.0.0.1:4096"
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[]) {
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
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 directory = url.searchParams.get("directory")
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/session") return json(route, { data: [currentSession(current)], cursor: {} })
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),
})
}
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 }) => {
const permissionRequests: string[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await mockServers(page, permissionRequests)
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 }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverB })
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: serverA,
retry: 20,
@@ -184,6 +181,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/provider")
return json(route, {
location: { directory },
@@ -327,3 +325,7 @@ function json(route: Route, body: unknown, status = 200) {
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -11,23 +11,18 @@ const title = "Request dock regression"
test("shows a pending question dock", async ({ page }) => {
await mockServer(page, {
forms: [
questions: [
{
id: "frm_question_request",
id: "question-request",
sessionID,
title: "Questions",
metadata: { kind: "question" },
fields: [
questions: [
{
key: "q0",
type: "string",
title: "Implementation",
description: "Which implementation should be used?",
header: "Implementation",
question: "Which implementation should be used?",
options: [
{ value: "minimal", label: "Minimal", description: "Use the smallest correct change" },
{ value: "extended", label: "Extended", description: "Include additional behavior" },
{ label: "Minimal", description: "Use the smallest correct change" },
{ label: "Extended", description: "Include additional behavior" },
],
custom: true,
},
],
},
@@ -47,7 +42,7 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = []
page.on("request", (request) => {
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())
})
@@ -72,10 +67,10 @@ test("shows a pending question dock", async ({ page }) => {
const reply = page.waitForRequest(
(request) =>
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()
expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } })
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
})
test("shows a pending permission dock", async ({ page }) => {
@@ -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"}`,
retry: 20,
})
await mockServer(page, { forms: [] })
await mockServer(page, { questions: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
@@ -140,24 +135,18 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.send({
directory,
payload: {
type: "form.created",
type: "question.asked",
properties: {
form: {
id: "frm_question_caret",
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
fields: [
{
key: "q0",
type: "string",
title: "Continue",
description: "Continue?",
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
custom: true,
},
],
},
id: "question-caret",
sessionID,
questions: [
{
header: "Continue",
question: "Continue?",
options: [{ label: "Yes", description: "Continue the session" }],
},
],
tool: { messageID: "message-caret", callID: "call-caret" },
},
},
})
@@ -167,10 +156,7 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.send({
directory,
payload: {
type: "form.cancelled",
properties: { sessionID, id: "frm_question_caret" },
},
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
})
await expect(question).toHaveCount(0)
await expect(editor).toBeVisible()
@@ -184,8 +170,6 @@ async function mockServer(
requests: {
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
sessionStatus?: Record<string, unknown>
},
) {
await mockOpenCodeServer(page, {
@@ -230,8 +214,6 @@ async function mockServer(
pageMessages: () => ({ items: [] }),
permissions: requests.permissions,
questions: requests.questions,
forms: requests.forms,
sessionStatus: requests.sessionStatus,
})
await page.addInitScript(() => {
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")
})
-112
View File
@@ -32,110 +32,15 @@ export interface MockServerConfig {
todos?: (sessionID: string) => unknown[]
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => 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) {
const cursors = new Map<string, string>()
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> = {
"/path": {
state: config.directory,
@@ -290,11 +195,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
location: location(config),
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")
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
@@ -380,18 +280,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
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") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
+22 -23
View File
@@ -54,7 +54,7 @@ import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { decode64 } from "@/utils/base64"
import { SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -86,23 +86,18 @@ function TargetServerRoute(props: ParentProps) {
return (
// Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn()}>
<ServerSyncProvider server={conn()}>{props.children}</ServerSyncProvider>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
function TargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</SessionRouteErrorBoundary>
)
}
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.
@@ -135,14 +130,16 @@ function DraftRoute() {
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
const directory = () => props.draft.directory
const serverKey = () => props.draft.server
return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn()}>
<ServerSyncProvider server={conn()}>
<ModelsProvider directory={props.draft.directory}>
<SDKProvider directory={props.draft.directory}>
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ModelsProvider directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
<NewSession />
</DraftProviders>
@@ -240,7 +237,7 @@ function DesktopCommands() {
}
type ServerScopedShellProps = ParentProps<{
directory?: string
directory?: () => string | undefined
serverScoped?: JSX.Element
}>
@@ -453,10 +450,12 @@ export function AppInterface(props: {
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const ServerShell = (shellProps: ParentProps) => (
<SharedProviders>
{props.children}
{shellProps.children}
</SharedProviders>
<QueryProvider>
<SharedProviders>
{props.children}
{shellProps.children}
</SharedProviders>
</QueryProvider>
)
return (
+3 -3
View File
@@ -1,5 +1,5 @@
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 { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
@@ -571,7 +571,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
/>
<Show when={platform.setForceFocus}>
{platform.setForceFocus && (
<ToggleCell
active={state.focus}
inline={props.inline}
@@ -580,7 +580,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
</Show>
)}
</div>
</aside>
)
@@ -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 { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
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 { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link"
@@ -20,7 +20,6 @@ import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
@@ -41,7 +40,7 @@ export function useProviderConnectController(options: { onBack?: () => void } =
}
export const DialogConnectProvider: Component<{
directory?: string
directory?: Accessor<string | undefined>
controller?: ReturnType<typeof useProviderConnectController>
}> = (props) => {
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()
if (settings.general.newLayoutDesigns())
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 popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
@@ -163,7 +166,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...integrations.list()]
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
}}
filterKeys={["id", "name"]}
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 }) {
const integrations = useIntegrations(() => props.directory)
function ProviderPickerV2(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const providers = useProviders(() => props.directory?.())
const language = useLanguage()
const [store, setStore] = createStore({
filter: "",
@@ -221,7 +228,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
const all = createMemo(() => {
language.locale()
const query = store.filter.trim().toLowerCase()
const values = [custom(), ...integrations.list()]
const values = [custom(), ...providers.all().values()]
if (!query) return values
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: {
provider: string
directory?: string
directory?: Accessor<string | undefined>
onBack: () => void
setBack: (handler: () => void) => void
}) {
@@ -367,9 +374,12 @@ function ProviderConnection(props: {
const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(() => props.directory)
const directory = () => props.directory ?? decode64(params.dir)
const providers = useProviders(() => props.directory?.())
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({
provider: () => props.provider,
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 }) => {
if (!value) return ""
if (value.type === "key") return language.t("provider.connect.method.apiKey")
@@ -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 { 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 { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon } from "@opencode-ai/ui/v2/icon"
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 { 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 { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { SDKProvider } from "@/context/sdk"
import { ServerConnection } from "@/context/server"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
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 }) {
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 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 (
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog">
<TabsV2
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="project-settings-v2"
>
<TabsV2.List>
<div class="project-settings-v2-nav">
<TabsV2.Trigger value="general">
<ProjectAvatar
fallback={projectName()}
variant={getProjectAvatarVariant(props.project.icon?.color)}
class="!size-4 shrink-0"
/>
<span class="truncate">{projectName()}</span>
</TabsV2.Trigger>
<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>
<Dialog fit>
<form onSubmit={model.submit} class="contents">
<DialogHeader>
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<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>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInputV2
autofocus
appearance="large"
class="!w-full"
value={model.store.name}
placeholder={model.folderName()}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
/>
</Field>
<TabsV2.Content value="general" class="project-settings-v2-panel">
<form onSubmit={model.submit} class="project-settings-v2-form">
<div class="project-settings-v2-scroll">
<div class="project-settings-page-header">
<h2>{language.t("dialog.project.edit.title")}</h2>
<span>{language.t("project.settings.general.description")}</span>
</div>
<Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInputV2
autofocus
appearance="large"
class="!w-full"
value={model.store.name}
placeholder={model.folderName()}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
<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.icon")}
</div>
<div class="flex items-center gap-3">
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
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"
classList={{
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
}}
onMouseEnter={() => model.setStore("iconHover", true)}
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]"
/>
</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">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.icon")}
</div>
<div class="flex items-center gap-3">
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
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"
classList={{
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
}}
onMouseEnter={() => model.setStore("iconHover", true)}
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"
<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={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
"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,
)
}}
>
<IconV2 name={model.store.iconOverride ? "close" : "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>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</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>
<Footer />
</form>
</TabsV2.Content>
</Show>
<TabsV2.Content value="scripts" class="project-settings-v2-panel">
<form onSubmit={model.submit} class="project-settings-v2-form">
<div class="project-settings-v2-scroll">
<div class="project-settings-page-header">
<h2>{language.t("project.settings.scripts")}</h2>
<span>{language.t("project.settings.scripts.description")}</span>
</div>
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<TextareaV2
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={5}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
/>
</Field>
</div>
<Footer />
</form>
</TabsV2.Content>
<TabsV2.Content value="extensions" class="project-settings-v2-panel">
<ProjectSettingsExtensions />
</TabsV2.Content>
</TabsV2>
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<TextareaV2
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={3}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
/>
</Field>
</DialogBody>
<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>
</form>
</Dialog>
)
}
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
void dialog.show(() => <DialogConnectProvider directory={directory} />)
}
const providerRank = (id: string) => popularProviders.indexOf(id)
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 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 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 { Button } from "@opencode-ai/ui/button"
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) */}
<div class="flex flex-col gap-12">
<div class="flex flex-col items-start gap-3">
<Show
when={isLast()}
fallback={
<Button variant="secondary" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")}
</Button>
}
>
{isLast() ? (
<Button variant="primary" size="large" onClick={handleClose}>
{language.t("dialog.releaseNotes.action.getStarted")}
</Button>
</Show>
) : (
<Button variant="secondary" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")}
</Button>
)}
<Button variant="ghost" size="small" onClick={handleDisable}>
{language.t("dialog.releaseNotes.action.hideFuture")}
</Button>
</div>
<Show when={paged()}>
{paged() && (
<div class="flex items-center gap-1.5 -my-2.5">
<Index each={props.highlights}>
{(_, i) => (
<button
type="button"
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
{props.highlights.map((_, i) => (
<button
type="button"
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={{
"w-8": i === index(),
"w-3": i !== index(),
"bg-icon-strong-base": i === index(),
"bg-icon-weak-base": i !== index(),
}}
onClick={() => setIndex(i)}
>
<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>
/>
</button>
))}
</div>
</Show>
)}
</div>
</div>
{/* Right side - Media content (edge to edge) */}
<Show when={feature()?.media}>
{(media) => (
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
<Show
when={media().type === "image"}
fallback={
<video src={media().src} autoplay loop muted playsinline class="w-full h-full object-cover" />
}
>
<img
src={media().src}
alt={media().alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
class="w-full h-full object-cover"
/>
</Show>
</div>
)}
</Show>
{feature()?.media && (
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
{feature()!.media!.type === "image" ? (
<img
src={feature()!.media!.src}
alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
class="w-full h-full object-cover"
/>
) : (
<video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
)}
</div>
)}
</div>
</Dialog>
)
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { useIntegrations } from "@/hooks/use-integrations"
import { useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip"
@@ -22,7 +22,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
const dialog = useDialog()
const theme = useTheme()
const directory = () => decode64(local.slug())
const integrations = useIntegrations(directory)
const providers = useProviders(directory)
const language = useLanguage()
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
const currentKey = createMemo(() => {
@@ -37,7 +37,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController()
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 class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
<For
each={integrations
.list()
each={[...providers.popular()]
.filter((provider) => featuredProviders.includes(provider.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) => {
const controller = x.useProviderConnectController()
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 = () => {
close("provider")
void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
})
}
const language = useLanguage()
@@ -240,7 +240,7 @@ export function ModelSelectorPopoverV2(props: {
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current()}
current={controller.current}
select={controller.select}
onManage={() => {
void import("./dialog-manage-models").then((module) => {
@@ -295,7 +295,7 @@ function ModelSelectorPopoverV2View(props: {
trigger: ModelSelectorTrigger
models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: string | undefined
current: () => string | undefined
select: (item: ModelItem) => void
onManage: () => void
onClose: () => void
@@ -310,7 +310,7 @@ function ModelSelectorPopoverV2View(props: {
const groups = createMemo(() => props.groups(models()))
const keys = () => [...models().map(modelKey), manageKey]
const initialActive = () => {
const selected = props.current
const selected = props.current()
const options = keys()
if (selected && options.includes(selected)) return selected
return options[0] ?? ""
@@ -453,7 +453,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel>
<MenuV2.RadioGroup value={props.current}>
<MenuV2.RadioGroup value={props.current()}>
<For each={group.items}>
{(item) => (
<TooltipV2
@@ -473,7 +473,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.RadioItem
value={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"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
@@ -529,7 +529,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
const provider = () => {
void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
})
}
@@ -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>
)
}
@@ -238,7 +238,6 @@ export function createProviderConnectionController(options: {
return {
loading: () => integration.loading,
integration: () => integration.latest,
methods,
currentMethod,
methodIndex: () => store.methodIndex,
@@ -96,16 +96,13 @@ export function ServerRow(props: ServerRowProps) {
{(conn) => (
<div class="flex flex-row gap-3">
<span>
<Show
when={conn().http.username}
fallback={<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>}
>
{conn().http.username ? (
<span class="text-text-weak">{conn().http.username}</span>
</Show>
) : (
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
)}
</span>
<Show when={conn().http.password}>
<span class="text-text-weak"></span>
</Show>
{conn().http.password && <span class="text-text-weak"></span>}
</div>
)}
</Show>
@@ -12,7 +12,7 @@ import { FileVisual } from "./session-sortable-tab"
export function SortableTabV2(props: {
tab: string
index: number
index: () => number
temporary?: boolean
onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
@@ -26,7 +26,7 @@ export function SortableTabV2(props: {
return props.tab
},
get index() {
return props.index
return props.index()
},
})
const path = createMemo(() => file.pathFromTab(props.tab))
@@ -14,7 +14,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
export function SortableTerminalTabV2(props: {
terminal: LocalPTY
index: number
index: () => number
newLayout: boolean
onClose?: () => void
}): JSX.Element {
@@ -25,7 +25,7 @@ export function SortableTerminalTabV2(props: {
return props.terminal.id
},
get index() {
return props.index
return props.index()
},
})
const [store, setStore] = createStore({
@@ -1,6 +1,5 @@
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -425,9 +424,9 @@ function SettingsKeybindsV2() {
filtered={controller.catalog.filtered}
title={controller.catalog.title}
keybind={controller.catalog.keybind}
active={controller.capture.active()}
active={controller.capture.active}
onCapture={controller.capture.toggle}
hasOverrides={controller.settings.hasOverrides()}
hasOverrides={controller.settings.hasOverrides}
onReset={controller.settings.reset}
/>
)
@@ -438,9 +437,9 @@ function SettingsKeybindsV2View(props: {
filtered: (query: string) => Map<KeybindGroup, string[]>
title: (id: string) => string
keybind: (id: string) => string
active: string | null
active: () => string | null
onCapture: (id: string) => void
hasOverrides: boolean
hasOverrides: () => boolean
onReset: () => void
}) {
const language = useLanguage()
@@ -452,11 +451,8 @@ function SettingsKeybindsV2View(props: {
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<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>
<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}>
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</div>
@@ -502,12 +498,12 @@ function SettingsKeybindsV2View(props: {
data-keybind-id={id}
classList={{
"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)}
>
<Show
when={props.active === id}
when={props.active() === id}
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
@@ -678,6 +674,8 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
</Show>
)
const List = props.v2 ? SettingsListV2 : SettingsList
const groups = (
<div
classList={{
@@ -702,7 +700,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
>
{language.t(groupKey[group])}
</h3>
<Dynamic component={props.v2 ? SettingsListV2 : SettingsList}>
<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">
@@ -737,7 +735,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
</div>
)}
</For>
</Dynamic>
</List>
</div>
</Show>
)}
@@ -1,29 +0,0 @@
import { type ParentProps, Show } from "solid-js"
import { useGlobal } from "@/context/global"
import { ModelsProvider } from "@/context/models"
import { ServerConnection } from "@/context/server"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
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 (
<ServerSDKProvider server={props.server}>
<ServerSyncProvider server={props.server}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
)
}
@@ -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>
</>
)
}
@@ -5,22 +5,15 @@ import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { SettingsGeneralV2 } from "./general"
import { SettingsAppearanceV2 } from "./appearance"
import { SettingsKeybinds } from "../settings-keybinds"
import { SettingsNotificationsV2 } from "./notifications"
import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServersV2 } from "./servers"
import { SettingsProjectsV2 } from "./projects"
import { SettingsExtensionsV2 } from "./extensions"
import { SettingsServerScope } from "../settings-server-picker"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
import { useGlobal } from "@/context/global"
import { ServerConnection } from "@/context/server"
import "./settings-v2.css"
export const DialogSettings: Component<{
sessionID?: string
@@ -32,13 +25,8 @@ export const DialogSettings: Component<{
const layout = useLayout()
const tabs = useTabs()
const serverSync = useServerSync()
const global = useGlobal()
const currentServer = global.servers.list().find((server) => global.ensureServerCtx(server).sync === serverSync())
if (currentServer) global.settings.server.set(ServerConnection.key(currentServer))
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const directory = createMemo(() => {
const server = global.settings.server.selected()
if (!server || serverSync() !== global.ensureServerCtx(server).sync) return
const route = layout.route()
if (route.type === "dir-new-sesssion") return route.dir
if (route.type === "draft") {
@@ -64,92 +52,62 @@ export const DialogSettings: Component<{
>
<TabsV2.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-4 w-full">
{/* Group 1: Preferences */}
<div class="flex flex-col gap-1 w-full">
<TabsV2.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.preferences")}
</TabsV2.Trigger>
<TabsV2.Trigger value="appearance">
<Icon name="appearance" />
{language.t("settings.general.section.appearance")}
</TabsV2.Trigger>
<TabsV2.Trigger value="notifications">
<Icon name="notifications" />
{language.t("settings.tab.notifications")}
</TabsV2.Trigger>
<TabsV2.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</TabsV2.Trigger>
</div>
<div class="flex flex-col gap-3 w-full">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.general")}
</TabsV2.Trigger>
<TabsV2.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</TabsV2.Trigger>
</div>
</div>
{/* Group 2: Environment & Workspaces */}
<div class="flex flex-col gap-1 w-full">
<TabsV2.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</TabsV2.Trigger>
<TabsV2.Trigger value="projects">
<Icon name="folder" />
{language.t("settings.tab.projects")}
</TabsV2.Trigger>
</div>
{/* Group 3: Capabilities & Extensions */}
<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 class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</TabsV2.Trigger>
<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>
</div>
</div>
</div>
</div>
<div class="settings-v2-nav-footer">
<span>{language.t("app.name.desktop")}</span>
<span>v{platform.version}</span>
</div>
</div>
</TabsV2.List>
<TabsV2.Content value="general" class="settings-v2-panel">
<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 value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="projects" class="settings-v2-panel">
<SettingsProjectsV2 />
<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>
<SettingsServerScope directory={directory()}>
<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>
</Dialog>
)
@@ -1,156 +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>
</>
)
}
@@ -279,6 +279,8 @@ export const SettingsGeneralV2: Component<{
const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(() => props.sessionID)
const shell = createShellSettingsController()
const appearance = createAppearanceSettingsController()
const sounds = createSoundSettingsController()
const desktop = createMemo(() => platform.platform === "desktop")
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
@@ -296,7 +298,6 @@ export const SettingsGeneralV2: Component<{
const GeneralSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.general")}</h3>
<SettingsListV2>
<LanguageSetting />
@@ -509,19 +510,18 @@ export const SettingsGeneralV2: Component<{
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.preferences")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("settings.preferences.description")}
</span>
</div>
</div>
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
</div>
<div class="settings-v2-tab-body">
<GeneralSection />
<AppearanceSection controller={appearance} />
<NotificationsSection />
<SoundsSection controller={sounds} />
<Show when={desktop()}>
<UpdatesSection />
</Show>
@@ -11,7 +11,6 @@ import { useModels } from "@/context/models"
import { useServerSDK } from "@/context/server-sdk"
import { popularProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { InlineServerSelect } from "./parts/server-select"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css"
@@ -54,13 +53,7 @@ export const SettingsModelsV2: Component = () => {
return (
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<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>
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
<div class="settings-v2-tab-search">
<TextInputV2
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/server"
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/server"
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 { showToast } from "@/utils/toast"
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 { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-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 "./settings-v2.css"
@@ -32,35 +30,25 @@ const PROVIDER_NOTES = [
const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component<{
directory: string | undefined
directory: Accessor<string | undefined>
onBack?: () => void
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders(() => props.directory)
const providers = useProviders(props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => (
<SettingsServerScope directory={props.directory}>
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
</SettingsServerScope>
))
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
}
const connected = createMemo(() => {
return providers.connected().filter(
(provider) =>
provider.id !== "opencode" ||
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
}),
)
return providers
.connected()
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
})
const popular = createMemo(() => {
@@ -104,8 +92,31 @@ export const SettingsProvidersV2: Component<{
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 location = props.directory ? { directory: props.directory } : undefined
const location = props.directory() ? { directory: props.directory() } : undefined
await serverSdk()
.api.integration.get({ integrationID: providerID, location })
.then(async (integration) => {
@@ -130,13 +141,7 @@ export const SettingsProvidersV2: Component<{
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.providers.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
</div>
<InlineServerSelect />
</div>
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
</div>
<div class="settings-v2-tab-body settings-v2-providers">
@@ -239,11 +244,7 @@ export const SettingsProvidersV2: Component<{
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => (
<SettingsServerScope directory={props.directory}>
<DialogCustomProvider onBack={dialog.close} />
</SettingsServerScope>
))
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
@@ -53,10 +53,7 @@ export const SettingsServersV2: Component = () => {
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
>
<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>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
</div>
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<AddServerMenu onAddServer={openAdd} />
</div>
<Show when={showSearch()}>
@@ -39,14 +39,6 @@
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 {
font-size: 15px;
font-weight: 640;
@@ -182,13 +174,13 @@
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);
}
@media (max-width: 639px) {
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
[data-slot="tabs-v2-list"] {
width: 144px;
min-width: 144px;
padding-inline: 8px;
@@ -735,24 +727,3 @@
line-height: 1;
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,7 +1,16 @@
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
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 { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
@@ -100,7 +109,7 @@ type ServerStatusItem = {
onSelect: () => void
}
export function StatusPopoverBody(props: { shown: boolean }) {
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
const sdk = useSDK()
const global = useGlobal()
@@ -116,6 +125,10 @@ export function StatusPopoverBody(props: { shown: boolean }) {
})
}
createEffect(() => {
if (!props.shown()) return
})
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
@@ -134,7 +147,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined),
() => (props.shown() ? sdk().directory : undefined),
(directory) =>
sdk()
.api.plugin.list({ location: { directory } })
@@ -74,7 +74,7 @@ export function StatusPopover() {
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
}
>
<Body shown={shown()} />
<Body shown={shown} />
</Suspense>
</Show>
</Popover>
@@ -114,7 +114,7 @@ function DirectoryStatusPopover() {
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<Body shown={shown()} />
<Body shown={shown} />
</StatusPopoverBody>
),
}))
-1
View File
@@ -175,7 +175,6 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings()
const theme = useTheme()
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 url = sdk().url
let container!: HTMLDivElement
@@ -22,14 +22,14 @@ export function TabNavItem(props: {
ref?: Ref<HTMLDivElement>
href: string
server: ServerConnection.Key
session: SessionInfo | undefined
session: () => SessionInfo | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onClose: () => void
onNavigate: () => void
active?: boolean
forceTruncate?: boolean
suppressNavigation?: boolean
suppressNavigation?: () => boolean
dragging?: boolean
pressed?: boolean
hidden?: boolean
@@ -52,22 +52,22 @@ export function TabNavItem(props: {
if (conn) return global.ensureServerCtx(conn)
})
const project = createMemo(() => {
const session = props.session
const session = props.session()
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
const title = createMemo(() => {
const session = props.session
const session = props.session()
return session ? sessionLabel(session) : props.fallbackTitle
})
const projectName = createMemo(() => {
const session = props.session
const session = props.session()
if (!session) return
return displayName(project() ?? { worktree: session.location.directory })
})
const previewPath = createMemo(() => {
const session = props.session
const session = props.session()
if (!session) return
const home = serverCtx()?.sync.data.path.home
return home ? session.location.directory.replace(home, "~") : session.location.directory
@@ -80,7 +80,7 @@ export function TabNavItem(props: {
})
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 = () => {
if (!titleEl || editing()) {
@@ -121,7 +121,7 @@ export function TabNavItem(props: {
const closeRename = async (save: boolean) => {
if (rename.isPending || !editing()) return
const original = props.session?.title ?? ""
const original = props.session()?.title ?? ""
const next = (titleEl.textContent ?? "").trim()
titleEl.scrollLeft = 0
@@ -146,7 +146,7 @@ export function TabNavItem(props: {
event.preventDefault()
event.stopPropagation()
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
const session = props.session
const session = props.session()
if (!session) return
titleEl.textContent = session.title ?? ""
setEditing(true)
@@ -213,7 +213,7 @@ export function TabNavItem(props: {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (editing()) return
if (props.suppressNavigation) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => {
@@ -221,14 +221,14 @@ export function TabNavItem(props: {
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (editing()) return
if (props.suppressNavigation) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session}
when={props.session()}
keyed
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
@@ -267,7 +267,7 @@ export function TabNavItem(props: {
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = props.session?.title ?? ""
titleEl.textContent = props.session()?.title ?? ""
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
@@ -308,7 +308,7 @@ export function TabNavItem(props: {
}}
data={{
projectName: projectName(),
title: props.session?.title,
title: props.session()?.title,
path: previewPath(),
serverName: serverLabel(),
}}
@@ -323,7 +323,7 @@ export function DraftTabItem(props: {
active?: boolean
onNavigate: () => void
onClose: () => void
suppressNavigation?: boolean
suppressNavigation?: () => boolean
dragging?: boolean
pressed?: boolean
hidden?: boolean
@@ -366,14 +366,14 @@ export function DraftTabItem(props: {
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (props.suppressNavigation) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (props.suppressNavigation) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
@@ -24,10 +24,10 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
function SessionTabSlot(props: {
tab: SessionTab
id: string
index: number
active: boolean
index: () => number
active: () => boolean
forceTruncate: boolean
session: SessionInfo | undefined
session: () => SessionInfo | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void
@@ -38,7 +38,7 @@ function SessionTabSlot(props: {
return props.id
},
get index() {
return props.index
return props.index()
},
})
let ref!: HTMLDivElement
@@ -48,7 +48,7 @@ function SessionTabSlot(props: {
ref={sortable.ref}
data-titlebar-tab-slot
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"
>
<TabNavItem
@@ -62,7 +62,7 @@ function SessionTabSlot(props: {
onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active}
active={props.active()}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/>
@@ -73,34 +73,34 @@ function SessionTabSlot(props: {
function SessionTabEntry(props: {
tab: SessionTab
id: string
index: number
active: boolean
index: () => number
active: () => boolean
forceTruncate: boolean
serverCtx: ServerCtx | undefined
serverCtx: () => ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const tabs = useTabs()
const language = useLanguage()
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx
const ctx = props.serverCtx()
return ctx ? { id: props.tab.sessionId, ctx } : null
},
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
)
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)
let prefetched = false
const rename = async (title: string) => {
const value = session()
const ctx = props.serverCtx
const ctx = props.serverCtx()
if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title })
@@ -108,7 +108,7 @@ function SessionTabEntry(props: {
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx
const currentCtx = props.serverCtx()
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
showToast({
title: language.t("common.requestFailed"),
@@ -120,7 +120,7 @@ function SessionTabEntry(props: {
createEffect(() => props.onVisibleChange(visible()))
createEffect(() => {
const ctx = props.serverCtx
const ctx = props.serverCtx()
const value = session()
if (!ctx || !value || prefetched) return
prefetched = true
@@ -157,7 +157,7 @@ function SessionTabEntry(props: {
index={props.index}
active={props.active}
forceTruncate={props.forceTruncate}
session={session()}
session={session}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onRename={rename}
onNavigate={props.onNavigate}
@@ -170,8 +170,8 @@ function SessionTabEntry(props: {
function DraftTabSlot(props: {
tab: Extract<Tab, { type: "draft" }>
id: string
index: number
active: boolean
index: () => number
active: () => boolean
title: string
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
@@ -181,7 +181,7 @@ function DraftTabSlot(props: {
return props.id
},
get index() {
return props.index
return props.index()
},
})
let ref!: HTMLDivElement
@@ -191,7 +191,7 @@ function DraftTabSlot(props: {
ref={sortable.ref}
data-titlebar-tab-slot
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"
>
<DraftTabItem
@@ -202,7 +202,7 @@ function DraftTabSlot(props: {
title={props.title}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active}
active={props.active()}
dragging={sortable.isDragSource()}
/>
</div>
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
export function TitlebarTabStrip(props: {
tabs: Tab[]
currentTab: Tab | undefined
currentTab: () => Tab | undefined
forceTruncate: boolean
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
onClose: (tab: Tab) => void
@@ -248,7 +248,7 @@ export function TitlebarTabStrip(props: {
])
function selectAdjacentTab(offset: -1 | 1) {
const current = props.currentTab
const current = props.currentTab()
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
const next = props.tabs.find((tab) => tabKey(tab) === key)
if (next) props.onNavigate(next)
@@ -350,10 +350,10 @@ export function TitlebarTabStrip(props: {
<SessionTabEntry
tab={tab}
id={id}
index={visibleIndex()}
active={props.currentTab === tab}
index={visibleIndex}
active={() => props.currentTab() === tab}
forceTruncate={props.forceTruncate}
serverCtx={serverCtx()}
serverCtx={serverCtx}
onVisibleChange={(visible) => setVisibility(id, visible)}
onNavigate={(element) => {
ref = element
@@ -368,8 +368,8 @@ export function TitlebarTabStrip(props: {
<DraftTabSlot
tab={tab}
id={id}
index={visibleIndex()}
active={props.currentTab === tab}
index={visibleIndex}
active={() => props.currentTab() === tab}
title={language.t("command.session.new")}
onNavigate={(element) => {
ref = element
+12 -10
View File
@@ -46,8 +46,8 @@ const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px
const macTrafficLightsBaseWidth = 84
export type TitlebarUpdate = {
version: string | undefined
installing: boolean
version: () => string | undefined
installing: () => boolean
install: () => void
}
@@ -121,8 +121,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const installing = props.update?.installing ?? false
const version = props.update?.version
const installing = props.update?.installing() ?? false
const version = props.update?.version()
return {
visible: version !== undefined || installing,
installing,
@@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab()}
currentTab={currentTab}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
@@ -657,10 +657,12 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
}
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">
{channel.toUpperCase()}
</div>
</Show>
<>
{["local", "beta", "dev"].includes(channel) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{channel.toUpperCase()}
</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 { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -50,20 +50,7 @@ export function WindowsAppMenu(props: {
return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
<Show
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}
/>
}
>
{props.variant === "v2" ? (
<div
data-component="desktop-icon-button"
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}
/>
</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.Content class="desktop-app-menu">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
<For each={DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows"))}>
{(menu) => (
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
<For each={menu.items?.filter((entry) => desktopMenuVisible(entry, "windows"))}>
{(entry) => {
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
if (entry.type === "separator") return <DropdownMenu.Separator />
return (
<DesktopMenuItem
label={entry.labelKey ? language.t(entry.labelKey) : ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)}
/>
)
}}
</For>
</DesktopMenuSubmenu>
)}
</For>
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
{menu.items
?.filter((entry) => desktopMenuVisible(entry, "windows"))
.map((entry) =>
entry.type === "separator" ? (
<DropdownMenu.Separator />
) : (
<DesktopMenuItem
label={entry.labelKey ? language.t(entry.labelKey) : ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)}
/>
),
)}
</DesktopMenuSubmenu>
))}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Portal>
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: "src/new.ts",
event: "add",
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: "src/open.ts",
event: "change",
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: "src",
event: "change",
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: "src/file.ts",
event: "change",
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: ".git/index.lock",
event: "change",
+1 -1
View File
@@ -16,7 +16,7 @@ type WatcherOps = {
}
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "filesystem.changed") return
if (event.type !== "file.watcher.updated") return
const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined
+114 -130
View File
@@ -6,8 +6,6 @@ import type {
CommandInfo,
CommandListInput,
CommandListOutput,
IntegrationListInput,
IntegrationListOutput,
LocationGetInput,
LocationGetOutput,
PermissionRequest,
@@ -26,9 +24,9 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js"
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 { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import { cmp, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
@@ -223,10 +221,6 @@ type CommandListApi = {
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
}
type IntegrationListApi = {
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
}
type ReferenceListApi = {
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
}
@@ -237,13 +231,6 @@ export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: Agen
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[]> =>
retry(() => api.list({ location: { directory } }).then((result) => result.data))
@@ -285,6 +272,7 @@ export async function bootstrapDirectory(input: {
}
store: Store<State>
setStore: SetStoreFunction<State>
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string
global: {
@@ -309,109 +297,104 @@ export async function bootstrapDirectory(input: {
const revKey = ScopedKey.from(input.scope, input.directory)
const rev = (providerRev.get(revKey) ?? 0) + 1
providerRev.set(revKey, rev)
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 &&
(() =>
;(async () => {
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
input.mcp &&
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
() =>
input.queryClient.fetchQuery(
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference),
),
() =>
retry(() =>
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, [])
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" },
)
if (input.session) input.session.set("permission", sessionID, value)
if (!input.session) input.setStore("permission", sessionID, value)
}
}),
)
}),
),
() =>
retry(() =>
input.api.question.request
.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[],
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, [])
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" },
)
if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value)
}
}),
)
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
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))
.catch((err) => {
.ensureQueryData(loadAgentsQuery(input.scope, 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
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
input.mcp &&
(() =>
loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.api.reference)),
() =>
retry(() =>
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, [])
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" },
)
if (input.session) input.session.set("permission", sessionID, value)
if (!input.session) input.setStore("permission", sessionID, value)
}
}),
)
}),
),
() =>
retry(() =>
input.api.question.request
.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[],
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, [])
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" },
)
if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value)
}
}),
)
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
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)
showToast({
variant: "error",
@@ -419,19 +402,20 @@ export async function bootstrapDirectory(input: {
description: formatServerError(err, input.translate),
})
}),
].filter(Boolean) as (() => Promise<any>)[]
].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint()
const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate),
})
}
await waitForPaint()
const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
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
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
let providerQuerySuccess = true
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
store[0],
store[1],
@@ -59,17 +58,11 @@ beforeAll(async () => {
get isLoading() {
return options().queryKey?.[1] === "path"
},
get isSuccess() {
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess
},
get isRefetchError() {
return false
},
get data() {
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] === "lsp") return []
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
if (options().queryKey?.[1] === "providers") return provider
return undefined
},
}
@@ -90,7 +83,6 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -122,7 +114,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -157,7 +148,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
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", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = querySingles.length
@@ -222,7 +181,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -272,7 +230,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -316,71 +273,4 @@ describe("createChildStoreManager", () => {
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 { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@/types"
@@ -22,7 +22,6 @@ import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: {
owner: Owner
connected: Accessor<boolean>
scope: ServerScope
persist: typeof persisted
isBooting: (directory: string) => boolean
@@ -189,29 +188,17 @@ export function createChildStoreManager(input: {
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const pathQuery = useQuery(() => ({
...input.queryOptions.path(key),
enabled: input.connected() && 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 pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: input.connected() && instanceQueriesEnabled(),
enabled: instanceQueriesEnabled(),
}))
const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key),
enabled: input.connected() && instanceQueriesEnabled(),
enabled: instanceQueriesEnabled(),
}))
const child = createStore<State>({
@@ -219,14 +206,13 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError)
return instanceQueriesEnabled() && !providerQuery.isLoading
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY
const provider = providerQuery.data
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
return provider
if (providerQuery.isLoading) return EMPTY
if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
return providerQuery.data ?? EMPTY
},
config: {},
get path() {
@@ -387,15 +373,6 @@ export function createChildStoreManager(input: {
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 {
children,
ensureChild,
@@ -403,7 +380,6 @@ export function createChildStoreManager(input: {
peek,
projectMeta,
projectIcon,
vcs,
mark,
pin,
unpin,
@@ -120,7 +120,7 @@ describe("applyGlobalEvent", () => {
expect(refreshCount).toBe(1)
})
test("leaves server.connected refresh to the connection sync", () => {
test("handles server.connected by triggering refresh", () => {
let refreshCount = 0
applyGlobalEvent({
event: { type: "server.connected" },
@@ -131,7 +131,7 @@ describe("applyGlobalEvent", () => {
setGlobalProject() {},
})
expect(refreshCount).toBe(0)
expect(refreshCount).toBe(1)
})
})
@@ -1,5 +1,4 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { Message, Part, Project, Todo } from "@/types"
import type {
@@ -38,10 +37,11 @@ export function applyGlobalEvent(input: {
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed") {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
input.refresh()
return
}
if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project
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))
break
}
case "project.directory.resolved": {
const properties = event.properties as { projectID: string; directory: string; previous: string }
input.store.session.forEach((session, index) => {
const adopted = ProjectDirectories.adopt(
{ projectID: session.projectID, directory: session.location.directory },
properties,
)
if (!adopted) return
input.setStore("session", index, (current) => ({ ...current, ...adopted }))
})
break
}
case "session.renamed": {
const properties = event.properties as { sessionID: string; title: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
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 { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -34,7 +34,6 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
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")] },
permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] },
form: { ses_1: [] as FormInfo[] },
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.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.form.ses_1).toBeUndefined()
})
test("dropSessionCaches clears message-backed parts", () => {
@@ -73,7 +70,6 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
session_status: {},
@@ -84,7 +80,6 @@ describe("app session cache", () => {
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
question: {},
form: {},
part_text_accum_delta: {},
}
@@ -1,5 +1,5 @@
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 { SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -14,7 +14,6 @@ type SessionCache = {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form?: Record<string, FormInfo[] | 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.permission[sessionID]
delete store.question[sessionID]
if (store.form) delete store.form[sessionID]
}
}
+11
View File
@@ -7,6 +7,7 @@ import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
@@ -97,6 +98,15 @@ function createServerCtx(
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnReconnect: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
})
const sdk = createServerSdkContext(conn, scope)
const sync = createServerSyncContext(sdk)
@@ -131,6 +141,7 @@ function createServerCtx(
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
return {
queryClient,
sdk,
sync,
isLocal,
-2
View File
@@ -33,8 +33,6 @@ type PluralKey =
| "session.question.pending"
| "session.followupDock.summary"
| "session.revertDock.summary"
| "session.background.shell"
| "session.background.subagent"
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
+3 -3
View File
@@ -1,4 +1,4 @@
import { createMemo, createResource } from "solid-js"
import { type Accessor, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
@@ -25,8 +25,8 @@ function modelKey(model: ModelKey) {
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models",
gate: false,
init: (props: { directory?: string } = {}) => {
const providers = useProviders(() => props.directory)
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
const providers = useProviders(() => props.directory?.())
const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]),
+4 -9
View File
@@ -398,20 +398,15 @@ function createServerNotificationState(input: {
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.interrupted" &&
event.type !== "session.execution.failed"
)
return
if (event.type !== "session.idle" && event.type !== "session.execution.failed") return
const directory = e.name
const time = Date.now()
if (event.type === "session.execution.failed") {
handleSessionError(directory, event, time)
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionIdle(directory, event, time)
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
+5 -2
View File
@@ -6,9 +6,12 @@ export type { DirectorySDK } from "./server-sdk"
const context = createSimpleContext({
name: "SDK",
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
init: (props: { directory: string }) => {
init: (props: { directory: string | Accessor<string> }) => {
const serverSDK = useServerSDK()
return createMemo(() => serverSDK().ensureDirSdkContext(props.directory))
return createMemo(() => {
const directory = typeof props.directory === "function" ? props.directory() : props.directory
return serverSDK().ensureDirSdkContext(directory)
})
},
})
+53 -134
View File
@@ -4,7 +4,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createApiForServer, type ServerApi } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
@@ -13,6 +12,10 @@ import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract<
@@ -92,20 +95,15 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
}
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
type ServerSDKBase = {
server: ServerConnection.Any
scope: ServerScope
url: string
api: ServerApi
connection: {
status: Accessor<ServerConnectionStatus>
attempt: Accessor<number>
error: Accessor<string | undefined>
}
event: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
start: () => Promise<void> | undefined
}
}
@@ -132,15 +130,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const CONNECT_TIMEOUT_MS = 2_000
const RECONNECT_DELAY_MS = 1_000
const RECONNECT_DELAY_MS = 250
let queue: Queued[] = []
let buffer: Queued[] = []
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
function flush() {
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
@@ -160,132 +157,63 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
buffer.length = 0
}
function schedule() {
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
function publish(event: OpenCodeEvent) {
const directory = event.location?.directory ?? "global"
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
}
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
const [connection, setConnection] = createStore<{
status: ServerConnectionStatus
attempt: number
error?: string
}>({ status: "connecting", attempt: 0 })
async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> {
let connectedAt: number | undefined
// Bound the initial handshake and tie this request to the stream lifetime.
const request = new AbortController()
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
signal.addEventListener("abort", cancel, { once: true })
try {
// Open the event stream and validate its initial handshake.
const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done) {
const error =
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
return { error, connectedAt }
}
if (first.value.type !== "server.connected")
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
// Publish the connected state before forwarding live events.
clearTimeout(timeout)
publish(first.value)
connectedAt = Date.now()
setConnection({ status: "connected", attempt: 0, error: undefined })
// Forward events until the stream closes or this connection is cancelled.
let yielded = Date.now()
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
publish(event.value)
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0, signal)
}
return { error: undefined, connectedAt }
} catch (error) {
return { error, connectedAt }
} finally {
request.abort()
clearTimeout(timeout)
signal.removeEventListener("abort", cancel)
}
}
async function runStream(active: number) {
let retries = 0
// oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
while (!abort.signal.aborted && started && generation === active) {
setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
const controller = new AbortController()
attempt = controller
const onAbort = () => controller.abort()
abort.signal.addEventListener("abort", onAbort)
const result = await connect(controller.signal)
abort.signal.removeEventListener("abort", onAbort)
if (abort.signal.aborted || !started || generation !== active) {
if (attempt === controller) attempt = undefined
return
}
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
retries += 1
const message =
result.error === undefined
? undefined
: result.error instanceof Error
? result.error.message
: String(result.error)
console.info("[global-sdk] event stream disconnected", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
attempt: retries,
error: message,
})
setConnection({ status: "reconnecting", attempt: retries, error: message })
await wait(RECONNECT_DELAY_MS, controller.signal)
if (attempt === controller) attempt = undefined
}
}
function start() {
const start = () => {
if (started) return run
started = true
const active = ++generation
const previous = run
const current = (async () => {
if (previous) await previous
await runStream(active)
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = eventApi.event.subscribe({ signal: attempt.signal })
let yielded = Date.now()
for await (const event of events) {
streamErrorLogged = false
const directory = event.location?.directory ?? "global"
const payload = adaptServerEvent(event)
if (enqueueServerEvent(queue, { directory, payload })) schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
}
if (abort.signal.aborted || !started || generation !== active) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
if (run !== current) return
run = undefined
@@ -295,7 +223,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
return run
}
function stop() {
const stop = () => {
started = false
generation++
attempt?.abort()
@@ -304,17 +232,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
onMount(() => {
makeEventListener(window, "pagehide", stop)
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
void start()
})
onCleanup(() => {
stop()
abort.abort()
if (timer) clearTimeout(timer)
timer = undefined
queue = []
buffer = []
emitter.clear()
flush()
})
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
@@ -324,14 +247,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
scope,
url: server.http.url,
api,
connection: {
status: () => connection.status,
attempt: () => connection.attempt,
error: () => connection.error,
},
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
}
}
@@ -351,13 +270,13 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
name: "ServerSDK",
// Returns an accessor so the resolved server can change reactively (e.g. a
// /new-session draft retargeting its server) without re-instantiating the subtree.
init: (props: { server?: ServerConnection.Any }) => {
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
return createMemo<ServerSDK>(() => {
const conn = props.server ?? server.current
const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.ensureServerCtx(conn).sdk
})
@@ -18,18 +18,18 @@ describe("v2 session reducer", () => {
apply({
...base,
id: "evt_admitted",
type: "session.inbox.enqueued",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inboxID: "msg_user",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
inputID: "msg_user",
input: { type: "user", delivery: "steer", data: { text: "hello" } },
},
})
apply({
...base,
id: "evt_promoted",
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
})
apply({
...base,
@@ -203,66 +203,14 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_promoted",
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
test("projects rendered instruction updates", () => {
const reducer = createV2SessionReducer()
const result = reducer.reduce(
[],
event({
...base,
id: "evt_instructions",
type: "session.instructions.updated",
data: { sessionID: "ses_1", delta: { agents: "hash" }, text: "Changed instructions" },
}),
)
expect(result?.messages).toEqual([
{
id: "msg_instructions",
type: "system",
text: "Changed instructions",
description: "Instructions updated: agents",
metadata: undefined,
time: { created: 1 },
},
])
})
test("projects session movement with the previous location", () => {
const result = createV2SessionReducer().reduce(
[],
event({
...base,
id: "evt_moved",
type: "session.moved",
data: {
sessionID: "ses_1",
projectID: "project_2",
location: { directory: "/repo-2" },
subpath: "packages/app",
},
}),
{ projectID: "project_1", location: { directory: "/repo-1" } },
)
expect(result?.messages).toMatchObject([
{
id: "msg_moved",
type: "location-switched",
projectID: "project_2",
location: { directory: "/repo-2" },
previous: { projectID: "project_1", location: { directory: "/repo-1" } },
},
])
})
test("removes cancelled input from the pending promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
@@ -270,11 +218,11 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_admitted",
type: "session.inbox.enqueued",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inboxID: "msg_user",
item: { type: "user", delivery: "queue", payload: { text: "cancel me" } },
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
},
}),
)
@@ -283,8 +231,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_cancelled",
type: "session.inbox.cancelled",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.cancelled",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
@@ -292,8 +240,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_promoted",
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
@@ -307,11 +255,11 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_admitted",
type: "session.inbox.enqueued",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inboxID: "msg_user",
item: { type: "user", delivery: "queue", payload: { text: "steer me" } },
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
},
}),
)
@@ -320,8 +268,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_steered",
type: "session.inbox.delivery.changed",
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "steer" },
type: "session.input.steered",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
reducer.reduce(
@@ -329,8 +277,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_queued",
type: "session.inbox.delivery.changed",
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "queue" },
type: "session.input.queued",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
@@ -339,8 +287,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_promoted",
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
@@ -1,4 +1,4 @@
import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -8,18 +8,13 @@ export type V2SessionReduction = {
sessionID: string
messages: SessionMessageInfo[]
touched: string[]
removed?: string[]
missing?: string
}
export function createV2SessionReducer() {
const pending = new Map<string, SessionInboxItem>()
const pending = new Map<string, SessionPendingMessage>()
const reduce = (
source: readonly SessionMessageInfo[],
event: OpenCodeEvent,
session?: Pick<SessionInfo, "location" | "projectID" | "subpath">,
): V2SessionReduction | undefined => {
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID
const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({
@@ -31,59 +26,32 @@ export function createV2SessionReducer() {
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
switch (event.type) {
case "session.inbox.enqueued":
pending.set(key(sessionID, event.data.inboxID), event.data.item)
if (event.data.item.type === "user")
return append({
id: event.data.inboxID,
type: "user",
metadata: event.data.item.payload.metadata,
text: event.data.item.payload.text,
files: event.data.item.payload.files,
agents: event.data.item.payload.agents,
time: { created: event.created },
})
if (event.data.item.type !== "synthetic") return result([...source])
return append({
id: event.data.inboxID,
type: "synthetic",
metadata: event.data.item.payload.metadata,
text: event.data.item.payload.text,
description: event.data.item.payload.description,
time: { created: event.created },
})
case "session.inbox.cancelled":
pending.delete(key(sessionID, event.data.inboxID))
return {
...result(source.filter((item) => item.id !== event.data.inboxID)),
removed: source.some((item) => item.id === event.data.inboxID) ? [event.data.inboxID] : [],
}
case "session.inbox.delivered": {
const input = pending.get(key(sessionID, event.data.inboxID))
pending.delete(key(sessionID, event.data.inboxID))
const existing = source.find((item) => item.id === event.data.inboxID)
if (existing) {
const promoted = { ...existing, time: { ...existing.time, created: event.created } }
return result([...source.filter((item) => item.id !== existing.id), promoted], [existing.id])
}
if (!input) return { ...result([...source]), missing: event.data.inboxID }
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source])
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID))
if (!input) return { ...result([...source]), missing: event.data.inputID }
if (input.type === "user")
return append({
id: event.data.inboxID,
id: event.data.inputID,
type: "user",
metadata: input.payload.metadata,
text: input.payload.text,
files: input.payload.files,
agents: input.payload.agents,
metadata: input.data.metadata,
text: input.data.text,
files: input.data.files,
agents: input.data.agents,
time: { created: event.created },
})
if (input.type !== "synthetic") return result([...source])
return append({
id: event.data.inboxID,
id: event.data.inputID,
type: "synthetic",
metadata: input.payload.metadata,
text: input.payload.text,
description: input.payload.description,
metadata: input.data.metadata,
text: input.data.text,
description: input.data.description,
time: { created: event.created },
})
}
@@ -115,32 +83,6 @@ export function createV2SessionReducer() {
)?.model,
time: { created: event.created },
})
case "session.moved":
if (!session) return
return append({
id: messageID(event.id),
type: "location-switched",
metadata: event.metadata,
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous: {
location: session.location,
projectID: session.projectID,
subpath: session.subpath,
},
time: { created: event.created },
})
case "session.instructions.updated":
if (event.data.text === undefined) return
return append({
id: messageID(event.id),
type: "system",
text: event.data.text,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
})
case "session.synthetic":
return append({
id: messageID(event.id),
@@ -409,7 +351,7 @@ export function createV2SessionReducer() {
metadata: event.metadata,
reason: event.data.reason,
summary: "",
recent: event.data.recent ?? "",
recent: event.data.recent,
time: { created: event.created },
})
case "session.compaction.delta":
@@ -460,11 +402,8 @@ export function createV2SessionReducer() {
type: "compaction",
status: "failed",
metadata: current?.metadata ?? event.metadata,
reason: event.data.reason ?? "manual",
error: event.data.error ?? {
type: "compaction.failed",
message: "Compaction failed before recording an error",
},
reason: event.data.reason,
error: event.data.error,
time: current?.time ?? { created: event.created },
}
if (!current) return append(failed)
@@ -1,10 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry"
import type {
FormInfo,
OpenCodeEvent,
SessionApi,
SessionInboxInfo,
SessionInfo,
SessionMessageAssistant,
SessionMessageAssistantTool,
@@ -350,187 +348,6 @@ describe("server session", () => {
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
})
test("projects V2 pending inputs and forms", () => {
const ctx = setup({ child: session("child") })
const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
apply({
id: "evt_admitted",
created: 1,
type: "session.inbox.enqueued",
data: {
sessionID: "child",
inboxID: "msg_input",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
})
apply({
id: "evt_form",
created: 2,
type: "form.created",
data: { form: { id: "frm_1", sessionID: "child", title: "Choose", fields: [] } },
})
expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input", delivery: "steer" }])
expect(ctx.store.data.input.child).toEqual(["msg_input"])
expect(ctx.store.data.form.child).toMatchObject([{ id: "frm_1", title: "Choose" }])
expect(ctx.store.data.message.child).toMatchObject([{ id: "msg_input" }])
apply({
id: "evt_cancelled",
created: 3,
type: "session.inbox.cancelled",
data: { sessionID: "child", inboxID: "msg_input" },
})
apply({
id: "evt_form_done",
created: 4,
type: "form.cancelled",
data: { sessionID: "child", id: "frm_1" },
})
expect(ctx.store.data.pending.child).toEqual([])
expect(ctx.store.data.input.child).toEqual([])
expect(ctx.store.data.form.child).toEqual([])
expect(ctx.store.data.message.child).toEqual([])
})
test("does not let transient hydration overwrite newer events", async () => {
const ctx = setup({ child: session("child") })
const response = Promise.withResolvers<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>()
const form: FormInfo = {
id: "frm_1",
sessionID: "child",
title: "Choose",
fields: [{ key: "choice", type: "string" as const, title: "Choice" }],
}
let loads = 0
const hydration = ctx.store.hydrateTransient("child", () => {
loads += 1
if (loads === 1) return response.promise
return Promise.resolve({ pending: ctx.store.data.pending.child ?? [], forms: [form] })
})
ctx.store.applyV2({
id: "evt_admitted",
created: 1,
type: "session.inbox.enqueued",
data: {
sessionID: "child",
inboxID: "msg_input",
item: { type: "user", delivery: "queue", payload: { text: "new" } },
},
} as OpenCodeEvent)
response.resolve({
pending: [],
forms: [form],
})
await hydration
expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input" }])
expect(ctx.store.data.form.child).toMatchObject([{ id: "frm_1" }])
expect(loads).toBe(2)
})
test("removes only the compaction input named by the start event", () => {
const ctx = setup({ child: session("child") })
const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
apply({
id: "evt_compaction_inbox",
created: 1,
type: "session.inbox.enqueued",
data: {
sessionID: "child",
inboxID: "msg_compaction",
item: { type: "compaction", delivery: "queue", payload: { reason: "manual" } },
},
})
expect(ctx.store.data.input.child).toBeUndefined()
expect(ctx.store.data.pending.child).toHaveLength(1)
apply({
id: "evt_compaction_inbox_2",
created: 2,
type: "session.inbox.enqueued",
data: {
sessionID: "child",
inboxID: "msg_compaction_2",
item: { type: "compaction", delivery: "queue", payload: { reason: "manual" } },
},
})
apply({
id: "evt_compaction_ended",
created: 3,
type: "session.compaction.ended",
data: { sessionID: "child", reason: "manual", text: "summary", recent: "recent" },
})
expect(ctx.store.data.pending.child).toHaveLength(2)
apply({
id: "evt_compaction_started",
created: 4,
type: "session.compaction.started",
data: { sessionID: "child", inputID: "msg_compaction", reason: "manual" },
})
expect(ctx.store.data.pending.child?.map((item) => item.id)).toEqual(["msg_compaction_2"])
})
test("projects committed revert before server reconciliation", () => {
const ctx = setup({ child: session("child") })
ctx.store.remember({ ...session("child"), revert: { messageID: "msg_2", partID: "prt_1" } })
ctx.store.set("input", "child", ["msg_1", "msg_2"])
ctx.store.set("session_message", "child", [
{ id: "msg_1", type: "user", text: "keep", time: { created: 1 } },
{ id: "msg_2", type: "user", text: "remove", time: { created: 2 } },
])
ctx.store.applyV2({
id: "evt_revert",
created: 3,
type: "session.revert.committed",
data: { sessionID: "child", to: "msg_2" },
} as OpenCodeEvent)
expect(ctx.store.data.info.child?.revert).toBeUndefined()
expect(ctx.store.data.input.child).toEqual(["msg_1"])
expect(ctx.store.data.session_message.child?.map((message) => message.id)).toEqual(["msg_1"])
})
test("does not restore a message hydrated before a committed revert", async () => {
const response = Promise.withResolvers<SessionMessageInfo>()
const store = createServerSession({
session: {
get: async () => session("child"),
message: () => response.promise,
} as unknown as SessionApi,
message: {
list: async () => currentPage({ data: [], response: { headers: new Headers() } }),
} as unknown as MessageApi,
})
store.remember(session("child"))
store.applyV2({
id: "evt_delivered",
created: 1,
type: "session.inbox.delivered",
data: { sessionID: "child", inboxID: "msg_2" },
} as OpenCodeEvent)
store.applyV2({
id: "evt_revert",
created: 2,
type: "session.revert.committed",
data: { sessionID: "child", to: "msg_2" },
} as OpenCodeEvent)
response.resolve({ id: "msg_2", type: "user", text: "stale", time: { created: 1 } })
await response.promise
await Bun.sleep(0)
expect(store.data.session_message.child ?? []).toEqual([])
})
test("resolves lineage by session ID without directory", async () => {
const ctx = setup({ child: session("child", "root"), root: session("root") })
@@ -551,17 +368,6 @@ describe("server session", () => {
expect(ctx.store.data.message.root).toEqual([])
})
test("reloads cached sessions after reconnect invalidation", async () => {
const ctx = setup({ root: session("root") })
await ctx.store.sync("root")
ctx.store.invalidate()
await ctx.store.sync("root")
expect(ctx.get).toHaveLength(2)
expect(ctx.messages).toHaveLength(2)
})
test("loads current session content through the current message API", async () => {
const requests: unknown[] = []
const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } }
+8 -154
View File
@@ -1,14 +1,6 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { retry } from "@opencode-ai/core/util/retry"
import type {
FormInfo,
OpenCodeEvent,
SessionApi,
SessionInfo,
SessionInboxInfo,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Message, Part, Todo } from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import { batch } from "solid-js"
@@ -199,9 +191,6 @@ export function createServerSession(
todo: {} as Record<string, Todo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
form: {} as Record<string, FormInfo[]>,
pending: {} as Record<string, SessionInboxInfo[]>,
input: {} as Record<string, string[]>,
message: {} as Record<string, Message[]>,
session_message: {} as Record<string, SessionMessageInfo[]>,
part: {} as Record<string, Part[]>,
@@ -215,11 +204,6 @@ export function createServerSession(
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const v2 = createV2SessionReducer()
const pendingRevision = new Map<string, number>()
const formRevision = new Map<string, number>()
const messageHydrationRevision = new Map<string, number>()
const invalidated = new Set<string>()
let invalidationRevision = 0
const messageLoads = new Map<string, MessageLoadState>()
const pendingParts = new Map<string, Map<string, Set<string>>>()
const orphanParts = new Map<string, Set<string>>()
@@ -278,9 +262,6 @@ export function createServerSession(
...Object.entries(data.question)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.form)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.session_status)
.filter(([, status]) => status.type !== "idle")
.map(([sessionID]) => sessionID),
@@ -482,7 +463,6 @@ export function createServerSession(
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
}
sessionIDs.forEach((sessionID) => {
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
generations.delete(sessionID)
clearOptimistic(sessionID)
requests.delete(sessionID)
@@ -526,9 +506,6 @@ export function createServerSession(
...Object.entries(data.question)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.form)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.session_status)
.filter(([, status]) => status.type !== "idle")
.map(([sessionID]) => sessionID),
@@ -820,16 +797,13 @@ export function createServerSession(
touch(sessionID)
return runInflight(inflight, sessionID, async () => {
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
const invalid = invalidated.has(sessionID)
const revision = invalidationRevision
if (cached && data.info[sessionID] && !invalid && !options?.force) return
if (cached && data.info[sessionID] && !options?.force) return
await Promise.all([
resolve(sessionID, invalid ? { ...options, force: true } : options),
cached && !invalid && !options?.force
resolve(sessionID, options),
cached && !options?.force
? Promise.resolve()
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
])
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
})
}
@@ -869,7 +843,7 @@ export function createServerSession(
const projectV2 = (reduction: V2SessionReduction) => {
reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID))
setData("session_message", reduction.sessionID, reconcile(reduction.messages))
if (reduction.touched.length === 0 && !reduction.removed?.length) return
if (reduction.touched.length === 0) return
const touched = new Set(reduction.touched)
let parentID: string | undefined
@@ -886,9 +860,6 @@ export function createServerSession(
const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages)
batch(() => {
for (const messageID of reduction.removed ?? []) {
apply({ type: "message.removed", properties: { sessionID: reduction.sessionID, messageID } })
}
for (const message of normalized.messages) {
if (!touched.has(message.id)) continue
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
@@ -912,14 +883,9 @@ export function createServerSession(
const hydrateV2Message = (sessionID: string, messageID: string) => {
if (!sessionApi) return
const active = generation(sessionID)
const revision = messageHydrationRevision.get(sessionID) ?? 0
void sessionApi
.message({ sessionID, messageID })
.then((message) => {
if (generations.get(sessionID) !== active) return
if ((messageHydrationRevision.get(sessionID) ?? 0) !== revision) return
if (removedMessages.get(sessionID)?.has(message.id)) return
const current = data.session_message[sessionID] ?? []
const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages)
projectV2({ sessionID, messages, touched: [message.id] })
@@ -928,86 +894,21 @@ export function createServerSession(
}
const applyV2 = (event: OpenCodeEvent) => {
if (event.type === "form.created") {
formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1)
const current = data.form[event.data.form.sessionID] ?? []
if (!current.some((form) => form.id === event.data.form.id))
setData("form", event.data.form.sessionID, [...current, event.data.form])
return
}
if (event.type === "form.replied" || event.type === "form.cancelled") {
formRevision.set(event.data.sessionID, (formRevision.get(event.data.sessionID) ?? 0) + 1)
setData("form", event.data.sessionID, (forms) => forms?.filter((form) => form.id !== event.data.id))
return
}
if (event.type === "project.directory.resolved") {
Object.values(data.info).forEach((info) => {
if (!info) return
const adopted = ProjectDirectories.adopt(
{ projectID: info.projectID, directory: info.location.directory },
event.data,
)
if (adopted) remember({ ...info, ...adopted })
})
return
}
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID
if (
event.type === "session.inbox.enqueued" ||
event.type === "session.inbox.delivery.changed" ||
event.type === "session.inbox.cancelled" ||
event.type === "session.inbox.delivered" ||
event.type === "session.compaction.started" ||
event.type === "session.compaction.failed"
)
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
if (event.type === "session.inbox.enqueued") {
const current = data.pending[sessionID] ?? []
if (!current.some((item) => item.id === event.data.inboxID))
setData("pending", sessionID, [
...current,
{ id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item },
])
if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
}
if (event.type === "session.inbox.delivery.changed")
setData("pending", sessionID, (items) =>
items?.map((item) => (item.id === event.data.inboxID ? { ...item, delivery: event.data.delivery } : item)),
)
if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") {
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== event.data.inboxID))
setData("input", sessionID, (items) => items?.filter((id) => id !== event.data.inboxID))
}
if (event.type === "session.compaction.started" || event.type === "session.compaction.failed") {
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== event.data.inputID))
setData("input", sessionID, (items) => items?.filter((id) => id !== event.data.inputID))
}
const info = data.info[sessionID]
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event, info)
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
if (reduction) {
projectV2(reduction)
if (reduction.missing) hydrateV2Message(sessionID, reduction.missing)
}
if (event.type === "session.agent.selected" && info) remember({ ...info, agent: event.data.agent })
if (event.type === "session.model.selected") {
if (info) remember({ ...info, model: event.data.model })
if (data.session_message[sessionID]) hydrateV2Message(sessionID, event.id.replace(/^evt_/, "msg_"))
}
const info = data.info[sessionID]
if (event.type === "session.renamed" && info)
remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } })
if (event.type === "session.renamed" && !info)
void resolve(sessionID)
.then((current) =>
remember({ ...current, title: event.data.title, time: { ...current.time, updated: event.created } }),
)
.catch(() => undefined)
if (event.type === "session.moved" && info)
remember({
...info,
projectID: event.data.projectID,
projectID: event.data.projectID ?? info.projectID,
location: event.data.location,
subpath: event.data.subpath,
time: { ...info.time, updated: event.created },
@@ -1033,29 +934,12 @@ export function createServerSession(
next: event.data.at,
})
if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {})
if (event.type === "session.revert.staged" && info) remember({ ...info, revert: event.data.revert })
if (event.type === "session.revert.cleared" && info) remember({ ...info, revert: undefined })
if (event.type === "session.revert.committed") {
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
if (info) remember({ ...info, revert: undefined })
setData("input", sessionID, (items) => items?.filter((id) => id < event.data.to))
const source = data.session_message[sessionID] ?? []
const removed = source.filter((message) => message.id >= event.data.to).map((message) => message.id)
removedMessages.set(sessionID, new Set([...(removedMessages.get(sessionID) ?? []), ...removed]))
projectV2({
sessionID,
messages: source.filter((message) => message.id < event.data.to),
touched: [],
removed,
})
}
if (
event.type === "session.revert.staged" ||
event.type === "session.revert.cleared" ||
event.type === "session.revert.committed"
)
void resolve(sessionID, { force: true }).catch(() => {})
if (event.type === "session.revert.committed") void sync(sessionID, { force: true }).catch(() => {})
}
const apply = (event: { type: string; properties?: unknown }) => {
@@ -1384,36 +1268,6 @@ export function createServerSession(
},
},
sync,
async hydrateTransient(sessionID: string, load: () => Promise<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>) {
while (true) {
const pendingAt = pendingRevision.get(sessionID) ?? 0
const formAt = formRevision.get(sessionID) ?? 0
const result = await load()
const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt
const formStable = (formRevision.get(sessionID) ?? 0) === formAt
if (pendingStable) {
setData("pending", sessionID, reconcile(result.pending))
setData(
"input",
sessionID,
reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
}
if (formStable) setData("form", sessionID, reconcile(result.forms))
if (pendingStable && formStable) return
}
},
refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) {
return Promise.all(
[...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
).then(() => undefined)
},
invalidate() {
invalidationRevision += 1
Object.keys(data.info).forEach((sessionID) => invalidated.add(sessionID))
Object.keys(data.message).forEach((sessionID) => invalidated.add(sessionID))
setMeta("at", {})
},
prefetch,
shouldPrefetch(sessionID: string, limit: number) {
if (data.message[sessionID] === undefined) return true
+1 -18
View File
@@ -9,13 +9,7 @@ import type {
import { QueryClient } from "@tanstack/solid-query"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
import {
loadActiveSessionsQuery,
loadMcpQuery,
loadMcpResourcesQuery,
reconcileActiveSessionStatuses,
seedActiveSessionStatuses,
} from "./server-sync"
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server"
@@ -105,17 +99,6 @@ describe("active session query", () => {
next: 10,
})
})
test("replaces stale active statuses after reconnect", () => {
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
session.set("session_status", "ses_stale", { type: "busy" })
session.set("session_status", "ses_active", { type: "idle" })
reconcileActiveSessionStatuses(session, { ses_active: { type: "running" } })
expect(session.data.session_status.ses_stale).toEqual({ type: "idle" })
expect(session.data.session_status.ses_active).toEqual({ type: "busy" })
})
})
describe("pickDirectoriesToEvict", () => {
+112 -221
View File
@@ -1,11 +1,11 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { batch, createMemo, getOwner, onCleanup, untrack } from "solid-js"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { type ServerEvent, type ServerSDK } from "./server-sdk"
import { ServerSDK } from "./server-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
@@ -13,7 +13,6 @@ import {
loadAgentsQuery,
loadCommands,
loadGlobalConfigQuery,
loadIntegrationsQuery,
loadPathQuery,
loadProjectsQuery,
loadProvidersQuery,
@@ -30,7 +29,7 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from
import type { SolidQueryOptions } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { pathKey, PathKey } from "@/utils/path-key"
import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
@@ -54,8 +53,6 @@ import type {
} from "@opencode-ai/client/promise"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session"
import { createCatalogSync } from "./server-sync/catalog"
import { createConnectionSync } from "./server-sync/connection"
import { usePlatform } from "./platform"
type GlobalStore = {
@@ -69,16 +66,6 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
const SESSION_LIST_EVENTS = new Set([
"session.created",
"session.updated",
"session.deleted",
"session.moved",
"session.forked",
"session.renamed",
"session.usage.updated",
])
type McpListApi = {
readonly list: (input?: McpListInput) => Promise<McpListOutput>
}
@@ -172,22 +159,11 @@ export function seedActiveSessionStatuses(
}
}
export function reconcileActiveSessionStatuses(
session: Pick<ServerSession, "data" | "set">,
active: SessionActiveOutput,
) {
Object.keys(session.data.session_status)
.filter((sessionID) => active[sessionID] === undefined && session.data.session_status[sessionID]?.type !== "idle")
.forEach((sessionID) => session.set("session_status", sessionID, { type: "idle" }))
Object.keys(active).forEach((sessionID) => session.set("session_status", sessionID, { type: "busy" }))
}
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
return {
globalConfig: () => loadGlobalConfigQuery(scope),
projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
@@ -208,46 +184,29 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
const sessionRevision = new Map<string, number>()
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
const connected = () => serverSDK.connection.status() === "connected"
const hydrateSessionState = async (sessionID: string) => {
await session.hydrateTransient(sessionID, async () => {
const [pending, forms] = await Promise.all([
serverSDK.api.session.inbox.list({ sessionID }),
serverSDK.api.form.list({ sessionID }),
])
return { pending, forms }
})
}
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
{ ...queryOptionsApi.providers(null), enabled: connected() },
{ ...queryOptionsApi.path(null), enabled: connected() },
],
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
}))
const activeSessionsQuery = useQuery(() => ({
...loadActiveSessionsQuery(serverSDK.scope, {
const activeSessionsQuery = useQuery(() =>
loadActiveSessionsQuery(serverSDK.scope, {
active: async () => {
const active = await serverSDK.api.session.active()
reconcileActiveSessionStatuses(session, active)
Object.keys(active).forEach((sessionID) => {
void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined)
})
seedActiveSessionStatuses(session, active)
for (const sessionID of Object.keys(active)) {
void session.resolve(sessionID).catch(() => undefined)
}
return active
},
}),
enabled: connected(),
}))
)
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
get ready() {
return bootstrap.isSuccess
return !bootstrap.isPending
},
project: [],
provider_auth: {},
@@ -272,6 +231,21 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const queryClient = useQueryClient()
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
const refreshProviders = () =>
queryClient.refetchQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
})
let bootedAt = 0
let bootingRoot = false
let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
setGlobalStore("project", next)
}
@@ -296,9 +270,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
setGlobalStore: setBootStore,
queryClient,
})
return Date.now()
bootedAt = Date.now()
return bootedAt
},
enabled: connected(),
}))
const set = ((...input: unknown[]) => {
@@ -320,13 +294,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const children = createChildStoreManager({
owner,
connected,
scope: serverSDK.scope,
persist: persisted,
isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => {
if (!connected()) return
void bootstrapInstance(directory)
},
onMcp: (directory, setStore) => {
@@ -349,60 +321,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
translate: language.t,
queryOptions: queryOptionsApi,
global: {
get provider() {
return globalStore.provider
},
provider: globalStore.provider,
},
})
const catalog = createCatalogSync({
scope: serverSDK.scope,
queryClient,
active: () => Object.keys(children.children).filter(children.active).map(pathKey),
load: (directory) =>
Promise.all([
queryClient.fetchQuery(queryOptionsApi.providers(directory)),
queryClient.fetchQuery(queryOptionsApi.integrations(directory)),
]).then(() => undefined),
})
const refreshVcs = (directory: string) =>
serverSDK.api.vcs
.get({ location: { directory } })
.then((result) =>
children.vcs(directory, {
branch: result.data.branch.current,
default_branch: result.data.branch.default,
}),
)
.catch(() => undefined)
const connection = createConnectionSync({
status: serverSDK.connection.status,
invalidate: () => {
session.invalidate()
void queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope,
refetchType: "none",
})
},
connected: (info) => {
if (info.reconnect) void session.refreshPinned(hydrateSessionState).catch(() => undefined)
if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch()
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
Object.keys(children.children)
.filter(children.active)
.forEach((directory) => {
queue.push(directory)
if (children.children[directory]?.[0].status !== "loading") void refreshVcs(directory)
})
},
})
async function loadCurrentSessions(directory: string, key: PathKey, limit: number) {
while (true) {
const revision = sessionRevision.get(key) ?? 0
const result = await loadRootSessions({ api: serverSDK.api.session, directory, limit })
if ((sessionRevision.get(key) ?? 0) === revision) return result
}
}
async function loadSessions(directory: string, options?: { limit?: number }) {
const key = directoryKey(directory)
@@ -433,7 +354,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.fetchQuery({
...queryOptionsApi.sessions(key),
queryFn: () =>
loadCurrentSessions(directory, key, limit)
loadRootSessions({ api: serverSDK.api.session, directory, limit })
.then((x) => {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
@@ -489,28 +410,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
children.pin(key)
const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory)
const initial = child[0].status === "loading"
await Promise.all([
bootstrapDirectory({
directory,
scope: serverSDK.scope,
mcp: children.mcp(key),
global: {
config: globalStore.config,
path: globalStore.path,
project: globalStore.project,
provider: globalStore.provider,
},
api: serverSDK.api,
store: child[0],
setStore: child[1],
loadSessions,
translate: language.t,
queryClient,
session,
}),
initial ? refreshVcs(directory) : Promise.resolve(),
])
const cache = children.vcsCache.get(key)
if (!cache) return
await bootstrapDirectory({
directory,
scope: serverSDK.scope,
mcp: children.mcp(key),
global: {
config: globalStore.config,
path: globalStore.path,
project: globalStore.project,
provider: globalStore.provider,
},
api: serverSDK.api,
store: child[0],
setStore: child[1],
vcsCache: cache,
loadSessions,
translate: language.t,
queryClient,
session,
})
})
booting.set(key, promise)
@@ -537,43 +457,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
loadLsp() {},
})
}
const updateHomeSession = (info: Parameters<typeof session.remember>[0]) =>
homeSessions.apply({
type: "session.updated",
properties: { sessionID: info.id, info },
})
const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => {
if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) {
const key = directoryKey(directory)
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
}
if (!previousDirectory || previousDirectory === directory) return
const key = directoryKey(previousDirectory)
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
}
const toDirectoryEvent = (event: ServerEvent) => {
if (event.current?.type === "session.created") return
if (
event.current?.type !== "session.renamed" &&
event.current?.type !== "session.moved" &&
event.current?.type !== "session.usage.updated"
)
return event
const info = session.get(event.current.data.sessionID)
if (info) return { type: "session.updated", properties: { info } }
return event
}
const unsub = serverSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
const eventType: string = event.type
const previousDirectory =
event.current?.type === "session.moved"
? session.get(event.current.data.sessionID)?.location.directory
: undefined
markSessionListChanged(event, directory, previousDirectory)
const recent = bootingRoot || Date.now() - bootedAt < 1500
if (event.current) session.applyV2(event.current)
session.apply(event)
if (event.current?.type === "session.created")
@@ -596,41 +487,35 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (event.type === "session.created" || event.type === "session.deleted") {
if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
}
if (
event.current?.type === "session.renamed" ||
event.current?.type === "session.moved" ||
event.current?.type === "session.usage.updated"
) {
const sessionID = event.current.data.sessionID
const info = session.get(sessionID)
if (info) updateHomeSession(info)
if (!info)
void session
.resolve(sessionID)
.then(() => {
const current = session.get(sessionID)
if (current) updateHomeSession(current)
})
.catch(() => undefined)
}
homeSessions.refresh(event.type)
catalog.handleEvent({ type: eventType, directory })
connection.handleEvent({ type: eventType, directory })
if (eventType === "integration.connection.updated") void refreshProviders()
if (directory === "global") {
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
void activeSessionsQuery.refetch()
applyGlobalEvent({
event,
project: globalStore.project,
refresh: () => void bootstrap.refetch(),
refresh: () => {
if (recent) return
bootstrap.refetch()
},
setGlobalProject: setProjects,
})
if (
eventType === "config.updated" ||
eventType === "catalog.updated" ||
eventType === "agent.updated" ||
eventType === "project.directories.updated"
)
bootstrap.refetch()
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
if (eventType === "server.connected" || eventType === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
if (!children.active(directory)) continue
queue.push(directory)
}
}
return
}
@@ -651,6 +536,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
event.current?.type === "session.moved" ||
// event.current?.type === "session.archived" ||
event.current?.type === "session.forked" ||
eventType === "command.updated" ||
eventType === "config.updated" ||
eventType === "agent.updated"
)
@@ -658,39 +544,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
const [store, setStore] = existing
if (eventType === "agent.updated")
void queryClient
.fetchQuery(queryOptionsApi.agents(key))
.then((data) => setStore("agent", data))
.catch(() => {})
if (eventType === "command.updated")
void loadCommands(directory, serverSDK.api.command)
.then((commands) => setStore("command", commands))
.catch(() => {})
if (eventType === "project.directories.updated") void bootstrap.refetch()
const projected = toDirectoryEvent(event)
if (projected)
applyDirectoryEvent({
event: projected,
directory,
store,
setStore,
push: (directory) => {
if (children.active(directory)) queue.push(directory)
},
retainedLimit: sessionMeta.get(key)?.limit,
sessionContent: false,
permission: session.data.permission,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
loadReferences: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.references(key))
},
})
applyDirectoryEvent({
event,
directory,
store,
setStore,
push: (directory) => {
if (children.active(directory)) queue.push(directory)
},
retainedLimit: sessionMeta.get(key)?.limit,
sessionContent: false,
permission: session.data.permission,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
loadReferences: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.references(key))
},
})
})
onCleanup(unsub)
@@ -703,6 +577,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
})
onMount(() => {
if (typeof requestAnimationFrame === "function") {
eventFrame = requestAnimationFrame(() => {
eventFrame = undefined
eventTimer = setTimeout(() => {
eventTimer = undefined
void serverSDK.event.start()
}, 0)
})
} else {
eventTimer = setTimeout(() => {
eventTimer = undefined
void serverSDK.event.start()
}, 0)
}
})
const projectApi = {
loadSessions,
meta(directory: string, patch: ProjectMeta) {
@@ -743,11 +634,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
peek: children.peek,
disableMcp: children.disableMcp,
queryOptions: queryOptionsApi,
refreshProviders: catalog.refreshActive,
refreshProviders,
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
session: Object.assign(session, { hydrate: hydrateSession }),
session,
homeSessions,
mcp: {
toggle: async (directory: string, name: string) => {
@@ -808,13 +699,13 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
name: "ServerSync",
// Returns an accessor so the resolved server can change reactively without
// re-instantiating the subtree (mirrors useServerSDK).
init: (props: { server?: ServerConnection.Any }) => {
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
return createMemo<ServerSync>(() => {
const conn = props.server ?? server.current
const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.ensureServerCtx(conn).sync
})
@@ -1,51 +0,0 @@
import { expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query"
import { ServerScope } from "@/utils/server-scope"
import { createCatalogSync } from "./catalog"
import { pathKey } from "@/utils/path-key"
test("invalidates the catalog for the event location", async () => {
const queryClient = new QueryClient()
const one = [ServerScope.local, "/one", "providers"] as const
const integrations = [ServerScope.local, "/one", "integrations"] as const
const two = [ServerScope.local, "/two", "providers"] as const
queryClient.setQueryData(one, { providers: ["one"] })
queryClient.setQueryData(integrations, { integrations: ["one"] })
queryClient.setQueryData(two, { providers: ["two"] })
const catalog = createCatalogSync({
scope: ServerScope.local,
queryClient,
active: () => [pathKey("/one"), pathKey("/two")],
load: async () => {},
})
catalog.handleEvent({ type: "catalog.updated", directory: "/one" })
await Bun.sleep(0)
expect(queryClient.getQueryState(one)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(integrations)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(two)?.isInvalidated).toBe(false)
})
test("invalidates global and active catalogs after connection", async () => {
const queryClient = new QueryClient()
const global = [ServerScope.local, null, "providers"] as const
const active = [ServerScope.local, "/active", "providers"] as const
const passive = [ServerScope.local, "/passive", "providers"] as const
queryClient.setQueryData(global, {})
queryClient.setQueryData(active, {})
queryClient.setQueryData(passive, {})
const catalog = createCatalogSync({
scope: ServerScope.local,
queryClient,
active: () => [pathKey("/active")],
load: async () => {},
})
catalog.handleEvent({ type: "server.connected", directory: "global" })
await Bun.sleep(0)
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(active)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(passive)?.isInvalidated).toBe(false)
})
@@ -1,53 +0,0 @@
import type { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
import { pathKey, type PathKey } from "@/utils/path-key"
type CatalogEvent = {
type: string
directory: string
}
export function createCatalogSync(input: {
scope: ServerScope
queryClient: QueryClient
active: () => PathKey[]
load: (directory: PathKey | null) => Promise<void>
}) {
function handleEvent(event: CatalogEvent) {
if (event.type === "server.connected") {
void refreshActive().catch(() => undefined)
return
}
if (
event.type === "catalog.updated" ||
event.type === "integration.updated" ||
event.type === "integration.connection.updated"
) {
void refresh(event.directory === "global" ? null : pathKey(event.directory)).catch(() => undefined)
}
}
async function refresh(directory: PathKey | null) {
await Promise.all(
["providers", "integrations"].map((resource) =>
input.queryClient.invalidateQueries({
queryKey: [input.scope, directory, resource],
exact: true,
refetchType: "none",
}),
),
)
await input.load(directory)
}
function refreshActive() {
return Promise.all([null, ...new Set(input.active())].map(refresh)).then(() => undefined)
}
return {
handleEvent,
refresh,
refreshActive,
}
}
@@ -1,23 +0,0 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createConnectionSync } from "./connection"
test("invalidates disconnected data and synchronizes after the handshake", () => {
const calls: string[] = []
const dispose = createRoot((dispose) => {
const [status, setStatus] = createSignal<"connecting" | "connected" | "reconnecting">("connecting")
const connection = createConnectionSync({
status,
invalidate: () => calls.push("invalidate"),
connected: () => calls.push("connected"),
})
connection.handleEvent({ type: "server.connected", directory: "global" })
expect(calls).toContain("connected")
connection.handleEvent({ type: "server.connected", directory: "/repo" })
expect(calls.filter((call) => call === "connected")).toHaveLength(1)
setStatus("connected")
return dispose
})
dispose()
})
@@ -1,22 +0,0 @@
import { createEffect, type Accessor } from "solid-js"
import type { ServerConnectionStatus } from "../server-sdk"
export function createConnectionSync(input: {
status: Accessor<ServerConnectionStatus>
invalidate: () => void
connected: (info: { reconnect: boolean }) => void
}) {
createEffect(() => {
if (input.status() === "connected") return
input.invalidate()
})
let connectedOnce = false
function handleEvent(event: { type: string; directory: string }) {
if (event.directory !== "global" || event.type !== "server.connected") return
input.connected({ reconnect: connectedOnce })
connectedOnce = true
}
return { handleEvent }
}
@@ -1,21 +0,0 @@
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { createQuery } from "@tanstack/solid-query"
import type { Accessor } from "solid-js"
export function useIntegrations(directory: Accessor<string | undefined>) {
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const query = createQuery(() => {
const value = directory()
return {
...serverSync().queryOptions.integrations(value ? pathKey(value) : null),
enabled: serverSDK().connection.status() === "connected",
}
})
return {
list: () => (query.isSuccess || query.isRefetchError ? query.data : []),
}
}
+4 -6
View File
@@ -1,4 +1,6 @@
import { useServerSync } from "@/context/server-sync"
import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect"
import { createEffect, createMemo, type Accessor } from "solid-js"
import { selectProviderCatalog } from "./provider-catalog"
@@ -17,7 +19,8 @@ const popularProviderSet = new Set(popularProviders)
export function useProviders(directory: Accessor<string | undefined>) {
const serverSync = useServerSync()
const dir = directory
const params = useParams()
const dir = () => (directory ? directory() : decode64(params.dir))
const providers = () => {
const value = dir()
const projectStore = value ? serverSync().child(value)[0] : undefined
@@ -36,11 +39,6 @@ export function useProviders(directory: Accessor<string | undefined>) {
}
return {
ready: () => {
const value = dir()
if (!value) return false
return serverSync().child(value)[0].provider_ready
},
all: () => providers().all,
default: () => providers().default,
popular: () =>
+3 -46
View File
@@ -673,17 +673,6 @@ export const dict = {
"session.error.notFound": "This session cannot be found",
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.combine": "{{first}} and {{second}}",
"session.background.shell.one": "{{count}} shell",
"session.background.shell.other": "{{count}} shells",
"session.background.subagent.one": "{{count}} subagent",
"session.background.subagent.other": "{{count}} subagents",
"command.session.background": "Move to background",
"session.timeline.notice.finished": "{{actor}} finished",
"session.timeline.notice.failed": "{{actor}} failed",
"session.timeline.notice.cancelled": "{{actor}} cancelled",
"session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "Files Changed {{count}}",
"session.review.change.one": "Change",
@@ -903,46 +892,14 @@ export const dict = {
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
"settings.tab.general": "General",
"settings.tab.preferences": "Preferences",
"settings.tab.shortcuts": "Shortcuts",
"settings.tab.notifications": "Notifications",
"settings.tab.projects": "Projects",
"settings.tab.extensions": "Extensions",
"settings.preferences.description": "Customize preferences and theme and default behavior",
"settings.appearance.description": "Customize theme and fonts",
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
"settings.shortcuts.description": "Customize shortcuts for common actions",
"settings.servers.description": "Manage server connections",
"settings.projects.title": "Projects",
"settings.projects.description": "Manage project settings on this server",
"settings.projects.empty": "No projects found",
"settings.projects.server.all": "All servers",
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
"settings.extensions.description": "Manage extensions available on this server",
"settings.extensions.tab.mcps": "MCPs",
"settings.extensions.tab.skills": "Skills",
"settings.extensions.availableAll": "Available to all projects",
"settings.extensions.manageConfig": "Manage in opencode.json",
"settings.extensions.addSkills": "How to add skills",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"dialog.server.authenticate.title": "Authenticate",
"project.settings.general.description": "Manage project name and appearance",
"project.settings.scripts": "Scripts",
"project.settings.scripts.description": "Configure scripts for this project",
"project.settings.extensions.description": "View extensions available to this project",
"project.settings.extensions.tab.lsps": "LSPs",
"project.settings.extensions.added": "Added to this project",
"project.settings.extensions.shared": "Shared with all projects",
"project.settings.extensions.lsp.detected": "Detected language servers",
"project.settings.extensions.lsp.description": "Auto-detected from file types",
"project.settings.extensions.setupRequired": "Setup required",
"settings.general.section.appearance": "Appearance",
"settings.general.section.general": "General",
"settings.general.section.advanced": "Advanced",
"settings.general.section.notifications": "Desktop notifications",
"settings.general.section.notifications": "System notifications",
"settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects",
"settings.general.section.feed": "Feed",
@@ -1103,7 +1060,7 @@ export const dict = {
"settings.shortcuts.group.prompt": "Prompt",
"settings.providers.title": "Providers",
"settings.providers.description": "Connect and manage model providers",
"settings.providers.description": "Provider settings will be configurable here.",
"settings.providers.section.connected": "Connected providers",
"settings.providers.connected.empty": "No connected providers",
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
@@ -1114,7 +1071,7 @@ export const dict = {
"settings.providers.tag.custom": "Custom",
"settings.providers.tag.other": "Other",
"settings.models.title": "Models",
"settings.models.description": "Choose which models appear in model picker",
"settings.models.description": "Model settings will be configurable here.",
"settings.agents.title": "Agents",
"settings.agents.description": "Agent settings will be configurable here.",
"settings.commands.title": "Commands",
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, test } from "bun:test"
import { desktopNativePluralCategories } from "./desktop-native"
const appLocales = [
"ar",
"br",
"bs",
"da",
"de",
"es",
"fr",
"ja",
"ko",
"no",
"pl",
"ru",
"uk",
"th",
"tr",
"zh",
"zht",
"hi",
"nl",
"id",
"vi",
"it",
"ur",
"pa",
"az",
"fi",
"sv",
"am",
"bg",
"bn",
"ca",
"cs",
"dv",
"dz",
"el",
"et",
"fa",
"fo",
"hr",
"hu",
"hy",
"is",
"ka",
"km",
"lo",
"lt",
"lv",
"mk",
"mn",
"ms",
"my",
"ne",
"ro",
"si",
"sk",
"sl",
"sq",
"sr",
"tg",
"tk",
"uz",
] as const
const desktopLocales = appLocales
const pluralCategories = new Map(
appLocales.map(
(locale) =>
[
locale,
desktopNativePluralCategories(locale).filter((category) => category !== "one" && category !== "other"),
] as const,
),
)
const domains = [
{
name: "app",
source: "./en.ts",
target: (locale: string) => `./${locale}.ts`,
locales: appLocales,
},
{
name: "ui",
source: "../../../ui/src/i18n/en.ts",
target: (locale: string) => `../../../ui/src/i18n/${locale}.ts`,
locales: appLocales,
},
{
name: "desktop",
source: "../../../desktop/src/renderer/i18n/en.ts",
target: (locale: string) => `../../../desktop/src/renderer/i18n/${locale}.ts`,
locales: desktopLocales,
},
] as const
describe("i18n parity", () => {
test("non-English locales have every English key and required plural variants", async () => {
for (const domain of domains) {
const source = await dictionary(domain.source)
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key))
const extra = Object.keys(target)
.filter((key) => !Object.hasOwn(source, key))
.sort()
const expected = pluralFamilies(source)
.flatMap((key) => (pluralCategories.get(locale) ?? []).map((category) => `${key}.${category}`))
.sort()
expect({ domain: domain.name, locale, missing, extra }).toEqual({
domain: domain.name,
locale,
missing: [],
extra: expected,
})
}
}
})
test("non-English locales preserve English placeholders", async () => {
for (const domain of domains) {
const source = await dictionary(domain.source)
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const mismatched = Object.keys(source).filter(
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
)
const pluralMismatched = pluralFamilies(source).flatMap((key) =>
(pluralCategories.get(locale) ?? [])
.map((category) => `${key}.${category}`)
.filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()),
)
expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({
domain: domain.name,
locale,
mismatched: [],
pluralMismatched: [],
})
}
}
})
test("non-English locales translate targeted unseen session keys", async () => {
const source = await dictionary("./en.ts")
for (const locale of appLocales) {
const target = await dictionary(`./${locale}.ts`)
for (const key of ["command.session.previous.unseen", "command.session.next.unseen"]) {
expect(target[key]).toBeDefined()
expect(target[key]).not.toBe(source[key])
}
}
})
test("changed-file summary keys preserve rendered English copy and localize complete phrases", async () => {
const source = await dictionary("../../../ui/src/i18n/en.ts")
expect(source["ui.sessionTurn.diffs.changed.one"].replace("{{count}}", "1")).toBe("1 Changed file")
expect(source["ui.sessionTurn.diffs.changed.other"].replace("{{count}}", "2")).toBe("2 Changed files")
expect(source["ui.sessionTurn.diffs.changed"]).toBeUndefined()
for (const locale of appLocales) {
const target = await dictionary(`../../../ui/src/i18n/${locale}.ts`)
for (const key of ["ui.sessionTurn.diffs.changed.one", "ui.sessionTurn.diffs.changed.other"]) {
expect(target[key].trim()).not.toBe("")
expect(placeholders(target[key])).toEqual(["count"])
}
}
})
})
describe("i18n plural parity", () => {
test("locale-specific categories exist and preserve count placeholders", async () => {
for (const domain of domains.slice(0, 2)) {
const source = await dictionary(domain.source)
const families = pluralFamilies(source)
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const missing = families.flatMap((key) =>
(pluralCategories.get(locale) ?? [])
.map((category) => `${key}.${category}`)
.filter((variant) => !Object.hasOwn(target, variant)),
)
const mismatched = families.flatMap((key) =>
(pluralCategories.get(locale) ?? [])
.map((category) => `${key}.${category}`)
.filter(
(variant) =>
Object.hasOwn(target, variant) &&
placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join(),
),
)
expect({ domain: domain.name, locale, missing, mismatched }).toEqual({
domain: domain.name,
locale,
missing: [],
mismatched: [],
})
}
}
})
})
async function dictionary(file: string) {
const module: unknown = await import(file)
if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
throw new Error(`Invalid translation dictionary: ${file}`)
}
return module.dict
}
function isDictionary(value: unknown): value is Record<string, string> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false
return Object.values(value).every((item) => typeof item === "string")
}
function placeholders(value: string) {
return Array.from(value.matchAll(/{{\s*([^}]+?)\s*}}/g), (match) => match[1]).sort()
}
function pluralFamilies(dictionary: Record<string, string>) {
return Object.keys(dictionary)
.filter(
(key) =>
key.endsWith(".one") &&
dictionary[key].includes("{{count}}") &&
dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"),
)
.map((key) => key.slice(0, -4))
}
+9 -9
View File
@@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/session-ui/context"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js"
import { type Accessor, createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
@@ -15,9 +15,9 @@ import { useServerSync } from "@/context/server-sync"
export function DirectoryDataProvider(
props: ParentProps<{
directory: string
directory: string | Accessor<string>
draftID?: string
server?: ServerConnection.Key
server?: Accessor<ServerConnection.Key | undefined>
}>,
) {
const location = useLocation()
@@ -25,17 +25,17 @@ export function DirectoryDataProvider(
const params = useParams()
const sync = useSync()
const serverSync = useServerSync()
const language = useLanguage()
const directory = () => props.directory
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => {
if (props.server) return sessionHref(props.server, sessionID)
const server = props.server?.()
if (server) return sessionHref(server, sessionID)
return `/${slug()}/session/${sessionID}`
}
createEffect(() => {
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
if (props.draftID || props.server) return
if (props.draftID || props.server?.()) return
const next = sync().data.path.directory
if (!next || next === directory()) return
const path = location.pathname.slice(slug().length + 1)
@@ -45,8 +45,8 @@ export function DirectoryDataProvider(
createResource(
() => params.id,
(id) =>
serverSync()
.session.hydrate(id)
sync()
.session.sync(id)
.catch(() => {}),
)
+2 -2
View File
@@ -23,8 +23,8 @@ export function Home() {
>
<ScrollView
class="h-full [container-type:size]"
thumbContainer={scroll.viewport.thumbTrack()}
thumbHoverTarget={scroll.viewport.hoverTarget()}
thumbContainer={scroll.viewport.thumbTrack}
thumbHoverTarget={scroll.viewport.hoverTarget}
viewportRef={scroll.viewport.setViewport}
onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
onWheel={scroll.viewport.containOuterWheel}
@@ -1,4 +1,4 @@
import { createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
import { createStore } from "solid-js/store"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
@@ -29,16 +29,16 @@ const projectContextMenuID = (server: ServerConnection.Any, directory: string) =
export type HomeProjectsViewProps = {
language: ReturnType<typeof useLanguage>
servers: ServerConnection.Any[]
projects: LocalProject[]
recentlyClosed: LocalProject[]
selection: HomeProjectSelection
homedir: string
servers: Accessor<ServerConnection.Any[]>
projects: Accessor<LocalProject[]>
recentlyClosed: Accessor<LocalProject[]>
selection: Accessor<HomeProjectSelection>
homedir: Accessor<string>
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
collapsed: (server: ServerConnection.Any) => boolean
canDefaultServer: boolean
defaultServerKey: ServerConnection.Key | null | undefined
canDefaultServer: Accessor<boolean>
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
canRevealProject: (server: ServerConnection.Any) => boolean
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
onWheel: (event: WheelEvent) => void
@@ -81,7 +81,9 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
<Show
when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2
data-action="home-add-project"
@@ -89,8 +91,8 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<IconV2 name="folder-add-left" />}
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
disabled={props.serverHealth(props.servers()[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers()[0])}
aria-label={props.language.t("home.project.add")}
/>
</TooltipV2>
@@ -98,20 +100,25 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
</div>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
<Show
when={props.servers.length > 1}
when={props.servers().length > 1}
fallback={
<div class="pr-3">
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
when={props.projects().length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
>
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
<HomeProjectList
{...props}
{...contextMenuProps}
server={props.servers()[0]}
items={props.projects()}
/>
</Show>
</div>
}
>
<div class="flex min-w-0 flex-col gap-4 pr-3">
<For each={props.servers}>
<For each={props.servers()}>
{(item) => {
const projects = () => props.projectsForServer(item)
const healthy = () => !!props.serverHealth(item)?.healthy
@@ -123,7 +130,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
server={item}
{...props}
{...contextMenuProps}
selected={props.selection.server === ServerConnection.key(item) && !props.selection.directory}
selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
collapsed={collapsed()}
health={props.serverHealth(item)}
/>
@@ -270,8 +277,8 @@ function HomeServerRow(props: {
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer}
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
canDefault={props.canDefaultServer()}
isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
@@ -332,7 +339,7 @@ function HomeProjectList(props: HomeProjectListProps) {
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
if (props.selection.server !== ServerConnection.key(props.server))
if (props.selection().server !== ServerConnection.key(props.server))
props.onSelectProject(props.server, source.id.toString())
}}
>
@@ -343,7 +350,7 @@ function HomeProjectList(props: HomeProjectListProps) {
row's sortable unregisters on unmount) and discarding animations.
String keys keep row elements alive and move them on reorder. */}
<For each={props.items.map((project) => project.worktree)}>
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index()} />}
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
</For>
</div>
</DragDropProvider>
@@ -353,7 +360,7 @@ function HomeProjectList(props: HomeProjectListProps) {
function HomeProjectSlot(
props: HomeProjectListProps & {
worktree: string
index: number
index: () => number
},
) {
const initial = props.items.find((item) => item.worktree === props.worktree)
@@ -369,9 +376,10 @@ function HomeProjectSlot(
project={project()}
server={props.server}
index={props.index}
serverSelected={props.selection.server === ServerConnection.key(props.server)}
serverSelected={props.selection().server === ServerConnection.key(props.server)}
selected={
props.selection.server === ServerConnection.key(props.server) && props.selection.directory === props.worktree
props.selection().server === ServerConnection.key(props.server) &&
props.selection().directory === props.worktree
}
unseen={props.unseenCount(props.server, project())}
/>
@@ -417,7 +425,7 @@ function HomeRecentlyClosedRow(
) {
const unreachable = () => props.serverHealth(props.server)?.healthy === false
const path = () => {
const home = props.homedir
const home = props.homedir()
const worktree = props.project.worktree
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
return worktree
@@ -443,7 +451,7 @@ function HomeProjectRow(
HomeProjectsContextMenuProps & {
project: LocalProject
server: ServerConnection.Any
index: number
index: () => number
serverSelected: boolean
selected: boolean
unseen: number
@@ -456,7 +464,7 @@ function HomeProjectRow(
return props.project.worktree
},
get index() {
return props.index
return props.index()
},
})
let pointerDownSelected: boolean | undefined
@@ -6,16 +6,16 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
return (
<HomeProjectsView
language={props.projects.copy.language}
servers={props.projects.server.list()}
projects={props.projects.project.list()}
recentlyClosed={props.projects.project.recentlyClosed()}
selection={props.projects.selection.value()}
homedir={props.projects.project.homedir()}
servers={props.projects.server.list}
projects={props.projects.project.list}
recentlyClosed={props.projects.project.recentlyClosed}
selection={props.projects.selection.value}
homedir={props.projects.project.homedir}
serverHealth={props.projects.server.health}
projectsForServer={props.projects.server.projects}
collapsed={props.projects.server.collapsed}
canDefaultServer={props.projects.server.canDefault()}
defaultServerKey={props.projects.server.defaultKey()}
canDefaultServer={props.projects.server.canDefault}
defaultServerKey={props.projects.server.defaultKey}
canRevealProject={props.projects.project.canReveal}
unseenCount={props.projects.project.unseenCount}
onWheel={props.scroll.viewport.containWheel}
@@ -60,7 +60,7 @@ export function createHomeSessionsController(home: HomeController) {
}))
const sessionLoad = useQuery(() => ({
queryKey: homeSessions().indexKey,
enabled: home.server.focusedContext()?.sdk.connection.status() === "connected",
enabled: !!home.server.focusedContext(),
queryFn: async ({ signal }) => {
const ctx = home.server.focusedContext()
if (!ctx) return { sessions: [], eventSequence: 0 }
@@ -295,13 +295,13 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
export function HomeSessionStatusController(props: {
server: ServerConnection.Key
server: Accessor<ServerConnection.Key>
record: HomeSessionRecord
isOpenTab: (record: HomeSessionRecord) => boolean
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
}) {
const avatar = useSessionTabAvatarState(
() => props.server,
props.server,
() => props.record.session.location.directory,
() => props.record.session.id,
)
@@ -1,5 +1,5 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createMemo, For, Show, Suspense } from "solid-js"
import { type Accessor, createMemo, For, Show, Suspense } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
@@ -38,17 +38,17 @@ function isBackgroundOpen(event: MouseEvent) {
export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage>
groups: HomeSessionGroup[]
showProjectName: boolean
server: ServerConnection.Key
canCreateSession: boolean
searchValue: string
searchPlaceholder: string
searchOpen: boolean
searchLoading: boolean
searchResults: HomeSessionRecord[]
searchActive: string
searchNoResultsLabel: string
groups: Accessor<HomeSessionGroup[]>
showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean>
searchValue: Accessor<string>
searchPlaceholder: Accessor<string>
searchOpen: Accessor<boolean>
searchLoading: Accessor<boolean>
searchResults: Accessor<HomeSessionRecord[]>
searchActive: Accessor<string>
searchNoResultsLabel: Accessor<string>
titleOpacity: (id: HomeSessionGroup["id"]) => number
isOpenTab: (record: HomeSessionRecord) => boolean
onCreateSession: () => void
@@ -81,7 +81,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} />
<Suspense>
<Show when={props.groups.length > 0 && props.canCreateSession}>
<Show when={props.groups().length > 0 && props.canCreateSession()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
@@ -113,16 +113,16 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
}
>
<Show
when={props.groups.length > 0}
when={props.groups().length > 0}
fallback={
<HomeSessionsEmpty
onNewSession={props.canCreateSession ? props.onCreateSession : undefined}
onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
language={props.language}
/>
}
>
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
<For each={props.groups}>
<For each={props.groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
@@ -132,7 +132,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups.length - 1 ? "" : "mb-6"}`}
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
</div>
@@ -205,7 +205,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
return (
<div class="w-full">
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
<Show when={props.searchOpen}>
<Show when={props.searchOpen()}>
<div
data-component="home-session-search-panel"
class={`
@@ -217,7 +217,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
<div class="flex flex-col pt-9">
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
<Show
when={!props.searchLoading}
when={!props.searchLoading()}
fallback={
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
<Spinner class="size-4" />
@@ -225,7 +225,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
}
>
<Show
when={props.searchResults.length > 0}
when={props.searchResults().length > 0}
fallback={
<p
class={`
@@ -233,7 +233,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
text-v2-text-text-muted [font-weight:440]
`}
>
{props.searchNoResultsLabel}
{props.searchNoResultsLabel()}
</p>
}
>
@@ -248,12 +248,12 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
</p>
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
<div class="flex flex-col gap-px pb-2">
<For each={props.searchResults}>
<For each={props.searchResults()}>
{(record) => (
<HomeSessionSearchResultRow
{...props}
record={record}
selected={props.searchActive === homeSessionSearchKey(record)}
selected={props.searchActive() === homeSessionSearchKey(record)}
/>
)}
</For>
@@ -280,14 +280,16 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0
text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint
`}
value={props.searchValue}
placeholder={props.searchPlaceholder}
aria-label={props.searchPlaceholder}
aria-expanded={props.searchOpen}
value={props.searchValue()}
placeholder={props.searchPlaceholder()}
aria-label={props.searchPlaceholder()}
aria-expanded={props.searchOpen()}
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
aria-autocomplete="list"
aria-activedescendant={
props.searchActive && props.searchOpen ? `home-session-search-option-${props.searchActive}` : undefined
props.searchActive() && props.searchOpen()
? `home-session-search-option-${props.searchActive()}`
: undefined
}
onFocus={props.onSearchFocus}
onInput={(event) => props.onSearchInput(event.currentTarget.value)}
@@ -298,7 +300,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
event.currentTarget.blur()
return
}
if (!props.searchOpen || props.searchResults.length === 0) return
if (!props.searchOpen() || props.searchResults().length === 0) return
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
@@ -316,14 +318,14 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
}
}}
/>
<Show when={props.searchValue}>
<Show when={props.searchValue()}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="relative z-20 shrink-0"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
aria-label={props.searchPlaceholder}
aria-label={props.searchPlaceholder()}
onClick={() => {
props.onSearchClose()
props.onSearchFocus()
@@ -343,7 +345,7 @@ function HomeSessionSearchResultRow(
},
) {
const title = createMemo(() => sessionLabel(props.record.session))
const showProjectName = () => props.showProjectName && props.record.projectName
const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
return (
@@ -414,7 +416,7 @@ function HomeSessionGroupHeader(props: {
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => sessionLabel(props.record.session))
const showProjectName = () => props.showProjectName && props.record.projectName
const showProjectName = () => props.showProjectName() && props.record.projectName
return (
<div
+11 -11
View File
@@ -11,17 +11,17 @@ export function HomeSessions(props: {
return (
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
showProjectName={props.sessions.session.showProjectName()}
server={props.sessions.session.server()}
canCreateSession={props.sessions.session.canCreate()}
searchValue={props.search.query.value()}
searchPlaceholder={props.search.query.placeholder()}
searchOpen={props.search.query.open()}
searchLoading={props.search.result.loading()}
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
groups={props.sessions.data.groups}
showProjectName={props.sessions.session.showProjectName}
server={props.sessions.session.server}
canCreateSession={props.sessions.session.canCreate}
searchValue={props.search.query.value}
searchPlaceholder={props.search.query.placeholder}
searchOpen={props.search.query.open}
searchLoading={props.search.result.loading}
searchResults={props.search.result.list}
searchActive={props.search.result.active}
searchNoResultsLabel={props.search.result.noResultsLabel}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
+6 -10
View File
@@ -1,4 +1,4 @@
import { onMount, Show, Suspense, type ParentProps } from "solid-js"
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
@@ -9,17 +9,15 @@ export default function Layout(props: ParentProps) {
const platform = usePlatform()
const [state, setState] = createStore({ debugTools: true })
onMount(() => setV2Toast(true))
createEffect(() => setV2Toast(true))
const update: TitlebarUpdate = {
get version() {
version: () => {
const state = platform.updater?.state()
if (state?.status !== "ready") return undefined
if (state?.status !== "ready") return
return state.version
},
get installing() {
return platform.updater?.state().status === "installing"
},
installing: () => platform.updater?.state().status === "installing",
install: () => void platform.updater?.install(),
}
@@ -42,9 +40,7 @@ export default function Layout(props: ParentProps) {
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
</main>
<Show when={import.meta.env.DEV && state.debugTools}>
<DebugBar inline />
</Show>
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
<ToastRegion v2 />
</div>
)
@@ -2,7 +2,7 @@ import { createMemo, type Accessor } from "solid-js"
import { useGlobal } from "@/context/global"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { sessionPermissionRequest, sessionQuestionForm } from "@/pages/session/composer/session-request-tree"
import { sessionPermissionRequest, sessionQuestionRequest } from "@/pages/session/composer/session-request-tree"
import { ServerConnection } from "@/context/server"
export function useSessionTabAvatarState(
@@ -31,7 +31,7 @@ export function useSessionTabAvatarState(
const serverSync = sync()
if (!serverSync) return false
const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId())
return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const notificationState = createMemo(() => {
+1 -1
View File
@@ -40,7 +40,7 @@ export default function NewSessionPage() {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus mount={rightMount()} visible={settings.visibility.status()} />
<NewSessionStatus mount={rightMount} visible={settings.visibility.status} />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<NewSessionView input={draft.input} project={project} workspace={workspace} />
</div>
@@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
import { Show, createMemo, createSignal } from "solid-js"
import { Show, createMemo, createSignal, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import createPresence from "solid-presence"
@@ -74,14 +74,14 @@ export function NewSessionView(props: {
)
}
export function NewSessionStatus(props: { mount: HTMLElement | null; visible: boolean }) {
export function NewSessionStatus(props: { mount: Accessor<HTMLElement | null>; visible: Accessor<boolean> }) {
const language = useLanguage()
return (
<Show when={props.mount} keyed>
<Show when={props.mount()} keyed>
{(mount) => (
<Portal mount={mount}>
<Show when={props.visible}>
<Show when={props.visible()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopoverV2 />
</Tooltip>
@@ -116,7 +116,7 @@ function ProviderTip() {
})
const openProviders = () => {
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
})
}
+54 -71
View File
@@ -163,7 +163,7 @@ export function TargetSessionRouteContent() {
return (
// Settings must keep the target-server SDK, sync, and models context and remain registered
// when session content falls back to the route error boundary.
<TargetServerScopedProviders directory={directory()} sessionID={params.id}>
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
@@ -183,15 +183,17 @@ export function SessionRouteErrorBoundary(
const settings = useSettings()
return (
<ErrorBoundary
fallback={(error) => (
<Show when={settings.general.newLayoutDesigns()} fallback={<ErrorPage error={error} />}>
fallback={(error) =>
settings.general.newLayoutDesigns() ? (
<SessionRouteFrame padded={props.padded}>
<SessionPanelFrame newLayout raised={!!props.sessionID}>
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
</SessionPanelFrame>
</SessionRouteFrame>
</Show>
)}
) : (
<ErrorPage error={error} />
)
}
>
{props.children}
</ErrorBoundary>
@@ -251,6 +253,7 @@ function ResolvedTargetSessionRoute() {
() => sync().session.lineage,
)
const directory = createMemo(() => current()?.session.location.directory)
const targetDirectory = () => directory()!
createEffect(() => {
const session = current()
@@ -267,13 +270,11 @@ function ResolvedTargetSessionRoute() {
// the terminal. Same-workspace tab switches keep it open because warm
// targets resolve synchronously from the sync cache.
<Show when={directory()}>
{(dir) => (
<SDKProvider directory={dir()}>
<DirectoryDataProvider directory={dir()} server={serverKey()}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
)}
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
)
}
@@ -291,7 +292,9 @@ function TargetSessionPage() {
)
}
function TargetServerScopedProviders(props: ParentProps<{ directory?: string; sessionID?: string }>) {
function TargetServerScopedProviders(
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
) {
return (
<>
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
@@ -300,10 +303,10 @@ function TargetServerScopedProviders(props: ParentProps<{ directory?: string; se
)
}
function MarkSessionNotificationsViewed(props: { sessionID?: string }) {
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
const sessionID = props.sessionID
const sessionID = props.sessionID?.()
if (!notification.ready() || !sessionID) return
if (notification.session.unseenCount(sessionID) === 0) return
notification.session.markViewed(sessionID)
@@ -651,17 +654,11 @@ export default function Page() {
})
const vcsKey = createMemo(
() =>
[
serverSDK().scope,
"session-vcs",
sdk().directory,
sync().data.vcs?.branch ?? "",
sync().data.vcs?.default_branch ?? "",
] as const,
["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const,
)
const vcsQuery = createQuery(() => {
const mode = vcsMode()
const enabled = serverSDK().connection.status() === "connected" && wantsReview() && sync().project?.vcs === "git"
const enabled = wantsReview() && sync().project?.vcs === "git"
return {
queryKey: [...vcsKey(), mode] as const,
@@ -673,17 +670,14 @@ export default function Page() {
sdk()
.api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
.then((result) => result.data)
.catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error })
return []
})
: skipToken,
}
})
const refreshVcs = debounce(() => {
void queryClient.invalidateQueries({ queryKey: vcsKey() })
}, 100)
onCleanup(
sdk().event.listen((event) => {
if (event.details.type === "filesystem.changed") refreshVcs()
}),
)
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
createEffect(
on(
() => desktopReviewOpen() || mobileChanges(),
@@ -727,7 +721,7 @@ export default function Page() {
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
queryKey: [...vcsKey(), mode, "directory", scope, context, version] as const,
queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () =>
@@ -1117,10 +1111,6 @@ export default function Page() {
useComposerCommands()
useSessionCommands({
session: controller,
background: {
blocking: () => composer.background.blocking().length > 0,
move: composer.background.move,
},
navigateMessageByOffset,
setActiveMessage,
focusInput,
@@ -1248,8 +1238,8 @@ export default function Page() {
<SessionReviewTab
title={changesTitle()}
empty={reviewEmpty(input)}
diffs={reviewDiffs()}
view={controller.layout.view()}
diffs={reviewDiffs}
view={controller.layout.view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
@@ -1282,12 +1272,8 @@ export default function Page() {
get empty() {
return reviewEmptyV2()
},
get diffs() {
return reviewDiffs()
},
get diffsReady() {
return reviewReady()
},
diffs: reviewDiffs,
diffsReady: reviewReady,
get diffVersion() {
return vcsQuery.dataUpdatedAt
},
@@ -1482,8 +1468,6 @@ export default function Page() {
working: () => true,
overflowAnchor: "none",
})
const shouldAnchorBottom = () =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
createEffect(
on(
() => controller.identity.params.id,
@@ -2088,11 +2072,13 @@ export default function Page() {
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture()}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={onHistoryScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={shouldAnchorBottom()}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
@@ -2265,14 +2251,7 @@ export default function Page() {
width: sessionPanelWidth(),
}}
>
<Show
when={settings.general.newLayoutDesigns()}
fallback={
<SessionPanelFrame newLayout={false} raised={!!controller.identity.params.id}>
{sessionPanelContent()}
</SessionPanelFrame>
}
>
{settings.general.newLayoutDesigns() ? (
<Show when={sessionPanelKey()} keyed>
{(_) => (
<SessionPanelFrame newLayout raised={!!controller.identity.params.id}>
@@ -2280,7 +2259,11 @@ export default function Page() {
</SessionPanelFrame>
)}
</Show>
</Show>
) : (
<SessionPanelFrame newLayout={false} raised={!!controller.identity.params.id}>
{sessionPanelContent()}
</SessionPanelFrame>
)}
<Show when={desktopSessionResizeOpen()}>
<div onPointerDown={() => size.start()}>
@@ -2304,13 +2287,13 @@ export default function Page() {
<Show when={!newSessionDesign() && desktopSidePanelOpen()}>
<Suspense>
<SessionSidePanel
canReview={canReview()}
diffs={reviewDiffs()}
diffsReady={reviewReady()}
empty={reviewEmptyText()}
hasReview={hasReview()}
reviewHasFocusableContent={hasReview()}
reviewCount={reviewCount()}
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewHasFocusableContent={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
@@ -2326,13 +2309,13 @@ export default function Page() {
<div class="min-h-0 flex-1">
<Suspense>
<SessionSidePanel
canReview={canReview()}
diffs={reviewDiffs()}
diffsReady={reviewReady()}
empty={reviewEmptyText()}
hasReview={hasReview()}
reviewHasFocusableContent={hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount()}
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount}
reviewPanel={reviewPanelV2}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
@@ -1,84 +0,0 @@
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { For, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { SessionComposerPullout } from "./session-composer-pullout"
export function SessionBackgroundDock(props: {
blocking: { type: "shell" | "subagent"; id?: string; label?: string }[]
tasks: { id: string; type: "shell" | "subagent"; label: string }[]
onBackground: () => void
}) {
const language = useLanguage()
const command = useCommand()
const [store, setStore] = createStore({ collapsed: true })
const describe = (shells: number, subagents: number) => {
const shell = shells ? language.plural("session.background.shell", shells, { count: shells }) : undefined
const subagent = subagents
? language.plural("session.background.subagent", subagents, { count: subagents })
: undefined
if (shell && subagent) return language.t("session.background.combine", { first: shell, second: subagent })
return shell ?? subagent ?? ""
}
const summary = createMemo(() => {
const shells = props.tasks.filter((task) => task.type === "shell").length
return describe(shells, props.tasks.length - shells)
})
const moving = createMemo(() => {
const shells = props.blocking.filter((task) => task.type === "shell").length
const subagents = props.blocking.length - shells
const tasks = describe(shells, subagents)
return tasks ? language.t("session.background.moveTasks", { tasks }) : ""
})
const background = createMemo(() =>
summary() ? language.t("session.background.inBackground", { tasks: summary() }) : "",
)
const blocking = () => props.blocking.length > 0
const toggle = () => {
if (blocking()) {
props.onBackground()
return
}
setStore("collapsed", (value) => !value)
}
return (
<SessionComposerPullout
name="background"
label={
<span class="flex flex-col items-start">
{blocking() && (
<span>
<span class="text-v2-text-text-muted">{moving()}</span>
<span class="pl-2">
<KeybindV2 keys={command.keybindParts("session.background")} variant="neutral" />
</span>
</span>
)}
{!!props.tasks.length && <span class="text-v2-text-text-faint">{background()}</span>}
</span>
}
ariaLabel={[moving(), background()].filter(Boolean).join(". ")}
multiline={blocking() && props.tasks.length > 0}
collapsed={blocking() || store.collapsed}
collapsible={!blocking()}
onToggle={toggle}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
>
<div class="px-4 pb-11 flex flex-col gap-1.5">
<For each={props.tasks}>
{(task) => (
<div class="flex min-w-0 items-baseline gap-2 text-13-regular">
<span class="shrink-0 text-13-medium text-text-strong">
{language.t(task.type === "shell" ? "ui.tool.shell" : "ui.tool.agent.default")}
</span>
<span class="truncate text-text-weak">{task.label}</span>
</div>
)}
</For>
</div>
</SessionComposerPullout>
)
}
@@ -20,7 +20,7 @@ import { pathKey } from "@/utils/path-key"
export function createPromptInputController(input: {
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
queryOptions: Pick<QueryOptionsApi, "agents">
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
model?: ModelSelection
}) {
const layout = useLayout()
@@ -30,6 +30,8 @@ export function createPromptInputController(input: {
const providers = useProviders(() => sdk().directory)
const view = layout.view(input.sessionKey)
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
return createMemo<PromptInputControls>(() => {
return {
@@ -44,7 +46,10 @@ export function createPromptInputController(input: {
model: {
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading: (local.agent.visible() && agentsQuery.isLoading) || !providers.ready(),
loading:
(local.agent.visible() && agentsQuery.isLoading) ||
providersQuery.isLoading ||
globalProvidersQuery.isLoading,
},
session: {
id: input.sessionID(),
@@ -1,157 +0,0 @@
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { useSettings } from "@/context/settings"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, createMemo, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
export function SessionComposerPullout(props: {
name: "todo" | "background"
label: JSX.Element
ariaLabel: string
preview?: string
multiline?: boolean
collapsed: boolean
collapsible?: boolean
onToggle: () => void
collapseLabel: string
expandLabel: string
dockProgress?: number
children: JSX.Element
}) {
const settings = useSettings()
const [store, setStore] = createStore({ height: 78, header: 42 })
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
const shut = createMemo(() => 1 - dock())
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
const hide = createMemo(() => Math.max(value(), shut()))
const off = createMemo(() => hide() > 0.98)
const base = createMemo(() => Math.max(78, store.header + 36))
const full = createMemo(() => Math.max(base(), store.height))
let contentRef: HTMLDivElement | undefined
let headerRef: HTMLDivElement | undefined
createEffect(() => {
const element = contentRef
const header = headerRef
if (!element || !header) return
const update = () => {
setStore("height", (height) => Math.max(height, element.scrollHeight))
setStore("header", header.getBoundingClientRect().height)
}
update()
createResizeObserver([element, header], update)
})
return (
<Dynamic
component={settings.general.newLayoutDesigns() ? "div" : DockTray}
data-component={`session-${props.name}-dock`}
classList={{
"w-full overflow-hidden rounded-xl border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-01":
settings.general.newLayoutDesigns(),
}}
style={{
"overflow-x": "visible",
"overflow-y": "hidden",
"max-height": `${Math.max(base(), full() - value() * (full() - base()))}px`,
}}
>
<div ref={contentRef}>
<div
ref={headerRef}
data-action={`session-${props.name}-toggle`}
classList={{
"flex items-center gap-2 overflow-visible": true,
"pl-4 pr-2": settings.general.newLayoutDesigns(),
"h-[42px]": settings.general.newLayoutDesigns() && !props.multiline,
"min-h-[42px] py-2": settings.general.newLayoutDesigns() && props.multiline,
"pl-3 pr-2 py-2": !settings.general.newLayoutDesigns(),
}}
role="button"
tabIndex={0}
onClick={props.onToggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
props.onToggle()
}}
>
<span
classList={{
"cursor-default inline-flex items-baseline shrink-0 overflow-visible": true,
"font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted":
settings.general.newLayoutDesigns(),
"text-14-regular text-text-strong": !settings.general.newLayoutDesigns(),
}}
aria-label={props.ariaLabel}
style={{
"--tool-motion-odometer-ms": "600ms",
"--tool-motion-mask": "18%",
"--tool-motion-mask-height": "0px",
"--tool-motion-spring-ms": "560ms",
"white-space": "pre",
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
}}
>
{props.label}
</span>
<div
data-slot={`session-${props.name}-preview`}
class="ml-1 min-w-0 overflow-hidden"
style={{ flex: "1 1 auto", "max-width": "100%", transform: "translateY(1px)" }}
>
<TextReveal
class={
settings.general.newLayoutDesigns()
? "cursor-default text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint"
: "text-14-regular text-text-base cursor-default"
}
text={props.preview}
duration={600}
travel={25}
edge={17}
spring="cubic-bezier(0.34, 1, 0.64, 1)"
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
growOnly
truncate
/>
</div>
{props.collapsible !== false && (
<div class="ml-auto">
<IconButton
data-action={`session-${props.name}-toggle-button`}
data-collapsed={props.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${value() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
props.onToggle()
}}
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
)}
</div>
<div
data-slot={`session-${props.name}-list`}
aria-hidden={props.collapsed || off()}
classList={{ "pointer-events-none": hide() > 0.1 }}
style={{ visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, 1 - hide())}` }}
>
{props.children}
</div>
</div>
</Dynamic>
)
}
@@ -6,7 +6,6 @@ import { SessionQuestionDock } from "@/pages/session/composer/session-question-d
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import { SessionBackgroundDock } from "@/pages/session/composer/session-background-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
export function SessionComposerRegion(props: {
@@ -16,8 +15,6 @@ export function SessionComposerRegion(props: {
const language = useLanguage()
const controller = props.controller
const settings = useSettings()
const background = () =>
controller.state.background.blocking().length > 0 || controller.state.background.tasks().length > 0
const rolled = () => {
const revert = controller.revert()
return revert?.items.length ? revert : undefined
@@ -126,21 +123,12 @@ export function SessionComposerRegion(props: {
</div>
)}
</Show>
<Show when={background()}>
<div style={{ "margin-top": `${-controller.lift()}px` }}>
<SessionBackgroundDock
blocking={controller.state.background.blocking()}
tasks={controller.state.background.tasks()}
onBackground={() => void controller.state.background.move()}
/>
</div>
</Show>
<div
classList={{
"relative z-[70]": true,
}}
style={{
"margin-top": `${background() ? -36 : -controller.lift()}px`,
"margin-top": `${-controller.lift()}px`,
}}
>
<Show when={controller.followup()?.items.length}>
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { todoDockAtBoundary, todoState } from "./session-composer-state"
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -19,10 +19,8 @@ const question = (id: string, sessionID: string) =>
({
id,
sessionID,
title: "Questions",
metadata: { kind: "question" },
fields: [{ key: "q0", type: "string" }],
}) as FormInfo
questions: [],
}) as QuestionRequest
describe("sessionPermissionRequest", () => {
test("prefers the current session permission", () => {
@@ -82,7 +80,7 @@ describe("sessionPermissionRequest", () => {
})
})
describe("sessionQuestionForm", () => {
describe("sessionQuestionRequest", () => {
test("prefers the current session question", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const questions = {
@@ -90,7 +88,7 @@ describe("sessionQuestionForm", () => {
child: [question("q-child", "child")],
}
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-root")
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-root")
})
test("returns a nested child question", () => {
@@ -103,16 +101,7 @@ describe("sessionQuestionForm", () => {
grand: [question("q-grand", "grand")],
}
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-grand")
})
test("skips forms that are not questions", () => {
const sessions = [session({ id: "root" })]
const forms = {
root: [{ ...question("form", "root"), metadata: { kind: "integration" } }],
}
expect(sessionQuestionForm(sessions, forms, "root")).toBeUndefined()
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
})
})
@@ -1,17 +1,15 @@
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { Todo } from "@/types"
import type { FormInfo, PermissionRequest } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
import { useParams } from "@solidjs/router"
import { showToast } from "@/utils/toast"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
import { createQuery, useQueryClient } from "@tanstack/solid-query"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
export const todoState = (input: {
count: number
@@ -33,33 +31,11 @@ export function createSessionComposerController(options?: { closeMs?: number | (
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const queryClient = useQueryClient()
const language = useLanguage()
const permission = usePermission()
const shellKey = () => [serverSDK().scope, sdk().directory, "shell"] as const
const shells = createQuery(() => ({
queryKey: shellKey(),
enabled: !!params.id && serverSDK().connection.status() === "connected",
queryFn: () =>
sdk()
.api.shell.list({ location: { directory: sdk().directory } })
.then((result) => result.data ?? []),
}))
onCleanup(
sdk().event.listen((event) => {
if (
event.details.type !== "shell.created" &&
event.details.type !== "shell.exited" &&
event.details.type !== "shell.deleted"
)
return
void queryClient.invalidateQueries({ queryKey: shellKey(), exact: true })
}),
)
const questionRequest = createMemo((): FormInfo | undefined => {
return sessionQuestionForm(sync().data.session, serverSync().session.data.form, params.id)
const questionRequest = createMemo((): QuestionRequest | undefined => {
return sessionQuestionRequest(sync().data.session, sync().data.question, params.id)
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
@@ -85,119 +61,6 @@ export function createSessionComposerController(options?: { closeMs?: number | (
)
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
const primary = () => {
const id = params.id
return !!id && !serverSync().session.get(id)?.parentID
}
const backgroundBlocking = createMemo(() => {
if (!primary()) return []
const id = params.id
if (!id) return []
const assistant = (serverSync().session.data.session_message[id] ?? []).findLast(
(message) => message.type === "assistant" && message.time.completed === undefined,
)
if (assistant?.type !== "assistant") return []
return assistant.content.flatMap((part) => {
if (part.type !== "tool" || part.state.status !== "running") return []
if (part.name !== "shell" && part.name !== "subagent") return []
const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID
const label = part.name === "shell" ? part.state.input.command : part.state.input.description
return [
{
type: part.name as "shell" | "subagent",
id: typeof value === "string" ? value : undefined,
label: typeof label === "string" ? label : undefined,
},
]
})
})
const backgroundTasks = createMemo(() => {
if (!primary()) return []
const id = params.id
if (!id) return []
const blocking = backgroundBlocking()
const messages = serverSync().session.data.session_message[id] ?? []
const completed = new Set(
messages.flatMap((message) => {
if (message.type !== "synthetic") return []
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
return [message.metadata.childID]
if (message.metadata?.source === "shell" && typeof message.metadata.jobID === "string")
return [message.metadata.jobID]
return []
}),
)
const backgrounded = messages.flatMap((message) => {
if (message.type !== "assistant") return []
return message.content.flatMap((part) => {
if (part.type !== "tool" || part.name !== "subagent") return []
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
const sessionID = part.state.metadata.sessionID
if (typeof sessionID !== "string" || completed.has(sessionID)) return []
const description = part.state.input.description
return [
{
id: sessionID,
type: "subagent" as const,
label: typeof description === "string" ? description : sessionID,
},
]
})
})
const active = Object.values(serverSync().session.data.info).flatMap((info) => {
if (info?.parentID !== id) return []
if ((serverSync().session.data.session_status[info.id]?.type ?? "idle") === "idle") return []
if (
blocking.some(
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
)
)
return []
return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }]
})
const backgroundShells = messages.flatMap((message) => {
if (message.type !== "assistant") return []
return message.content.flatMap((part) => {
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
const shellID = part.state.metadata.shellID
const command = part.state.input.command
return [
{
id: typeof shellID === "string" ? shellID : part.id,
type: "shell" as const,
label: typeof command === "string" ? command : part.id,
},
]
})
})
const running = (shells.isSuccess || shells.isRefetchError ? shells.data : []).flatMap((shell) => {
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
if (
blocking.some(
(item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)),
)
)
return []
return [{ id: shell.id, type: "shell" as const, label: shell.command }]
})
return [
...new Map([...backgrounded, ...active, ...backgroundShells, ...running].map((task) => [task.id, task])).values(),
]
})
const moveToBackground = async () => {
if (!primary()) return
const sessionID = params.id
if (!sessionID) return
await sdk()
.api.session.background({ sessionID })
.catch((error) => {
showToast({
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
})
}
const [store, setStore] = createStore({
sessionID: params.id,
@@ -328,11 +191,6 @@ export function createSessionComposerController(options?: { closeMs?: number | (
questionRequest,
permissionRequest,
permissionResponding,
background: {
blocking: backgroundBlocking,
tasks: backgroundTasks,
move: moveToBackground,
},
decide,
todos,
dock: () =>
@@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { showToast } from "@/utils/toast"
import type { FormAnswer, FormInfo, FormMultiselectField, FormStringField } from "@opencode-ai/client/promise"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/client/promise"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -14,13 +14,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useServerSDK } from "@/context/server-sdk"
import { ScopedKey } from "@/utils/server-scope"
const cache = new Map<string, { tab: number; answers: string[][]; custom: string[]; customOn: boolean[] }>()
type QuestionField = FormStringField | FormMultiselectField
function questionField(field: FormInfo["fields"][number]): field is QuestionField {
return field.type === "string" || field.type === "multiselect"
}
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
return (
@@ -67,27 +61,19 @@ function Option(props: {
)
}
export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => void }> = (props) => {
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK()
const serverSDK = useServerSDK()
const language = useLanguage()
const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id)
const questions = createMemo(() =>
props.request.fields.filter(questionField).map((field) => ({
field,
header: field.title ?? "",
question: field.description ?? field.title ?? "",
options: field.type === "string" ? (field.options ?? []) : field.options,
multiple: field.type === "multiselect",
})),
)
const questions = createMemo(() => props.request.questions)
const total = createMemo(() => questions().length)
const cached = cache.get(cacheKey)
const [store, setStore] = createStore({
tab: cached?.tab ?? 0,
answers: cached?.answers ?? ([] as string[][]),
answers: cached?.answers ?? ([] as QuestionAnswer[]),
custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
@@ -171,7 +157,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
if (store.customOn[tab] === true) return list.length
return Math.max(
0,
list.findIndex((item) => store.answers[tab]?.includes(item.value) ?? false),
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
)
}
@@ -237,8 +223,8 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
}
const replyMutation = useMutation(() => ({
mutationFn: (answer: FormAnswer) =>
sdk().api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
mutationFn: (answers: QuestionAnswer[]) =>
sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }),
onMutate: () => {
props.onSubmit()
},
@@ -250,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => sdk().api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
@@ -263,26 +249,17 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
const reply = (answer: FormAnswer) => {
const reply = async (answers: QuestionAnswer[]) => {
if (sending()) return
replyMutation.mutate(answer)
await replyMutation.mutateAsync(answers)
}
const reject = () => {
const reject = async () => {
if (sending()) return
rejectMutation.mutate()
await rejectMutation.mutateAsync()
}
const submit = () =>
reply(
Object.fromEntries(
questions().flatMap((question, index) => {
const answers = store.answers[index] ?? []
if (answers.length === 0) return []
return [[question.field.key, question.multiple ? answers : answers[0]]]
}),
),
)
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
const answered = (i: number) => {
if ((store.answers[i]?.length ?? 0) > 0) return true
@@ -348,7 +325,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
if (event.key === "Escape") {
event.preventDefault()
reject()
void reject()
return
}
@@ -401,10 +378,10 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
if (!opt) return
if (multi()) {
setStore("editing", false)
toggle(opt.value)
toggle(opt.label)
return
}
pick(opt.value)
pick(opt.label)
}
const commitCustom = () => {
@@ -572,7 +549,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
{(opt, i) => (
<Option
multi={multi()}
picked={picked(opt.value)}
picked={picked(opt.label)}
label={opt.label}
description={opt.description}
disabled={sending()}
@@ -1,4 +1,5 @@
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
function sessionTreeRequest<T>(
session: SessionInfo[],
@@ -42,10 +43,11 @@ export function sessionPermissionRequest(
return sessionTreeRequest(session, request, sessionID, include)
}
export function sessionQuestionForm(
export function sessionQuestionRequest(
session: SessionInfo[],
request: Record<string, FormInfo[] | undefined>,
request: Record<string, QuestionRequest[] | undefined>,
sessionID?: string,
include?: (item: QuestionRequest) => boolean,
) {
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
return sessionTreeRequest(session, request, sessionID, include)
}

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