Compare commits

..

5 Commits

Author SHA1 Message Date
opencode-agent[bot] 1288161528 chore: generate 2026-08-13 07:18:58 +00:00
Luke Parker 154f298fe9 fix(app): unify v2 server and session lifecycle (#41930) 2026-08-13 17:17:11 +10:00
Luke Parker 75c82e046b feat(app): redesign non-modal settings (#40845) 2026-08-13 16:52:07 +10:00
Dax Raad aec4b711f6 ci: make v2 publishing manual 2026-08-13 01:13:11 -04:00
Dax Raad 721f9bfcff docs: remove beta data deletion warnings 2026-08-13 00:57:12 -04:00
87 changed files with 4141 additions and 1179 deletions
-1
View File
@@ -6,7 +6,6 @@ on:
branches:
- ci
- dev
- v2
- beta
- fix/npm-native-binary-install
- snapshot-*
@@ -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, SessionStatus } from "@opencode-ai/client/promise"
import type { SessionInfo, SessionMessageInfo, 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,6 +90,8 @@ export async function setupTimeline(
page: Page,
input: {
messages?: TimelineMessage[]
currentMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean>
sessions?: Session[]
cpuRate?: number
@@ -102,13 +104,22 @@ export async function setupTimeline(
} = {},
) {
const sessions = input.sessions ?? [session()]
const messages = validateTimelineMessages([
...(input.seedHistory ? historyMessages(18) : []),
...(input.messages ?? [userMessage(), assistantMessage()]),
])
const active = messages.findLast((message) => message.info.role === "assistant")
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 initialStatus = decodeStatus(
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
active &&
("info" in active
? active.info.role === "assistant" && active.info.time.completed === undefined
: active.type === "assistant" && active.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"}`
@@ -122,7 +133,7 @@ export async function setupTimeline(
project: project(),
provider: provider(),
sessions,
sessionStatus: { [sessionID]: initialStatus },
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus },
pageMessages: () => ({
items: messages,
}),
@@ -122,6 +122,7 @@ 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,6 +1,7 @@
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"
@@ -78,6 +79,8 @@ 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()
@@ -85,7 +88,6 @@ 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: {} })
@@ -142,7 +144,3 @@ 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,6 +13,8 @@ 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)
@@ -46,6 +48,7 @@ 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,
@@ -181,7 +184,6 @@ 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 },
@@ -325,7 +327,3 @@ 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,18 +11,23 @@ const title = "Request dock regression"
test("shows a pending question dock", async ({ page }) => {
await mockServer(page, {
questions: [
forms: [
{
id: "question-request",
id: "frm_question_request",
sessionID,
questions: [
title: "Questions",
metadata: { kind: "question" },
fields: [
{
header: "Implementation",
question: "Which implementation should be used?",
key: "q0",
type: "string",
title: "Implementation",
description: "Which implementation should be used?",
options: [
{ label: "Minimal", description: "Use the smallest correct change" },
{ label: "Extended", description: "Include additional behavior" },
{ value: "minimal", label: "Minimal", description: "Use the smallest correct change" },
{ value: "extended", label: "Extended", description: "Include additional behavior" },
],
custom: true,
},
],
},
@@ -42,7 +47,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}/question/question-request/reject`)
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
rejectRequests.push(request.url())
})
@@ -67,10 +72,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}/question/question-request/reply`,
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`,
)
await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } })
})
test("shows a pending permission dock", async ({ page }) => {
@@ -109,7 +114,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, { questions: [] })
await mockServer(page, { forms: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
@@ -135,18 +140,24 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.send({
directory,
payload: {
type: "question.asked",
type: "form.created",
properties: {
id: "question-caret",
sessionID,
questions: [
{
header: "Continue",
question: "Continue?",
options: [{ label: "Yes", description: "Continue the session" }],
},
],
tool: { messageID: "message-caret", callID: "call-caret" },
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,
},
],
},
},
},
})
@@ -156,7 +167,10 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.send({
directory,
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
payload: {
type: "form.cancelled",
properties: { sessionID, id: "frm_question_caret" },
},
})
await expect(question).toHaveCount(0)
await expect(editor).toBeVisible()
@@ -170,6 +184,8 @@ async function mockServer(
requests: {
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
sessionStatus?: Record<string, unknown>
},
) {
await mockOpenCodeServer(page, {
@@ -214,6 +230,8 @@ 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 } }))
@@ -0,0 +1,184 @@
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,15 +32,110 @@ 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,
@@ -195,6 +290,11 @@ 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: [] })
@@ -280,6 +380,18 @@ 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": "*" } })
}
+15 -12
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 { TargetSessionRouteContent } from "@/pages/session"
import { SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -93,11 +93,16 @@ function TargetServerRoute(props: ParentProps) {
)
}
const TargetSessionRoute = () => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)
function TargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</SessionRouteErrorBoundary>
)
}
// 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.
@@ -448,12 +453,10 @@ export function AppInterface(props: {
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const ServerShell = (shellProps: ParentProps) => (
<QueryProvider>
<SharedProviders>
{props.children}
{shellProps.children}
</SharedProviders>
</QueryProvider>
<SharedProviders>
{props.children}
{shellProps.children}
</SharedProviders>
)
return (
@@ -20,6 +20,7 @@ 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"
@@ -140,7 +141,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
const settings = useSettings()
if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
@@ -162,7 +163,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
return [{ id: CUSTOM_ID, name: customLabel() }, ...integrations.list()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -208,7 +209,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
}
function ProviderPickerV2(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const language = useLanguage()
const [store, setStore] = createStore({
filter: "",
@@ -220,7 +221,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
const all = createMemo(() => {
language.locale()
const query = store.filter.trim().toLowerCase()
const values = [custom(), ...providers.all().values()]
const values = [custom(), ...integrations.list()]
if (!query) return values
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
})
@@ -369,9 +370,6 @@ function ProviderConnection(props: {
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,
@@ -385,6 +383,14 @@ 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")
@@ -0,0 +1,238 @@
.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,156 +1,223 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { Field } from "@opencode-ai/ui/v2/field-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
import { 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 } from "solid-js"
import { For, Show, createSignal, startTransition } 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 { getProjectAvatarSource } from "@/pages/layout/helpers"
import { displayName, 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 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>
<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"
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}
<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"
/>
<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>
<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>
<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")}
<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>
<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"
<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>
<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"
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,
)
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
}}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
<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>
</div>
</div>
</Show>
<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>
<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>
<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>
</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 { useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
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 providers = useProviders(directory)
const integrations = useIntegrations(directory)
const language = useLanguage()
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
const currentKey = createMemo(() => {
@@ -127,7 +127,8 @@ 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={[...providers.popular()]
each={integrations
.list()
.filter((provider) => featuredProviders.includes(provider.id))
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
>
@@ -0,0 +1,238 @@
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,6 +238,7 @@ export function createProviderConnectionController(options: {
return {
loading: () => integration.loading,
integration: () => integration.latest,
methods,
currentMethod,
methodIndex: () => store.methodIndex,
@@ -452,7 +452,10 @@ function SettingsKeybindsV2View(props: {
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<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}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
@@ -0,0 +1,29 @@
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>
)
}
@@ -0,0 +1,134 @@
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,15 +5,22 @@ 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
@@ -25,8 +32,13 @@ 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") {
@@ -52,62 +64,92 @@ export const DialogSettings: Component<{
>
<TabsV2.List>
<div class="flex flex-col justify-between h-full w-full">
<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>
<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-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>
{/* 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>
</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="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
<TabsV2.Content value="projects" class="settings-v2-panel">
<SettingsProjectsV2 />
</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>
)
@@ -0,0 +1,156 @@
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,8 +279,6 @@ 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(
@@ -298,6 +296,7 @@ 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 />
@@ -510,18 +509,19 @@ export const SettingsGeneralV2: Component<{
return (
<>
<div class="settings-v2-tab-header">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
<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>
</div>
<div class="settings-v2-tab-body">
<GeneralSection />
<AppearanceSection controller={appearance} />
<NotificationsSection />
<SoundsSection controller={sounds} />
<Show when={desktop()}>
<UpdatesSection />
</Show>
@@ -11,6 +11,7 @@ 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"
@@ -53,7 +54,13 @@ export const SettingsModelsV2: Component = () => {
return (
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
<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>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
@@ -0,0 +1,124 @@
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>
</>
)
}
@@ -0,0 +1,49 @@
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>
)
}
@@ -0,0 +1,139 @@
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>
</>
)
}
@@ -10,6 +10,8 @@ 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"
@@ -42,13 +44,23 @@ export const SettingsProvidersV2: Component<{
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
void dialog.show(() => (
<SettingsServerScope directory={props.directory}>
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
</SettingsServerScope>
))
}
const connected = createMemo(() => {
return providers
.connected()
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
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
}),
)
})
const popular = createMemo(() => {
@@ -92,29 +104,6 @@ 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
await serverSdk()
@@ -141,7 +130,13 @@ export const SettingsProvidersV2: Component<{
return (
<>
<div class="settings-v2-tab-header">
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
<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>
</div>
<div class="settings-v2-tab-body settings-v2-providers">
@@ -244,7 +239,11 @@ export const SettingsProvidersV2: Component<{
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
dialog.show(() => (
<SettingsServerScope directory={props.directory}>
<DialogCustomProvider onBack={dialog.close} />
</SettingsServerScope>
))
}}
>
{language.t("common.connect")}
@@ -53,7 +53,10 @@ export const SettingsServersV2: Component = () => {
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
>
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<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>
<AddServerMenu onAddServer={openAdd} />
</div>
<Show when={showSearch()}>
@@ -39,6 +39,14 @@
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;
@@ -174,13 +182,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;
@@ -727,3 +735,24 @@
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;
}
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src/new.ts",
event: "add",
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src/open.ts",
event: "change",
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src",
event: "change",
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src/file.ts",
event: "change",
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
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 !== "file.watcher.updated") return
if (event.type !== "filesystem.changed") 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
+130 -114
View File
@@ -6,6 +6,8 @@ import type {
CommandInfo,
CommandListInput,
CommandListOutput,
IntegrationListInput,
IntegrationListOutput,
LocationGetInput,
LocationGetOutput,
PermissionRequest,
@@ -24,9 +26,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, VcsCache } from "./types"
import type { State } from "./types"
import type { ServerSession } from "../server-session"
import { cmp, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
@@ -221,6 +223,10 @@ type CommandListApi = {
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
}
type IntegrationListApi = {
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
}
type ReferenceListApi = {
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
}
@@ -231,6 +237,13 @@ 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))
@@ -272,7 +285,6 @@ 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: {
@@ -297,104 +309,109 @@ export async function bootstrapDirectory(input: {
const revKey = ScopedKey.from(input.scope, input.directory)
const rev = (providerRev.get(revKey) ?? 0) + 1
providerRev.set(revKey, rev)
;(async () => {
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
.then((data) => input.setStore("agent", data)),
!seededProject &&
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.setStore("project", project.id),
)),
!seededPath &&
(() =>
input.queryClient
.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) => {
.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) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
@@ -402,20 +419,19 @@ 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,6 +8,7 @@ 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],
@@ -58,11 +59,17 @@ 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 provider
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
return undefined
},
}
@@ -83,6 +90,7 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -114,6 +122,7 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -148,6 +157,7 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -173,6 +183,37 @@ 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
@@ -181,6 +222,7 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -230,6 +272,7 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -273,4 +316,71 @@ 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 Owner } from "solid-js"
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, 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,6 +22,7 @@ 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
@@ -188,17 +189,29 @@ export function createChildStoreManager(input: {
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
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 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 providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: instanceQueriesEnabled(),
enabled: input.connected() && instanceQueriesEnabled(),
}))
const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key),
enabled: instanceQueriesEnabled(),
enabled: input.connected() && instanceQueriesEnabled(),
}))
const child = createStore<State>({
@@ -206,13 +219,14 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return instanceQueriesEnabled() && !providerQuery.isLoading
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError)
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
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
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
},
config: {},
get path() {
@@ -373,6 +387,15 @@ 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,
@@ -380,6 +403,7 @@ export function createChildStoreManager(input: {
peek,
projectMeta,
projectIcon,
vcs,
mark,
pin,
unpin,
@@ -120,7 +120,7 @@ describe("applyGlobalEvent", () => {
expect(refreshCount).toBe(1)
})
test("handles server.connected by triggering refresh", () => {
test("leaves server.connected refresh to the connection sync", () => {
let refreshCount = 0
applyGlobalEvent({
event: { type: "server.connected" },
@@ -131,7 +131,7 @@ describe("applyGlobalEvent", () => {
setGlobalProject() {},
})
expect(refreshCount).toBe(1)
expect(refreshCount).toBe(0)
})
})
@@ -38,11 +38,10 @@ export function applyGlobalEvent(input: {
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
if (input.event.type === "global.disposed") {
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)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Todo } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -34,6 +34,7 @@ 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 },
@@ -44,6 +45,7 @@ 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" },
}
@@ -57,6 +59,7 @@ 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", () => {
@@ -70,6 +73,7 @@ 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: {},
@@ -80,6 +84,7 @@ 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 { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FormInfo, 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,6 +14,7 @@ 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>
}
@@ -38,6 +39,7 @@ 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,7 +7,6 @@ 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({
@@ -98,15 +97,6 @@ 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)
@@ -141,7 +131,6 @@ function createServerCtx(
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
return {
queryClient,
sdk,
sync,
isLocal,
+2
View File
@@ -33,6 +33,8 @@ 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) {
+9 -4
View File
@@ -398,15 +398,20 @@ function createServerNotificationState(input: {
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.execution.failed") return
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.interrupted" &&
event.type !== "session.execution.failed"
)
return
const directory = e.name
const time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
if (event.type === "session.execution.failed") {
handleSessionError(directory, event, time)
return
}
handleSessionError(directory, event, time)
handleSessionIdle(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
+133 -52
View File
@@ -3,7 +3,8 @@ import type { Event } from "@/types"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, createMemo, onCleanup, onMount } from "solid-js"
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"
@@ -12,10 +13,6 @@ 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<
@@ -95,15 +92,20 @@ 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
}
}
@@ -130,14 +132,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
const CONNECT_TIMEOUT_MS = 2_000
const RECONNECT_DELAY_MS = 1_000
let queue: Queued[] = []
let buffer: Queued[] = []
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const flush = () => {
function flush() {
if (timer) clearTimeout(timer)
timer = undefined
@@ -157,63 +160,132 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
buffer.length = 0
}
const schedule = () => {
function schedule() {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
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 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 })
const start = () => {
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() {
if (started) return run
started = true
const active = ++generation
const previous = run
const current = (async () => {
if (previous) await previous
// 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)
}
await runStream(active)
})().finally(() => {
if (run !== current) return
run = undefined
@@ -223,7 +295,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
return run
}
const stop = () => {
function stop() {
started = false
generation++
attempt?.abort()
@@ -232,12 +304,17 @@ 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()
flush()
if (timer) clearTimeout(timer)
timer = undefined
queue = []
buffer = []
emitter.clear()
})
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
@@ -247,10 +324,14 @@ 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,
},
}
}
@@ -211,6 +211,58 @@ describe("v2 session reducer", () => {
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(
@@ -1,4 +1,4 @@
import type { OpenCodeEvent, SessionInboxItem, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -8,13 +8,18 @@ export type V2SessionReduction = {
sessionID: string
messages: SessionMessageInfo[]
touched: string[]
removed?: string[]
missing?: string
}
export function createV2SessionReducer() {
const pending = new Map<string, SessionInboxItem>()
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
const reduce = (
source: readonly SessionMessageInfo[],
event: OpenCodeEvent,
session?: Pick<SessionInfo, "location" | "projectID" | "subpath">,
): 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 => ({
@@ -28,13 +33,39 @@ export function createV2SessionReducer() {
switch (event.type) {
case "session.inbox.enqueued":
pending.set(key(sessionID, event.data.inboxID), event.data.item)
return result([...source])
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
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 }
if (input.type === "user")
return append({
@@ -84,6 +115,32 @@ 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),
@@ -403,8 +460,11 @@ export function createV2SessionReducer() {
type: "compaction",
status: "failed",
metadata: current?.metadata ?? event.metadata,
reason: event.data.reason,
error: event.data.error,
reason: event.data.reason ?? "manual",
error: event.data.error ?? {
type: "compaction.failed",
message: "Compaction failed before recording an error",
},
time: current?.time ?? { created: event.created },
}
if (!current) return append(failed)
@@ -1,8 +1,10 @@
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,
@@ -348,6 +350,187 @@ 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") })
@@ -368,6 +551,17 @@ 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 } }
+142 -8
View File
@@ -1,7 +1,14 @@
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 { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
FormInfo,
OpenCodeEvent,
SessionApi,
SessionInfo,
SessionInboxInfo,
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"
@@ -192,6 +199,9 @@ 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[]>,
@@ -205,6 +215,11 @@ 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>>()
@@ -263,6 +278,9 @@ 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),
@@ -464,6 +482,7 @@ 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)
@@ -507,6 +526,9 @@ 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),
@@ -798,13 +820,16 @@ export function createServerSession(
touch(sessionID)
return runInflight(inflight, sessionID, async () => {
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
if (cached && data.info[sessionID] && !options?.force) return
const invalid = invalidated.has(sessionID)
const revision = invalidationRevision
if (cached && data.info[sessionID] && !invalid && !options?.force) return
await Promise.all([
resolve(sessionID, options),
cached && !options?.force
resolve(sessionID, invalid ? { ...options, force: true } : options),
cached && !invalid && !options?.force
? Promise.resolve()
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
])
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
})
}
@@ -844,7 +869,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) return
if (reduction.touched.length === 0 && !reduction.removed?.length) return
const touched = new Set(reduction.touched)
let parentID: string | undefined
@@ -861,6 +886,9 @@ 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 } })
@@ -884,9 +912,14 @@ 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] })
@@ -895,6 +928,18 @@ 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
@@ -908,19 +953,61 @@ export function createServerSession(
}
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
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)
if (reduction) {
projectV2(reduction)
if (reduction.missing) hydrateV2Message(sessionID, reduction.missing)
}
const info = data.info[sessionID]
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_"))
}
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 ?? info.projectID,
projectID: event.data.projectID,
location: event.data.location,
subpath: event.data.subpath,
time: { ...info.time, updated: event.created },
@@ -946,12 +1033,29 @@ 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 }) => {
@@ -1280,6 +1384,36 @@ 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
+18 -1
View File
@@ -9,7 +9,13 @@ 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, seedActiveSessionStatuses } from "./server-sync"
import {
loadActiveSessionsQuery,
loadMcpQuery,
loadMcpResourcesQuery,
reconcileActiveSessionStatuses,
seedActiveSessionStatuses,
} from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server"
@@ -99,6 +105,17 @@ 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", () => {
+219 -110
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, onMount, untrack } from "solid-js"
import { batch, createMemo, getOwner, onCleanup, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { ServerSDK } from "./server-sdk"
import { type ServerEvent, type ServerSDK } from "./server-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
@@ -13,6 +13,7 @@ import {
loadAgentsQuery,
loadCommands,
loadGlobalConfigQuery,
loadIntegrationsQuery,
loadPathQuery,
loadProjectsQuery,
loadProvidersQuery,
@@ -29,7 +30,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 } from "@/utils/path-key"
import { pathKey, 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"
@@ -53,6 +54,8 @@ 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 = {
@@ -66,6 +69,16 @@ 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>
}
@@ -159,11 +172,22 @@ 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),
@@ -184,29 +208,46 @@ 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(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
queries: [
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
{ ...queryOptionsApi.providers(null), enabled: connected() },
{ ...queryOptionsApi.path(null), enabled: connected() },
],
}))
const activeSessionsQuery = useQuery(() =>
loadActiveSessionsQuery(serverSDK.scope, {
const activeSessionsQuery = useQuery(() => ({
...loadActiveSessionsQuery(serverSDK.scope, {
active: async () => {
const active = await serverSDK.api.session.active()
seedActiveSessionStatuses(session, active)
for (const sessionID of Object.keys(active)) {
void session.resolve(sessionID).catch(() => undefined)
}
reconcileActiveSessionStatuses(session, active)
Object.keys(active).forEach((sessionID) => {
void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined)
})
return active
},
}),
)
enabled: connected(),
}))
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
get ready() {
return !bootstrap.isPending
return bootstrap.isSuccess
},
project: [],
provider_auth: {},
@@ -231,21 +272,6 @@ 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)
}
@@ -270,9 +296,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
setGlobalStore: setBootStore,
queryClient,
})
bootedAt = Date.now()
return bootedAt
return Date.now()
},
enabled: connected(),
}))
const set = ((...input: unknown[]) => {
@@ -294,11 +320,13 @@ 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) => {
@@ -321,9 +349,60 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
translate: language.t,
queryOptions: queryOptionsApi,
global: {
provider: globalStore.provider,
get provider() {
return 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)
@@ -354,7 +433,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.fetchQuery({
...queryOptionsApi.sessions(key),
queryFn: () =>
loadRootSessions({ api: serverSDK.api.session, directory, limit })
loadCurrentSessions(directory, key, limit)
.then((x) => {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
@@ -410,27 +489,28 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
children.pin(key)
const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory)
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,
})
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(),
])
})
booting.set(key, promise)
@@ -457,14 +537,43 @@ 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 recent = bootingRoot || Date.now() - bootedAt < 1500
const previousDirectory =
event.current?.type === "session.moved"
? session.get(event.current.data.sessionID)?.location.directory
: undefined
markSessionListChanged(event, directory, previousDirectory)
if (event.current) session.applyV2(event.current)
session.apply(event)
if (event.current?.type === "session.created")
@@ -487,35 +596,41 @@ 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)
if (eventType === "integration.connection.updated") void refreshProviders()
catalog.handleEvent({ type: eventType, directory })
connection.handleEvent({ type: eventType, directory })
if (directory === "global") {
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
void activeSessionsQuery.refetch()
applyGlobalEvent({
event,
project: globalStore.project,
refresh: () => {
if (recent) return
bootstrap.refetch()
},
refresh: () => void bootstrap.refetch(),
setGlobalProject: setProjects,
})
if (
eventType === "config.updated" ||
eventType === "catalog.updated" ||
eventType === "agent.updated" ||
eventType === "project.directories.updated"
)
bootstrap.refetch()
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)
}
}
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
return
}
@@ -536,7 +651,6 @@ 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"
)
@@ -544,27 +658,39 @@ 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
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))
},
})
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))
},
})
})
onCleanup(unsub)
@@ -577,23 +703,6 @@ 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) {
@@ -634,11 +743,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
peek: children.peek,
disableMcp: children.disableMcp,
queryOptions: queryOptionsApi,
refreshProviders,
refreshProviders: catalog.refreshActive,
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
session,
session: Object.assign(session, { hydrate: hydrateSession }),
homeSessions,
mcp: {
toggle: async (directory: string, name: string) => {
@@ -0,0 +1,51 @@
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)
})
@@ -0,0 +1,53 @@
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,
}
}
@@ -0,0 +1,23 @@
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()
})
@@ -0,0 +1,22 @@
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 }
}
@@ -0,0 +1,21 @@
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 : []),
}
}
+6 -4
View File
@@ -1,6 +1,4 @@
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"
@@ -19,8 +17,7 @@ const popularProviderSet = new Set(popularProviders)
export function useProviders(directory: Accessor<string | undefined>) {
const serverSync = useServerSync()
const params = useParams()
const dir = () => (directory ? directory() : decode64(params.dir))
const dir = directory
const providers = () => {
const value = dir()
const projectStore = value ? serverSync().child(value)[0] : undefined
@@ -39,6 +36,11 @@ 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: () =>
+46 -3
View File
@@ -673,6 +673,17 @@ 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",
@@ -892,14 +903,46 @@ 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": "System notifications",
"settings.general.section.notifications": "Desktop notifications",
"settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects",
"settings.general.section.feed": "Feed",
@@ -1060,7 +1103,7 @@ export const dict = {
"settings.shortcuts.group.prompt": "Prompt",
"settings.providers.title": "Providers",
"settings.providers.description": "Provider settings will be configurable here.",
"settings.providers.description": "Connect and manage model providers",
"settings.providers.section.connected": "Connected providers",
"settings.providers.connected.empty": "No connected providers",
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
@@ -1071,7 +1114,7 @@ export const dict = {
"settings.providers.tag.custom": "Custom",
"settings.providers.tag.other": "Other",
"settings.models.title": "Models",
"settings.models.description": "Model settings will be configurable here.",
"settings.models.description": "Choose which models appear in model picker",
"settings.agents.title": "Agents",
"settings.agents.description": "Agent settings will be configurable here.",
"settings.commands.title": "Commands",
-230
View File
@@ -1,230 +0,0 @@
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))
}
+3 -2
View File
@@ -25,6 +25,7 @@ export function DirectoryDataProvider(
const params = useParams()
const sync = useSync()
const serverSync = useServerSync()
const language = useLanguage()
const directory = () => props.directory
const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => {
@@ -44,8 +45,8 @@ export function DirectoryDataProvider(
createResource(
() => params.id,
(id) =>
sync()
.session.sync(id)
serverSync()
.session.hydrate(id)
.catch(() => {}),
)
@@ -60,7 +60,7 @@ export function createHomeSessionsController(home: HomeController) {
}))
const sessionLoad = useQuery(() => ({
queryKey: homeSessions().indexKey,
enabled: !!home.server.focusedContext(),
enabled: home.server.focusedContext()?.sdk.connection.status() === "connected",
queryFn: async ({ signal }) => {
const ctx = home.server.focusedContext()
if (!ctx) return { sessions: [], eventSequence: 0 }
@@ -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, sessionQuestionRequest } from "@/pages/session/composer/session-request-tree"
import { sessionPermissionRequest, sessionQuestionForm } 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 !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId())
return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const notificationState = createMemo(() => {
+24 -11
View File
@@ -651,11 +651,17 @@ export default function Page() {
})
const vcsKey = createMemo(
() =>
["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const,
[
serverSDK().scope,
"session-vcs",
sdk().directory,
sync().data.vcs?.branch ?? "",
sync().data.vcs?.default_branch ?? "",
] as const,
)
const vcsQuery = createQuery(() => {
const mode = vcsMode()
const enabled = wantsReview() && sync().project?.vcs === "git"
const enabled = serverSDK().connection.status() === "connected" && wantsReview() && sync().project?.vcs === "git"
return {
queryKey: [...vcsKey(), mode] as const,
@@ -667,14 +673,17 @@ 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)
const refreshVcs = debounce(() => {
void queryClient.invalidateQueries({ queryKey: vcsKey() })
}, 100)
onCleanup(
sdk().event.listen((event) => {
if (event.details.type === "filesystem.changed") refreshVcs()
}),
)
createEffect(
on(
() => desktopReviewOpen() || mobileChanges(),
@@ -718,7 +727,7 @@ export default function Page() {
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const,
queryKey: [...vcsKey(), mode, "directory", scope, context, version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () =>
@@ -1108,6 +1117,10 @@ export default function Page() {
useComposerCommands()
useSessionCommands({
session: controller,
background: {
blocking: () => composer.background.blocking().length > 0,
move: composer.background.move,
},
navigateMessageByOffset,
setActiveMessage,
focusInput,
@@ -1469,6 +1482,8 @@ export default function Page() {
working: () => true,
overflowAnchor: "none",
})
const shouldAnchorBottom = () =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
createEffect(
on(
() => controller.identity.params.id,
@@ -2077,9 +2092,7 @@ export default function Page() {
onUserScroll={markUserScroll}
onHistoryScroll={onHistoryScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
shouldAnchorBottom={shouldAnchorBottom()}
centered={centered()}
setContentRef={(el) => {
content = el
@@ -0,0 +1,84 @@
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" | "providers">
queryOptions: Pick<QueryOptionsApi, "agents">
model?: ModelSelection
}) {
const layout = useLayout()
@@ -30,8 +30,6 @@ 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 {
@@ -46,10 +44,7 @@ export function createPromptInputController(input: {
model: {
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
(local.agent.visible() && agentsQuery.isLoading) ||
providersQuery.isLoading ||
globalProvidersQuery.isLoading,
loading: (local.agent.visible() && agentsQuery.isLoading) || !providers.ready(),
},
session: {
id: input.sessionID(),
@@ -0,0 +1,157 @@
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,6 +6,7 @@ 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: {
@@ -15,6 +16,8 @@ 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
@@ -123,12 +126,21 @@ 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": `${-controller.lift()}px`,
"margin-top": `${background() ? -36 : -controller.lift()}px`,
}}
>
<Show when={controller.followup()?.items.length}>
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { todoDockAtBoundary, todoState } from "./session-composer-state"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -19,8 +19,10 @@ const question = (id: string, sessionID: string) =>
({
id,
sessionID,
questions: [],
}) as QuestionRequest
title: "Questions",
metadata: { kind: "question" },
fields: [{ key: "q0", type: "string" }],
}) as FormInfo
describe("sessionPermissionRequest", () => {
test("prefers the current session permission", () => {
@@ -80,7 +82,7 @@ describe("sessionPermissionRequest", () => {
})
})
describe("sessionQuestionRequest", () => {
describe("sessionQuestionForm", () => {
test("prefers the current session question", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const questions = {
@@ -88,7 +90,7 @@ describe("sessionQuestionRequest", () => {
child: [question("q-child", "child")],
}
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-root")
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-root")
})
test("returns a nested child question", () => {
@@ -101,7 +103,16 @@ describe("sessionQuestionRequest", () => {
grand: [question("q-grand", "grand")],
}
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-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()
})
})
@@ -1,15 +1,17 @@
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { Todo } from "@/types"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
import type { FormInfo, PermissionRequest } 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, sessionQuestionRequest } from "./session-request-tree"
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
import { createQuery, useQueryClient } from "@tanstack/solid-query"
export const todoState = (input: {
count: number
@@ -31,11 +33,33 @@ 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((): QuestionRequest | undefined => {
return sessionQuestionRequest(sync().data.session, sync().data.question, params.id)
const questionRequest = createMemo((): FormInfo | undefined => {
return sessionQuestionForm(sync().data.session, serverSync().session.data.form, params.id)
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
@@ -61,6 +85,119 @@ 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,
@@ -191,6 +328,11 @@ 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 { QuestionAnswer, QuestionRequest } from "@opencode-ai/client/promise"
import type { FormAnswer, FormInfo, FormMultiselectField, FormStringField } from "@opencode-ai/client/promise"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -14,7 +14,13 @@ 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: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
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"
}
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
return (
@@ -61,19 +67,27 @@ function Option(props: {
)
}
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
export const SessionQuestionDock: Component<{ request: FormInfo; 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.questions)
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 total = createMemo(() => questions().length)
const cached = cache.get(cacheKey)
const [store, setStore] = createStore({
tab: cached?.tab ?? 0,
answers: cached?.answers ?? ([] as QuestionAnswer[]),
answers: cached?.answers ?? ([] as string[][]),
custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
@@ -157,7 +171,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
if (store.customOn[tab] === true) return list.length
return Math.max(
0,
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
list.findIndex((item) => store.answers[tab]?.includes(item.value) ?? false),
)
}
@@ -223,8 +237,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
const replyMutation = useMutation(() => ({
mutationFn: (answers: QuestionAnswer[]) =>
sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }),
mutationFn: (answer: FormAnswer) =>
sdk().api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
onMutate: () => {
props.onSubmit()
},
@@ -236,7 +250,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }),
mutationFn: () => sdk().api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
@@ -249,17 +263,26 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
const reply = async (answers: QuestionAnswer[]) => {
const reply = (answer: FormAnswer) => {
if (sending()) return
await replyMutation.mutateAsync(answers)
replyMutation.mutate(answer)
}
const reject = async () => {
const reject = () => {
if (sending()) return
await rejectMutation.mutateAsync()
rejectMutation.mutate()
}
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
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 answered = (i: number) => {
if ((store.answers[i]?.length ?? 0) > 0) return true
@@ -325,7 +348,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
if (event.key === "Escape") {
event.preventDefault()
void reject()
reject()
return
}
@@ -378,10 +401,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
if (!opt) return
if (multi()) {
setStore("editing", false)
toggle(opt.label)
toggle(opt.value)
return
}
pick(opt.label)
pick(opt.value)
}
const commitCustom = () => {
@@ -549,7 +572,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
{(opt, i) => (
<Option
multi={multi()}
picked={picked(opt.label)}
picked={picked(opt.value)}
label={opt.label}
description={opt.description}
disabled={sending()}
@@ -1,5 +1,4 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
function sessionTreeRequest<T>(
session: SessionInfo[],
@@ -43,11 +42,10 @@ export function sessionPermissionRequest(
return sessionTreeRequest(session, request, sessionID, include)
}
export function sessionQuestionRequest(
export function sessionQuestionForm(
session: SessionInfo[],
request: Record<string, QuestionRequest[] | undefined>,
request: Record<string, FormInfo[] | undefined>,
sessionID?: string,
include?: (item: QuestionRequest) => boolean,
) {
return sessionTreeRequest(session, request, sessionID, include)
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
}
@@ -1,17 +1,11 @@
import type { Todo } from "@/types"
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
import { Checkbox } from "@opencode-ai/ui/checkbox"
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 { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Index, Match, Switch, createEffect, createMemo } from "solid-js"
import { Dynamic } from "solid-js/web"
import { Index, Match, Switch, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SessionComposerPullout } from "./session-composer-pullout"
const doneToken = "\u0000done\u0000"
const totalToken = "\u0000total\u0000"
@@ -50,10 +44,6 @@ export function SessionTodoDock(props: {
dockProgress: number
}) {
const language = useLanguage()
const settings = useSettings()
const [store, setStore] = createStore({
height: 78,
})
const total = createMemo(() => props.todos.length)
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
@@ -73,147 +63,33 @@ export function SessionTodoDock(props: {
)
const preview = createMemo(() => active()?.content ?? "")
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress)))
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 turn = createMemo(() => Math.max(0, Math.min(1, value())))
const full = createMemo(() => Math.max(78, store.height))
let contentRef: HTMLDivElement | undefined
createEffect(() => {
const el = contentRef
if (!el) return
const update = () => {
setStore("height", (height) => Math.max(height, el.scrollHeight))
}
update()
createResizeObserver(el, update)
})
return (
<Dynamic
component={settings.general.newLayoutDesigns() ? "div" : DockTray}
data-component="session-todo-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(78, full() - value() * (full() - 78))}px`,
}}
<SessionComposerPullout
name="todo"
label={
<Index each={progress()}>
{(item) => (
<Switch fallback={<span>{item()}</span>}>
<Match when={item() === doneToken}>
<AnimatedNumber value={done()} />
</Match>
<Match when={item() === totalToken}>
<AnimatedNumber value={total()} />
</Match>
</Switch>
)}
</Index>
}
ariaLabel={label()}
preview={props.collapsed ? preview() : undefined}
collapsed={props.collapsed}
onToggle={props.onToggle}
collapseLabel={props.collapseLabel}
expandLabel={props.expandLabel}
dockProgress={props.dockProgress}
>
<div ref={contentRef}>
<div
data-action="session-todo-toggle"
classList={{
"flex items-center gap-2 overflow-visible": true,
"h-[42px] pl-4 pr-2": settings.general.newLayoutDesigns(),
"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={label()}
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()))}`,
}}
>
<Index each={progress()}>
{(item) => (
<Switch fallback={<span>{item()}</span>}>
<Match when={item() === doneToken}>
<AnimatedNumber value={done()} />
</Match>
<Match when={item() === totalToken}>
<AnimatedNumber value={total()} />
</Match>
</Switch>
)}
</Index>
</span>
<div
data-slot="session-todo-preview"
class="ml-1 min-w-0 overflow-hidden"
style={{
flex: "1 1 auto",
"max-width": "100%",
}}
>
<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.collapsed ? preview() : undefined}
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>
<div class="ml-auto">
<IconButton
data-action="session-todo-toggle-button"
data-collapsed={props.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${turn() * 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-todo-list"
aria-hidden={props.collapsed || off()}
classList={{
"pointer-events-none": hide() > 0.1,
}}
style={{
visibility: off() ? "hidden" : "visible",
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
}}
>
<TodoList todos={props.todos} />
</div>
</div>
</Dynamic>
<TodoList todos={props.todos} />
</SessionComposerPullout>
)
}
@@ -54,6 +54,7 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
const emptyTools: ToolPart[] = []
const emptyAssistantMessages: AssistantMessage[] = []
@@ -240,10 +241,12 @@ function MessageTimelineView(
) {
let touchGesture: number | undefined
const language = useLanguage()
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
const hasScrollGesture = createMemo(() => props.hasScrollGesture)
const ownerSessionKey = props.data.sessionKey()
const cached = timelineCache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom
const coldBottomMount = !initialMeasurements?.length && shouldAnchorBottom()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const sessionID = props.data.sessionID
@@ -262,10 +265,46 @@ function MessageTimelineView(
const assistantMessagesByParent = projection.assistantMessagesByParent
const lastAssistantGroupKey = projection.lastAssistantGroupKey
const messageByID = projection.messageByID
const sessionMessageByID = projection.sessionMessageByID
const messageLastRowIndex = projection.messageLastRowIndex
const messageRowIndex = projection.messageRowIndex
const timelineRowByKey = projection.rowByKey
const timelineRows = projection.rows
const noticeContent = (message: SessionMessageInfo) => {
if (message.type === "agent-switched")
return {
label: language.t("ui.tool.agent.default"),
data: message.previous ? `${message.previous}${message.agent}` : message.agent,
}
if (message.type === "model-switched")
return {
label: language.t("command.category.model"),
data: `${message.model.providerID}/${message.model.id}`,
}
if (message.type === "location-switched")
return { label: language.t("ui.patch.action.moved"), data: message.location.directory }
if (message.type === "skill") return { label: language.t("ui.tool.skill"), data: message.name }
if (message.type === "system") return { label: message.description ?? message.text }
if (message.type === "compaction") return { label: language.t("ui.messagePart.compaction"), data: message.status }
if (message.type !== "synthetic") return
if (message.description === "Continuing after restart") return { label: message.description }
const source = typeof message.metadata?.source === "string" ? message.metadata.source : undefined
const state = typeof message.metadata?.state === "string" ? message.metadata.state : undefined
if (source === "subagent" || source === "shell") {
const agent = typeof message.metadata?.agent === "string" ? message.metadata.agent : undefined
const actor = source === "shell" ? language.t("ui.tool.shell") : (agent ?? language.t("ui.tool.agent.default"))
const label = language.t(
state === "error"
? "session.timeline.notice.failed"
: state === "cancelled"
? "session.timeline.notice.cancelled"
: "session.timeline.notice.finished",
{ actor },
)
return { label, data: message.description }
}
return { label: message.description ?? message.text }
}
let prependAnchor: { key: string; offset: number } | undefined
let prependAnchorFrame: number | undefined
@@ -338,7 +377,7 @@ function MessageTimelineView(
},
getScrollElement: () => listRoot() ?? null,
observeElementOffset: observeElementOffsetReconnectAware,
initialOffset: () => (props.shouldAnchorBottom ? Number.MAX_SAFE_INTEGER : 0),
initialOffset: () => (shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => timelineFallbackItemSize,
scrollToFn: (offset, options, instance) => {
@@ -376,11 +415,11 @@ function MessageTimelineView(
const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
const anchorResizedBottom = () => {
if (resizeAnchorScheduled || props.hasScrollGesture) return
if (resizeAnchorScheduled || hasScrollGesture()) return
resizeAnchorScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
if (!props.shouldAnchorBottom || props.hasScrollGesture) return
if (!shouldAnchorBottom() || hasScrollGesture()) return
virtualizer.scrollToEnd()
})
}
@@ -405,10 +444,10 @@ function MessageTimelineView(
})
}
resizeItem(index, size)
if (root && props.shouldAnchorBottom) anchorResizedBottom()
if (root && shouldAnchorBottom()) anchorResizedBottom()
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
if (props.shouldAnchorBottom) return false
if (shouldAnchorBottom()) return false
const first = virtualizer.range?.startIndex
return first !== undefined && item.index < first
}
@@ -429,18 +468,18 @@ function MessageTimelineView(
let overscanFrame: number | undefined
onMount(() => {
overscanFrame = requestAnimationFrame(() => {
if (props.shouldAnchorBottom) virtualizer.scrollToEnd()
if (shouldAnchorBottom()) virtualizer.scrollToEnd()
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
if (renderOverscan() < 20) setRenderOverscan(20)
if (props.shouldAnchorBottom) virtualizer.scrollToEnd()
if (shouldAnchorBottom()) virtualizer.scrollToEnd()
})
})
})
const maybeAnchorBottom = () => {
if (timelineRows().length === 0) return
if (!props.shouldAnchorBottom || props.hasScrollGesture) return
if (!shouldAnchorBottom() || hasScrollGesture()) return
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
@@ -816,6 +855,25 @@ function MessageTimelineView(
</TimelineRowFrame>
)
}
case "Notice": {
const noticeRow = row as Accessor<TimelineRowByTag<"Notice">>
const content = createMemo(() => {
const message = sessionMessageByID().get(noticeRow().messageID)
return message ? noticeContent(message) : undefined
})
return (
<TimelineRowFrame row={noticeRow()}>
<Show when={content()}>
{(content) => (
<div data-slot="session-timeline-notice" class="w-full px-4 pt-3 pb-1 md:px-5 text-13-regular">
<span class="text-13-medium text-text-strong">{content().label}</span>
<Show when={content().data}>{(data) => <span class="text-text-weak"> · {data()}</span>}</Show>
</div>
)}
</Show>
</TimelineRowFrame>
)
}
case "TurnDivider": {
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
return (
@@ -16,6 +16,9 @@ export function createTimelineProjection(input: {
inlineComments: Accessor<boolean>
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const sessionMessageByID = createMemo(
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
)
const assistantMessagesByParent = createMemo(() => {
const result = new Map<string, AssistantMessage[]>()
input.messages().forEach((message) => {
@@ -77,5 +80,6 @@ export function createTimelineProjection(input: {
messageLastRowIndex,
rowByKey,
rows,
sessionMessageByID,
}
}
@@ -92,6 +92,84 @@ describe("current session timeline rows", () => {
])
})
test("keeps CLI notice messages between the assistant steps they surround", () => {
const source = [
{ id: "msg_user", type: "user", text: "run", time: { created: 1 } },
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
{
id: "msg_assistant_1",
type: "assistant",
agent: "explore",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "started" }],
time: { created: 3, completed: 4 },
},
{
id: "msg_background",
type: "synthetic",
text: "result",
description: "Search code",
metadata: { source: "subagent", agent: "explore", state: "completed" },
time: { created: 5 },
},
{
id: "msg_model",
type: "model-switched",
model: { id: "next", providerID: "provider" },
time: { created: 6 },
},
{
id: "msg_assistant_2",
type: "assistant",
agent: "explore",
model: { id: "next", providerID: "provider" },
content: [{ type: "text", text: "finished" }],
time: { created: 7, completed: 8 },
},
{
id: "msg_restart",
type: "synthetic",
text: "continue",
description: "Continuing after restart",
time: { created: 9 },
},
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 10 } },
{
id: "msg_compaction",
type: "compaction",
status: "completed",
reason: "auto",
summary: "summary",
recent: "recent",
time: { created: 11 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
true,
normalized.messages.filter((message) => message.role === "user"),
)
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user",
"notice:msg_agent",
"assistant-part:msg_user:msg_assistant_1:text:0",
"notice:msg_background",
"notice:msg_model",
"assistant-part:msg_user:msg_assistant_2:text:0",
"notice:msg_restart",
"notice:msg_skill",
"notice:msg_compaction",
])
})
test("keeps a projected parent missing from the source page before newer turns", () => {
const source = [
{ id: "msg_user_1", type: "user", text: "first question", time: { created: 1 } },
+91 -57
View File
@@ -17,6 +17,7 @@ export type TimelineRowMap = {
userMessageID: string
anchor: boolean
}
Notice: { userMessageID: string; messageID: string }
TurnDivider: {
userMessageID: string
label: "compaction" | "interrupted"
@@ -42,39 +43,57 @@ export namespace Timeline {
inlineComments: boolean,
projectedUserMessages: UserMessage[],
) {
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: AssistantMessage } | { type: "notice"; message: Notice }
const turns: { user: UserMessage; entries: Entry[] }[] = []
const turnByUserID = new Map<string, (typeof turns)[number]>()
const leading: Notice[] = []
let current: (typeof turns)[number] | undefined
messages.forEach((message) => {
if (isNotice(message)) {
if (current) current.entries.push({ type: "notice", message })
if (!current) leading.push(message)
return
}
const projected = getMessage(message.id)
if (message.type === "shell" && projected?.role === "user") {
const assistant = getMessage(`${message.id}:assistant`)
const turn = { user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] }
const turn = {
user: projected,
entries: assistant?.role === "assistant" ? [{ type: "assistant" as const, message: assistant }] : [],
}
turns.push(turn)
turnByUserID.set(projected.id, turn)
current = turn
return
}
if (projected?.role === "user") {
if (turnByUserID.has(projected.id)) return
const turn = { user: projected, assistants: [] }
const turn = { user: projected, entries: [] }
turns.push(turn)
turnByUserID.set(projected.id, turn)
current = turn
return
}
if (projected?.role !== "assistant") return
const existing = turnByUserID.get(projected.parentID)
const existing = current ?? turnByUserID.get(projected.parentID)
if (existing) {
existing.assistants.push(projected)
existing.entries.push({ type: "assistant", message: projected })
current = existing
return
}
const user = getMessage(projected.parentID)
if (user?.role !== "user") return
const turn = { user, assistants: [projected] }
const turn = { user, entries: [{ type: "assistant" as const, message: projected }] }
turns.push(turn)
turnByUserID.set(user.id, turn)
current = turn
})
const notices = new Set(messages.filter(isNotice).map((message) => message.id))
projectedUserMessages.forEach((user) => {
if (notices.has(user.id)) return
if (turnByUserID.has(user.id)) return
const turn = { user, assistants: [] }
const turn = { user, entries: [] }
const index = turns.findIndex((item) => compareMessages(user, item.user) < 0)
if (index < 0) turns.push(turn)
if (index >= 0) turns.splice(index, 0, turn)
@@ -83,25 +102,34 @@ export namespace Timeline {
const activeMessageID = turns.at(-1)?.user.id
return {
activeMessageID,
rows: turns.flatMap((turn, index) =>
constructMessageRows(
turn.user,
getMessageParts,
turn.assistants,
index,
showReasoning,
status,
turn.user.id === activeMessageID,
inlineComments,
rows: [
...leading.map(
(message) =>
new TimelineRow.Notice({ userMessageID: turns[0]?.user.id ?? message.id, messageID: message.id }),
),
),
...turns.flatMap((turn, index) =>
constructMessageRows(
turn.user,
getMessageParts,
turn.entries,
index,
showReasoning,
status,
turn.user.id === activeMessageID,
inlineComments,
),
),
],
}
}
export function constructMessageRows(
userMessage: UserMessage,
getMessageParts: (messageID: string) => Part[],
assistantMessages: AssistantMessage[],
entries: Array<
| { type: "assistant"; message: AssistantMessage }
| { type: "notice"; message: Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }> }
>,
index: number,
showReasoning: boolean,
status: SessionStatus["type"],
@@ -110,13 +138,14 @@ export namespace Timeline {
inlineComments: boolean,
) {
const rows: TimelineRow.TimelineRow[] = []
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
const previousUserMessage = index > 0
const userParts = getMessageParts(userMessage.id)
const comments = userParts.flatMap((p) => MessageComment.fromPart(p) ?? [])
const compaction = userParts.some((p) => p.type === "compaction")
const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError")
const interrupted = interruptedMessageIndex !== -1
const compaction =
userParts.some((p) => p.type === "compaction") &&
!entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
const latestError = assistantMessages.at(-1)?.error
const error = latestError?.name === "MessageAbortedError" ? undefined : latestError
@@ -125,24 +154,6 @@ export namespace Timeline {
.filter((part) => renderable(part, showReasoning))
.map((part) => ({ messageID: message.id, messageIndex, part })),
)
const assistantItems =
interrupted && !compaction
? [
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map(
(group) => ({
type: "part" as const,
group,
}),
),
{ type: "interrupted" as const },
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map(
(group) => ({
type: "part" as const,
group,
}),
),
]
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
if (comments.length > 0 && !inlineComments)
@@ -169,26 +180,41 @@ export namespace Timeline {
}
let assistantGroupIndex = 0
assistantItems.forEach((item) => {
if (item.type === "interrupted") {
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
label: "interrupted",
}),
)
const appendAssistants = (messages: AssistantMessage[]) => {
const ids = new Set(messages.map((message) => message.id))
const refs = assistantPartRefs.filter((ref) => ids.has(ref.messageID))
const interruptedAt = messages.findIndex((message) => message.error?.name === "MessageAbortedError")
const interruptedID = messages[interruptedAt]?.id
const interruptedIndex = assistantMessages.findIndex((message) => message.id === interruptedID)
const before = interruptedID ? refs.filter((ref) => ref.messageIndex <= interruptedIndex) : refs
const after = interruptedID ? refs.filter((ref) => ref.messageIndex > interruptedIndex) : []
const appendGroups = (items: typeof refs) =>
groupParts(items).forEach((group) => {
rows.push(
new TimelineRow.AssistantPart({
userMessageID: userMessage.id,
group,
previousAssistantPart: assistantGroupIndex > 0,
}),
)
assistantGroupIndex += 1
})
appendGroups(before)
if (interruptedAt >= 0 && !compaction)
rows.push(new TimelineRow.TurnDivider({ userMessageID: userMessage.id, label: "interrupted" }))
appendGroups(after)
}
let assistantSegment: AssistantMessage[] = []
entries.forEach((entry) => {
if (entry.type === "assistant") {
assistantSegment.push(entry.message)
return
}
rows.push(
new TimelineRow.AssistantPart({
userMessageID: userMessage.id,
group: item.group,
previousAssistantPart: assistantGroupIndex > 0,
}),
)
assistantGroupIndex += 1
appendAssistants(assistantSegment)
assistantSegment = []
rows.push(new TimelineRow.Notice({ userMessageID: userMessage.id, messageID: entry.message.id }))
})
appendAssistants(assistantSegment)
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
const heading = assistantMessages
@@ -316,6 +342,14 @@ export namespace Timeline {
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function isNotice(
message: SessionMessageInfo,
): message is Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }> {
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
if (message.type !== "synthetic") return true
return !!message.description?.trim()
}
}
export namespace MessageComment {
@@ -15,6 +15,10 @@ export namespace TimelineRow {
userMessageID: string
anchor: boolean
}> {}
export class Notice extends Data.TaggedClass("Notice")<{
userMessageID: string
messageID: string
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
label: "compaction" | "interrupted"
@@ -44,6 +48,7 @@ export namespace TimelineRow {
| TurnGap
| CommentStrip
| UserMessage
| Notice
| TurnDivider
| AssistantPart
| Thinking
@@ -59,6 +64,8 @@ export namespace TimelineRow {
return `comment-strip:${row.userMessageID}`
case "UserMessage":
return `user-message:${row.userMessageID}`
case "Notice":
return `notice:${row.messageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}:${row.label}`
case "AssistantPart":
@@ -30,6 +30,10 @@ type SessionCommandSource = {
export type SessionCommandContext = {
session: SessionCommandSource
background: {
blocking: () => boolean
move: () => Promise<void>
}
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
focusInput: () => void
@@ -434,6 +438,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
onSelect: compact,
}),
sessionCommand({
id: "session.background",
title: language.t("command.session.background"),
keybind: "ctrl+b",
disabled: !actions.background.blocking(),
onSelect: actions.background.move,
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
@@ -8,6 +8,7 @@ import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { displayName } from "@/pages/layout/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileView } from "@/pages/session/file-tabs"
@@ -38,6 +39,7 @@ export function SessionFileBrowserTab(props: {
const language = useLanguage()
const layout = useLayout()
const sdk = useSDK()
const serverSDK = useServerSDK()
const { workspaceKey } = useSessionLayout()
const resultsID = `session-file-browser-results-${createUniqueId()}`
const [filter, setFilter] = createSignal("")
@@ -47,8 +49,8 @@ export function SessionFileBrowserTab(props: {
const search = createQuery(() => {
const value = query()
return {
queryKey: ["session-open-file", workspaceKey(), value] as const,
enabled: value.length > 0,
queryKey: [serverSDK().scope, "session-open-file", workspaceKey(), value] as const,
enabled: serverSDK().connection.status() === "connected" && value.length > 0,
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
}
})
+1 -1
View File
@@ -7,7 +7,7 @@ const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
if (process.env.OPENCODE_CHANNEL === "latest") return "prod"
return "dev"
})()
+1 -4
View File
@@ -122,10 +122,7 @@ const layer = Layer.effect(
return { id: info?.id ?? defaultID, info }
}),
list: Effect.fn("Agent.list")(function* () {
const agents = Array.fromIterable(state.get().agents.values())
const selected = selectedDefault()
if (!selected) return agents
return [selected, ...agents.filter((agent) => agent.id !== selected.id)]
return Array.fromIterable(state.get().agents.values())
}),
})
}),
+2 -1
View File
@@ -115,7 +115,7 @@ export const Plugin = {
sessionID,
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: { source: "shell", state },
metadata: { source: "shell", jobID: id, state },
})
}),
Effect.forkIn(scope, { startImmediately: true }),
@@ -300,6 +300,7 @@ export const Plugin = {
output,
content,
metadata: {
status: output.status,
truncated: output.truncated,
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
-20
View File
@@ -68,26 +68,6 @@ describe("Agent", () => {
}),
)
it.effect("lists the selected default agent first", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
yield* agent.transform((editor) => {
editor.update(Agent.ID.make("build"), (info) => {
info.mode = "primary"
})
editor.update(Agent.ID.make("reviewer"), (info) => {
info.mode = "primary"
})
editor.update(Agent.ID.make("explore"), (info) => {
info.mode = "subagent"
})
editor.default(Agent.ID.make("reviewer"))
})
expect((yield* agent.list()).map((info) => String(info.id))).toEqual(["reviewer", "build", "explore"])
}),
)
it.effect("rebuilds state when a transform is replaced", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
@@ -0,0 +1,99 @@
const resizeLoopWarning = "ResizeObserver loop completed with undelivered notifications."
if (import.meta.env.DEV) {
installConsoleStacks()
installResizeObserverStacks()
}
function installConsoleStacks() {
console.warn = tracedConsole(console.warn.bind(console), "Console warning")
console.error = tracedConsole(console.error.bind(console), "Console error")
}
function installResizeObserverStacks() {
if (typeof ResizeObserver !== "function") return
const NativeResizeObserver = ResizeObserver
type ResizeTrace = {
created: string
last?: { at: number; targets: Element[] }
}
const observers = new Set<ResizeTrace>()
class TracedResizeObserver extends NativeResizeObserver {
private readonly trace: ResizeTrace
constructor(callback: ResizeObserverCallback) {
const trace: ResizeTrace = {
created: diagnosticStack("ResizeObserver created"),
}
super((entries, observer) => {
trace.last = { at: performance.now(), targets: entries.map((entry) => entry.target) }
callback(entries, observer)
})
this.trace = trace
observers.add(trace)
}
override disconnect() {
observers.delete(this.trace)
super.disconnect()
}
}
globalThis.ResizeObserver = TracedResizeObserver
window.addEventListener("error", (event) => {
if (event.message !== resizeLoopWarning) return
const now = performance.now()
const active = [...observers]
.flatMap((observer) => {
if (!observer.last || now - observer.last.at >= 100) return []
return [{ ...observer, last: observer.last }]
})
.sort((a, b) => b.last.at - a.last.at)
.slice(0, 5)
const detail = active.length
? active
.map(
(observer, index) =>
`Recent ResizeObserver ${index + 1}; targets: ${observer.last.targets.map(describeElement).join(", ") || "none"}\n${observer.created}`,
)
.join("\n")
: "No ResizeObserver callback was recorded in the previous 100 ms."
console.warn(`[renderer diagnostics] ${resizeLoopWarning}\n${detail}`)
})
}
function tracedConsole(write: (...args: unknown[]) => void, label: string) {
const pending = new Map<string, { count: number }>()
return (...args: unknown[]) => {
const stack = diagnosticStack(label)
const key = `${String(args[0])}\n${stack}`
const current = pending.get(key)
if (current) {
current.count += 1
return
}
const entry = { count: 0 }
pending.set(key, entry)
write(...args, stack)
window.setTimeout(() => {
pending.delete(key)
if (entry.count) write(`${String(args[0])} (repeated ${entry.count} times)`)
}, 1_000)
}
}
function describeElement(element: Element) {
const id = element.id ? `#${element.id}` : ""
const classes = [...element.classList]
.slice(0, 3)
.map((name) => `.${name}`)
.join("")
return `${element.tagName.toLowerCase()}${id}${classes}`
}
function diagnosticStack(label: string) {
return new Error(label).stack ?? label
}
+1
View File
@@ -1,5 +1,6 @@
// @refresh reload
import "./diagnostics"
import {
ACCEPTED_FILE_EXTENSIONS,
AppBaseProviders,
@@ -515,11 +515,10 @@ export function getToolInfo(
title: webSearchProviderLabel(metadata?.provider, i18n),
subtitle: input.query,
}
case "task": {
const type =
typeof input.subagent_type === "string" && input.subagent_type
? input.subagent_type[0]!.toUpperCase() + input.subagent_type.slice(1)
: undefined
case "task":
case "subagent": {
const raw = input.agent ?? input.subagent_type
const type = typeof raw === "string" && raw ? raw[0]!.toUpperCase() + raw.slice(1) : undefined
return {
icon: "task",
title: agentTitle(i18n, type),
@@ -594,19 +593,12 @@ function sessionLink(id: string | undefined, href?: (id: string) => string | und
return href?.(id)
}
function taskSession(
input: Record<string, any>,
parentID: string | undefined,
sessions: SessionSummary[] | undefined,
agents?: readonly { name: string; color?: string }[],
) {
function taskSession(input: Record<string, any>, parentID: string | undefined, sessions: SessionSummary[] | undefined) {
if (!parentID) return
const description = typeof input.description === "string" ? input.description : ""
const agent = taskAgent(input.subagent_type, agents).name
return (sessions ?? [])
.filter((session) => session.parentID === parentID && !session.time?.archived)
.filter((session) => (description ? session.title?.startsWith(description) : true))
.filter((session) => (agent ? session.title?.includes(`@${agent}`) : true))
.sort((a, b) => (b.time.created ?? 0) - (a.time.created ?? 0))[0]?.id
}
@@ -1553,16 +1545,16 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
// @ts-expect-error
const partMetadata = () => part().state?.metadata ?? emptyMetadata
const taskId = createMemo(() => {
if (part().tool !== "task") return
const value = partMetadata().sessionId
if (part().tool !== "task" && part().tool !== "subagent") return
const value = partMetadata().sessionID ?? partMetadata().sessionId
if (typeof value === "string" && value) return value
})
const taskHref = createMemo(() => {
if (part().tool !== "task") return
if (part().tool !== "task" && part().tool !== "subagent") return
return sessionLink(taskId(), data.sessionHref)
})
const taskSubtitle = createMemo(() => {
if (part().tool !== "task") return undefined
if (part().tool !== "task" && part().tool !== "subagent") return undefined
const value = input().description
if (typeof value === "string" && value) return value
return taskId()
@@ -1979,18 +1971,17 @@ ToolRegistry.register({
)
},
})
ToolRegistry.register({
name: "task",
render(props) {
const data = useData()
const i18n = useI18n()
const childSessionId = createMemo(() => {
const value = props.metadata.sessionId
const value = props.metadata.sessionID ?? props.metadata.sessionId
if (typeof value === "string" && value) return value
return taskSession(props.input, data.sessionID, data.store.session, data.store.agent)
return taskSession(props.input, data.sessionID, data.store.session)
})
const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent))
const agent = createMemo(() => taskAgent(props.input.agent ?? props.input.subagent_type, data.store.agent))
const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
const tone = createMemo(() => agent().color)
const v2Tone = createMemo(() => agent().v2Color)
@@ -2000,7 +1991,8 @@ ToolRegistry.register({
? props.input.description
: childSessionId()
if (!value) return value
if (props.metadata.background === true) return `${value} (background)`
if (props.input.background === true || props.metadata.background === true || props.metadata.status === "running")
return `${value} (background)`
return value
})
const running = createMemo(() => props.status === "pending" || props.status === "running")
@@ -2087,11 +2079,14 @@ ToolRegistry.register({
},
})
ToolRegistry.register({ name: "subagent", render: ToolRegistry.render("task") })
ToolRegistry.register({
name: "shell",
render(props) {
const i18n = useI18n()
const pending = () => props.status === "pending" || props.status === "running"
const pending = () =>
props.status === "pending" || props.status === "running" || props.metadata.status === "running"
const sawPending = pending()
const text = createMemo(() => {
const cmd = props.input.command ?? props.metadata.command ?? ""
+5
View File
@@ -103,6 +103,11 @@ const icons = {
link: `<path d="M2.08334 12.0833L1.72979 11.7298L1.37624 12.0833L1.72979 12.4369L2.08334 12.0833ZM7.91668 17.9167L7.56312 18.2702L7.91668 18.6238L8.27023 18.2702L7.91668 17.9167ZM17.9167 7.91666L18.2702 8.27022L18.6238 7.91666L18.2702 7.56311L17.9167 7.91666ZM12.0833 2.08333L12.4369 1.72977L12.0833 1.37622L11.7298 1.72977L12.0833 2.08333ZM8.39646 5.06311L8.0429 5.41666L8.75001 6.12377L9.10356 5.77021L8.75001 5.41666L8.39646 5.06311ZM5.77023 9.10355L6.12378 8.74999L5.41668 8.04289L5.06312 8.39644L5.41668 8.74999L5.77023 9.10355ZM14.2298 10.8964L13.8762 11.25L14.5833 11.9571L14.9369 11.6035L14.5833 11.25L14.2298 10.8964ZM11.6036 14.9369L11.9571 14.5833L11.25 13.8762L10.8965 14.2298L11.25 14.5833L11.6036 14.9369ZM7.14646 12.1464L6.7929 12.5L7.50001 13.2071L7.85356 12.8535L7.50001 12.5L7.14646 12.1464ZM12.8536 7.85355L13.2071 7.49999L12.5 6.79289L12.1465 7.14644L12.5 7.49999L12.8536 7.85355ZM2.08334 12.0833L1.72979 12.4369L7.56312 18.2702L7.91668 17.9167L8.27023 17.5631L2.4369 11.7298L2.08334 12.0833ZM17.9167 7.91666L18.2702 7.56311L12.4369 1.72977L12.0833 2.08333L11.7298 2.43688L17.5631 8.27022L17.9167 7.91666ZM12.0833 2.08333L11.7298 1.72977L8.39646 5.06311L8.75001 5.41666L9.10356 5.77021L12.4369 2.43688L12.0833 2.08333ZM5.41668 8.74999L5.06312 8.39644L1.72979 11.7298L2.08334 12.0833L2.4369 12.4369L5.77023 9.10355L5.41668 8.74999ZM14.5833 11.25L14.9369 11.6035L18.2702 8.27022L17.9167 7.91666L17.5631 7.56311L14.2298 10.8964L14.5833 11.25ZM7.91668 17.9167L8.27023 18.2702L11.6036 14.9369L11.25 14.5833L10.8965 14.2298L7.56312 17.5631L7.91668 17.9167ZM7.50001 12.5L7.85356 12.8535L12.8536 7.85355L12.5 7.49999L12.1465 7.14644L7.14646 12.1464L7.50001 12.5Z" fill="currentColor"/>`,
providers: `<path d="M10.0001 4.37562V2.875M13 4.37793V2.87793M7.00014 4.37793V2.875M10 17.1279V15.6279M13 17.1279V15.6279M7 17.1279V15.6279M15.625 13.0029H17.125M15.625 7.00293H17.125M15.625 10.0029H17.125M2.875 10.0029H4.375M2.875 13.0029H4.375M2.875 7.00293H4.375M4.375 4.37793H15.625V15.6279H4.375V4.37793ZM12.6241 10.0022C12.6241 11.4519 11.4488 12.6272 9.99908 12.6272C8.54934 12.6272 7.37408 11.4519 7.37408 10.0022C7.37408 8.55245 8.54934 7.3772 9.99908 7.3772C11.4488 7.3772 12.6241 8.55245 12.6241 10.0022Z" stroke="currentColor" stroke-linecap="square"/>`,
models: `<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 10C12.2917 10 10 12.2917 10 17.5C10 12.2917 7.70833 10 2.5 10C7.70833 10 10 7.70833 10 2.5C10 7.70833 12.2917 10 17.5 10Z" stroke="currentColor"/>`,
appearance: `<path d="M2.707 14.707L14.707 2.707M2.707 9.06L9.06 2.707M2.707 3.413L3.413 2.707M8.354 14.707L14.707 8.354M14.000 14.706L14.706 14.000" stroke="currentColor" stroke-linecap="square"/>`,
notifications: `<path d="M15.389 7.278V9.611C15.389 10.593 15.389 11.389 15.389 11.389H11.833M6.056 11.389H2.5C2.5 11.389 2.5 10.593 2.5 9.611V4.278C2.5 3.296 2.5 2.5 2.5 2.5H9.945M6.056 11.389V13.611H8.944H11.833V11.389M6.056 11.389H11.833" stroke="currentColor"/><circle cx="14.5" cy="4.5" r="2" stroke="currentColor" fill="currentColor"/>`,
extensions: `<path d="M9.166 6.805V9.305M11.834 6.805V9.305M6.5 6.805V9.305M2.5 2.5V13.61H15.833V2.5H2.5Z" stroke="currentColor" stroke-linecap="square"/>`,
cube: `<path d="M10 2.5L16.5 6.25V13.75L10 17.5L3.5 13.75V6.25L10 2.5Z" stroke="currentColor"/><path d="M10 10L16.5 6.25M10 10V17.5M10 10L3.5 6.25" stroke="currentColor"/>`,
"post-skill": `<rect x="2.5" y="3.5" width="15" height="13" rx="1.5" stroke="currentColor"/><path d="M5.5 7.5H10.5M5.5 10.5H14.5" stroke="currentColor"/>`,
"arrow-undo-down": `<path d="M4.08333 11.0859L1.75 8.7526L4.08333 6.41927M2.33333 8.7526L12.5417 8.7526L12.5417 3.21094L7 3.21094" stroke="currentColor" stroke-width="1" stroke-linecap="square"/>`,
}
+12 -4
View File
@@ -175,7 +175,7 @@
border: 0.5px solid var(--v2-border-border-muted);
}
[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"] {
flex-direction: column;
width: 240px;
min-width: 200px;
@@ -186,7 +186,9 @@
border-inline-end: 1px solid var(--v2-border-border-base);
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-section-title"] {
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-section-title"] {
width: 100%;
padding-inline-start: 4px;
color: var(--v2-text-text-muted);
@@ -195,7 +197,9 @@
user-select: none;
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"] {
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"] {
width: 100%;
height: 28px;
border-radius: 4px;
@@ -203,7 +207,9 @@
color: var(--v2-text-text-muted);
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger"] {
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger"] {
width: 100%;
height: 100%;
justify-content: flex-start;
@@ -212,12 +218,14 @@
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"]:hover:not(:disabled):not(:has([data-selected])) {
background-color: var(--v2-background-bg-layer-03);
color: var(--v2-text-text-base);
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"]:has([data-selected]) {
background-color: var(--v2-background-bg-layer-03);
color: var(--v2-text-text-base);
+2 -2
View File
@@ -4,8 +4,8 @@ description: "Get started with OpenCode."
---
<Callout type="warning">
These docs are for the beta version of OpenCode, which will become OpenCode 2.0. The beta is still changing: we may
wipe your data, things may break, and APIs, configuration, and plugin APIs may change.
These docs are for the beta version of OpenCode, which will become OpenCode 2.0. The beta is still changing: things
may break, and APIs, configuration, and plugin APIs may change.
</Callout>
<Callout type="note">
+1 -2
View File
@@ -30,8 +30,7 @@ beta compatibility bug rather than an expected migration requirement.
</Callout>
<Callout type="warning">
OpenCode 2.0 is in beta. Beta data may be wiped, features may break unintentionally, and the server and plugin APIs
may continue to change.
OpenCode 2.0 is in beta. Features may break unintentionally, and the server and plugin APIs may continue to change.
</Callout>
## Install the beta
+1 -3
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env bash
BUMP_TYPE=${1:-patch}
gh workflow run publish.yml -f bump="$BUMP_TYPE"
gh workflow run publish.yml --ref v2