Compare commits

..

2 Commits

Author SHA1 Message Date
Luke Parker 0b31861db2 refactor(core): use scoped search effects 2026-08-13 02:16:17 +00:00
Luke Parker fbde9dd414 fix(core): refresh fallback file search 2026-08-13 02:09:27 +00:00
106 changed files with 1361 additions and 4564 deletions
+1
View File
@@ -6,6 +6,7 @@ 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, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
@@ -90,8 +90,6 @@ export async function setupTimeline(
page: Page,
input: {
messages?: TimelineMessage[]
currentMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean>
sessions?: Session[]
cpuRate?: number
@@ -104,22 +102,13 @@ export async function setupTimeline(
} = {},
) {
const sessions = input.sessions ?? [session()]
const messages =
input.currentMessages ??
validateTimelineMessages([
...(input.seedHistory ? historyMessages(18) : []),
...(input.messages ?? [userMessage(), assistantMessage()]),
])
const active = messages.findLast((message) =>
"info" in message ? message.info.role === "assistant" : message.type === "assistant",
)
const messages = validateTimelineMessages([
...(input.seedHistory ? historyMessages(18) : []),
...(input.messages ?? [userMessage(), assistantMessage()]),
])
const active = messages.findLast((message) => message.info.role === "assistant")
const initialStatus = decodeStatus(
active &&
("info" in active
? active.info.role === "assistant" && active.info.time.completed === undefined
: active.type === "assistant" && active.time.completed === undefined)
? { type: "busy" }
: { type: "idle" },
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
decodeOptions,
)
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
@@ -133,7 +122,7 @@ export async function setupTimeline(
project: project(),
provider: provider(),
sessions,
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus },
sessionStatus: { [sessionID]: initialStatus },
pageMessages: () => ({
items: messages,
}),
@@ -122,7 +122,6 @@ test("applies message latency after a list response gate is released", async ()
const gate = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
@@ -1,7 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
@@ -79,8 +78,6 @@ function session(id: string, directory: string, title: string) {
}
async function mockServers(page: Page, requests: string[]) {
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
@@ -88,6 +85,7 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
@@ -144,3 +142,7 @@ function json(route: Route, body: unknown, status = 200) {
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -13,8 +13,6 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await mockServers(page, permissionRequests)
await configureServers(page)
@@ -48,7 +46,6 @@ test("session settings use the remote server context", async ({ page }) => {
test("auto-accept responds for an unfocused server session", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverB })
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: serverA,
retry: 20,
@@ -184,6 +181,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/provider")
return json(route, {
location: { directory },
@@ -327,3 +325,7 @@ function json(route: Route, body: unknown, status = 200) {
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -11,23 +11,18 @@ const title = "Request dock regression"
test("shows a pending question dock", async ({ page }) => {
await mockServer(page, {
forms: [
questions: [
{
id: "frm_question_request",
id: "question-request",
sessionID,
title: "Questions",
metadata: { kind: "question" },
fields: [
questions: [
{
key: "q0",
type: "string",
title: "Implementation",
description: "Which implementation should be used?",
header: "Implementation",
question: "Which implementation should be used?",
options: [
{ value: "minimal", label: "Minimal", description: "Use the smallest correct change" },
{ value: "extended", label: "Extended", description: "Include additional behavior" },
{ label: "Minimal", description: "Use the smallest correct change" },
{ label: "Extended", description: "Include additional behavior" },
],
custom: true,
},
],
},
@@ -47,7 +42,7 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
rejectRequests.push(request.url())
})
@@ -72,10 +67,10 @@ test("shows a pending question dock", async ({ page }) => {
const reply = page.waitForRequest(
(request) =>
request.method() === "POST" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`,
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
)
await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } })
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
})
test("shows a pending permission dock", async ({ page }) => {
@@ -114,7 +109,7 @@ test("restores the draft caret before typing after a request dock closes", async
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: 20,
})
await mockServer(page, { forms: [] })
await mockServer(page, { questions: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
@@ -140,24 +135,18 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.send({
directory,
payload: {
type: "form.created",
type: "question.asked",
properties: {
form: {
id: "frm_question_caret",
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
fields: [
{
key: "q0",
type: "string",
title: "Continue",
description: "Continue?",
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
custom: true,
},
],
},
id: "question-caret",
sessionID,
questions: [
{
header: "Continue",
question: "Continue?",
options: [{ label: "Yes", description: "Continue the session" }],
},
],
tool: { messageID: "message-caret", callID: "call-caret" },
},
},
})
@@ -167,10 +156,7 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.send({
directory,
payload: {
type: "form.cancelled",
properties: { sessionID, id: "frm_question_caret" },
},
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
})
await expect(question).toHaveCount(0)
await expect(editor).toBeVisible()
@@ -184,8 +170,6 @@ async function mockServer(
requests: {
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
sessionStatus?: Record<string, unknown>
},
) {
await mockOpenCodeServer(page, {
@@ -230,8 +214,6 @@ async function mockServer(
pageMessages: () => ({ items: [] }),
permissions: requests.permissions,
questions: requests.questions,
forms: requests.forms,
sessionStatus: requests.sessionStatus,
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
@@ -1,184 +0,0 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
const assistant = (completed: boolean, tool = false, childID?: string) =>
({
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: tool
? [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
time: { created: 2 },
},
]
: [{ type: "text", text: "Working" }],
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
}) satisfies SessionMessageInfo
test("renders current protocol notices in CLI order", async ({ page }) => {
const ownerWarnings: string[] = []
page.on("console", (message) => {
if (message.text().includes("computations created outside a `createRoot` or `render`"))
ownerWarnings.push(message.text())
})
await setupTimeline(page, {
currentMessages: [
user,
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
assistant(true),
{
id: "msg_subagent",
type: "synthetic",
text: "done",
description: "Search code",
metadata: { source: "subagent", agent: "explore", state: "completed" },
time: { created: 4 },
},
{
id: "msg_restart",
type: "synthetic",
text: "continue",
description: "Continuing after restart",
time: { created: 5 },
},
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } },
],
})
const notices = page.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toContainText("Agent · explore")
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
expect(ownerWarnings).toEqual([])
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, { currentMessages: [user, assistant(false, true)] })
await expect(page.locator('[data-component="task-tool-card"]')).toBeVisible()
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
)
await page.keyboard.press("Control+b")
await request
})
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
currentMessages: [user, assistant(false, true, childID)],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
})
test("shows a badge for active background work", async ({ page }) => {
const childID = "ses_background_child"
await setupTimeline(page, {
currentMessages: [user, assistant(true)],
sessions: [session(), session({ id: childID, parentID: sessionID })],
sessionStatus: { [childID]: { type: "busy" } },
})
await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background")
})
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
await setupTimeline(page, {
currentMessages: [
user,
{
id: "msg_backgrounded",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_backgrounded",
name: "subagent",
state: {
status: "completed",
input: { description: "Background task" },
content: [{ type: "text", text: "working" }],
metadata: { sessionID: backgroundID, status: "running" },
},
time: { created: 2, completed: 3 },
},
{
type: "tool",
id: "call_shell_backgrounded",
name: "shell",
state: {
status: "completed",
input: { command: "sleep 120" },
content: [{ type: "text", text: "working" }],
metadata: { shellID: "shell_backgrounded", status: "running" },
},
time: { created: 2, completed: 3 },
},
],
time: { created: 2, completed: 3 },
},
{
id: "msg_blocking",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_blocking",
name: "subagent",
state: {
status: "running",
input: { description: "Foreground task" },
metadata: { sessionID: blockingID },
},
time: { created: 4 },
},
],
time: { created: 4 },
},
],
sessions: [
session(),
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
],
sessionStatus: {
[sessionID]: { type: "busy" },
[backgroundID]: { type: "busy" },
[blockingID]: { type: "busy" },
},
})
const dock = page.locator('[data-component="session-background-dock"]')
await expect(dock).toContainText("Move 1 subagent to background")
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
await expect(
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
).toHaveAttribute("data-active", "true")
})
-112
View File
@@ -32,110 +32,15 @@ export interface MockServerConfig {
todos?: (sessionID: string) => unknown[]
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
}
type MockStreamWindow = Window & {
__testSseTransport?: unknown
__mockServerStream?: { push: (payloads: unknown[]) => void }
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>()
let nextCursor = 0
await page.addInitScript(
({ port, retry }) => {
const host = window as MockStreamWindow
if (host.__testSseTransport || host.__mockServerStream) return
const originalFetch = window.fetch.bind(window)
const encoder = new TextEncoder()
const state: {
controller?: ReadableStreamDefaultController<Uint8Array>
buffer: string[]
connections: number
} = { buffer: [], connections: 0 }
const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\n\n`
host.__mockServerStream = {
push(payloads: unknown[]) {
const frames = payloads.map(frame)
const controller = state.controller
if (!controller) {
state.buffer.push(...frames)
return
}
frames.forEach((item) => controller.enqueue(encoder.encode(item)))
},
}
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const url = new URL(request.url)
if (url.port !== port || url.pathname !== "/api/event") return originalFetch(request)
state.connections += 1
const id = state.connections
let ended = false
let own: ReadableStreamDefaultController<Uint8Array> | undefined
const stream = new ReadableStream<Uint8Array>({
start(controller) {
own = controller
state.controller = controller
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
request.signal.addEventListener(
"abort",
() => {
if (ended) return
ended = true
if (state.controller === controller) state.controller = undefined
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
},
{ once: true },
)
},
cancel() {
if (ended) return
ended = true
if (state.controller === own) state.controller = undefined
},
})
return Promise.resolve(
new Response(stream, {
status: 200,
headers: { "cache-control": "no-cache", "content-type": "text/event-stream" },
}),
)
}
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
},
{ port: process.env.PLAYWRIGHT_SERVER_PORT ?? "4096", retry: config.eventRetry },
)
if (config.events) {
const pump = { busy: false }
const timer = setInterval(() => {
if (pump.busy) return
const batch = config.events?.() ?? []
if (batch.length === 0) return
pump.busy = true
void page
.evaluate(
(payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads),
batch.map(currentEvent),
)
.catch(() => {})
.finally(() => {
pump.busy = false
})
}, 50)
page.on("close", () => clearInterval(timer))
}
const staticRoutes: Record<string, unknown> = {
"/path": {
state: config.directory,
@@ -290,11 +195,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
location: location(config),
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
})
if (path === "/api/form/request")
return json(route, {
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
@@ -380,18 +280,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
if (sessionForm && route.request().method() === "GET") {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
}
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
return json(route, { data: [] })
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
+12 -15
View File
@@ -54,7 +54,7 @@ import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { decode64 } from "@/utils/base64"
import { SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -93,16 +93,11 @@ function TargetServerRoute(props: ParentProps) {
)
}
function TargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</SessionRouteErrorBoundary>
)
}
const TargetSessionRoute = () => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell for that server.
@@ -453,10 +448,12 @@ export function AppInterface(props: {
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const ServerShell = (shellProps: ParentProps) => (
<SharedProviders>
{props.children}
{shellProps.children}
</SharedProviders>
<QueryProvider>
<SharedProviders>
{props.children}
{shellProps.children}
</SharedProviders>
</QueryProvider>
)
return (
@@ -20,7 +20,6 @@ import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
@@ -141,7 +140,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 integrations = useIntegrations(() => props.directory)
const providers = useProviders(() => props.directory)
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
@@ -163,7 +162,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...integrations.list()]
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -209,7 +208,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
}
function ProviderPickerV2(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
const integrations = useIntegrations(() => props.directory)
const providers = useProviders(() => props.directory)
const language = useLanguage()
const [store, setStore] = createStore({
filter: "",
@@ -221,7 +220,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
const all = createMemo(() => {
language.locale()
const query = store.filter.trim().toLowerCase()
const values = [custom(), ...integrations.list()]
const values = [custom(), ...providers.all().values()]
if (!query) return values
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
})
@@ -370,6 +369,9 @@ 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,
@@ -383,14 +385,6 @@ function ProviderConnection(props: {
})
},
})
const provider = createMemo(() => ({
id: props.provider,
name:
providers.all().get(props.provider)?.name ??
serverSync().data.provider.all.get(props.provider)?.name ??
controller.integration()?.name ??
props.provider,
}))
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
if (value.type === "key") return language.t("provider.connect.method.apiKey")
@@ -1,238 +0,0 @@
.project-settings-v2-dialog [data-slot="dialog-container"] {
background: var(--v2-background-bg-base);
}
.project-settings-v2-dialog [data-slot="dialog-body"] {
padding: 0;
overflow: hidden;
}
.project-settings-v2 {
height: 100%;
}
.project-settings-v2-nav {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.project-settings-v2-panel {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
overflow: hidden !important;
user-select: none;
}
.project-settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.project-settings-v2-form {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.project-settings-v2-scroll {
display: flex;
flex: 1;
flex-direction: column;
gap: 24px;
min-height: 0;
padding: 40px;
overflow-y: auto;
}
.project-settings-page-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.project-settings-page-header h2 {
color: var(--v2-text-text-base);
font-size: 15px;
font-weight: 640;
line-height: 1;
}
.project-settings-page-header > span {
color: var(--v2-text-text-muted);
font-size: 11px;
font-weight: 440;
line-height: 1;
}
.project-settings-extensions {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 40px;
}
.project-settings-extension-tabs {
flex: 1;
height: auto;
min-height: 0;
margin-top: 24px;
overflow: visible;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: auto;
padding-inline: 0 !important;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
display: none;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"] {
border-radius: 6px;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger"] {
padding-inline: 8px;
}
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
overflow-y: auto;
}
.project-settings-extension-section {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 20px;
}
.project-settings-extension-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: 1;
}
.project-settings-extension-section-header > :last-child {
color: var(--v2-text-text-faint);
font-size: 11px;
font-weight: 440;
}
.project-settings-extension-link {
color: var(--v2-text-text-accent) !important;
text-decoration: none;
}
.project-settings-extension-link:hover {
text-decoration: underline;
}
.project-settings-extension-card {
padding-inline: 12px;
overflow: hidden;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 8px;
background: var(--v2-background-bg-base);
}
.project-settings-extension-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.project-settings-extension-row:last-child {
border-bottom: 0;
}
.project-settings-extension-row-main {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
}
.project-settings-extension-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.project-settings-extension-row-name {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-settings-extension-row-status {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 11px;
}
.project-settings-extension-row-status-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--v2-state-fg-warning);
}
.project-settings-shared {
display: flex;
flex-direction: column;
gap: 12px;
}
.project-settings-shared-trigger {
display: flex;
align-items: center;
align-self: flex-start;
gap: 6px;
color: var(--v2-text-text-base);
font-size: 11px;
font-weight: 530;
}
.project-settings-shared-chevron {
width: 12px;
height: 12px;
color: var(--v2-icon-icon-muted);
transition: transform 120ms ease;
}
.project-settings-shared-chevron.open {
transform: rotate(90deg);
}
.project-settings-shared-count {
padding: 1px 4px;
border-radius: 3px;
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-muted);
font-size: 9px;
}
@@ -1,223 +1,156 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Field } from "@opencode-ai/ui/v2/field-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { For, Show, createSignal, startTransition } from "solid-js"
import { For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { SDKProvider } from "@/context/sdk"
import { ServerConnection } from "@/context/server"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project"
import { ProjectSettingsExtensions } from "./project-settings-extensions"
import { SettingsServerDataScope } from "./settings-server-picker"
import "./settings-v2/settings-v2.css"
import "./dialog-edit-project-v2.css"
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
return (
<SettingsServerDataScope server={props.server}>
<SDKProvider directory={props.project.worktree}>
<ProjectSettingsDialog project={props.project} server={props.server} />
</SDKProvider>
</SettingsServerDataScope>
)
}
function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage()
const model = createEditProjectModel(props)
const projectName = () => displayName(props.project)
const [tab, setTab] = createSignal("general")
const Footer = () => (
<DialogFooter>
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2>
</DialogFooter>
)
return (
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog">
<TabsV2
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="project-settings-v2"
>
<TabsV2.List>
<div class="project-settings-v2-nav">
<TabsV2.Trigger value="general">
<ProjectAvatar
fallback={projectName()}
variant={getProjectAvatarVariant(props.project.icon?.color)}
class="!size-4 shrink-0"
/>
<span class="truncate">{projectName()}</span>
</TabsV2.Trigger>
<TabsV2.Trigger value="scripts">
<Icon name="code" size="small" />
{language.t("project.settings.scripts")}
</TabsV2.Trigger>
<TabsV2.Trigger value="extensions">
<Icon name="extensions" size="small" />
{language.t("settings.tab.extensions")}
</TabsV2.Trigger>
</div>
</TabsV2.List>
<Dialog fit>
<form onSubmit={model.submit} class="contents">
<DialogHeader>
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
<Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInputV2
autofocus
appearance="large"
class="!w-full"
value={model.store.name}
placeholder={model.folderName()}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
/>
</Field>
<TabsV2.Content value="general" class="project-settings-v2-panel">
<form onSubmit={model.submit} class="project-settings-v2-form">
<div class="project-settings-v2-scroll">
<div class="project-settings-page-header">
<h2>{language.t("dialog.project.edit.title")}</h2>
<span>{language.t("project.settings.general.description")}</span>
</div>
<Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInputV2
autofocus
appearance="large"
class="!w-full"
value={model.store.name}
placeholder={model.folderName()}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.icon")}
</div>
<div class="flex items-center gap-3">
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
}}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
</Field>
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
}}
>
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
</span>
</button>
<input
ref={(element) => {
model.setIconInput(element)
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.icon")}
</div>
<div class="flex items-center gap-3">
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
}}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}}
>
<IconV2 name={model.store.iconOverride ? "close" : "share"} />
</span>
</button>
<input
ref={(element) => model.setIconInput(element)}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
</div>
</Show>
</div>
<Footer />
</form>
</TabsV2.Content>
</Show>
<TabsV2.Content value="scripts" class="project-settings-v2-panel">
<form onSubmit={model.submit} class="project-settings-v2-form">
<div class="project-settings-v2-scroll">
<div class="project-settings-page-header">
<h2>{language.t("project.settings.scripts")}</h2>
<span>{language.t("project.settings.scripts.description")}</span>
</div>
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<TextareaV2
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={5}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
/>
</Field>
</div>
<Footer />
</form>
</TabsV2.Content>
<TabsV2.Content value="extensions" class="project-settings-v2-panel">
<ProjectSettingsExtensions />
</TabsV2.Content>
</TabsV2>
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<TextareaV2
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={3}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
/>
</Field>
</DialogBody>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2>
</DialogFooter>
</form>
</Dialog>
)
}
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { useIntegrations } from "@/hooks/use-integrations"
import { useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip"
@@ -22,7 +22,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
const dialog = useDialog()
const theme = useTheme()
const directory = () => decode64(local.slug())
const integrations = useIntegrations(directory)
const providers = useProviders(directory)
const language = useLanguage()
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
const currentKey = createMemo(() => {
@@ -127,8 +127,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
</div>
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
<For
each={integrations
.list()
each={[...providers.popular()]
.filter((provider) => featuredProviders.includes(provider.id))
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
>
@@ -1,238 +0,0 @@
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
import { ExternalLink } from "./external-link"
type SkillItem = {
name: string
location: string
}
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
const ExtensionCard: Component<{ children: unknown }> = (props) => (
<div class="project-settings-extension-card">{props.children as any}</div>
)
const ExtensionRow: Component<{
icon: "mcp" | "cube" | "post-skill" | "code"
name: string
status?: string
children?: unknown
}> = (props) => (
<div class="project-settings-extension-row">
<div class="project-settings-extension-row-main">
<Icon name={props.icon} class="project-settings-extension-row-icon" />
<span class="project-settings-extension-row-name">{props.name}</span>
</div>
<Show when={props.status}>
{(status) => (
<span class="project-settings-extension-row-status">
<span class="project-settings-extension-row-status-dot" />
{status()}
</span>
)}
</Show>
{props.children as any}
</div>
)
const SharedSection: Component<{
count: number
children: unknown
}> = (props) => {
const language = useLanguage()
const [open, setOpen] = createSignal(false)
return (
<Show when={props.count > 0}>
<div class="project-settings-shared">
<button
type="button"
class="project-settings-shared-trigger"
aria-expanded={open()}
onClick={() => setOpen((value) => !value)}
>
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
<span>{language.t("project.settings.extensions.shared")}</span>
<span class="project-settings-shared-count">{props.count}</span>
</button>
<Show when={open()}>
<ExtensionCard>{props.children}</ExtensionCard>
</Show>
</div>
</Show>
)
}
export const ProjectSettingsExtensions: Component = () => {
const language = useLanguage()
const serverSDK = useServerSDK()
const directorySDK = useSDK()
const serverSync = useServerSync()
const sync = useSync()
const toggleMcp = useMcpToggle()
const [serverMcp] = createResource(
serverSDK,
(sdk) =>
sdk.api.mcp
.list()
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
.catch(() => ({})),
{ initialValue: {} },
)
const globalMcpNames = createMemo(() =>
[...new Set([...Object.keys(serverSync().data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
)
const projectMcpNames = createMemo(() => {
const shared = new Set(globalMcpNames())
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
if (configured.length > 0) return configured.sort()
return Object.keys(sync().data.mcp ?? {})
.filter((name) => !shared.has(name))
.sort()
})
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
const globalPlugins = createMemo(() => (serverSync().data.config.plugin ?? []).map(pluginName))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
})
const [serverSkills] = createResource(
serverSDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const [directorySkills] = createResource(
directorySDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill
.list({ location: { directory: sdk.directory } })
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const projectSkills = createMemo(() => {
const shared = new Set(serverSkills.latest.map(skillKey))
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
})
const mcpRows = (items: string[]) => (
<For each={items}>
{(name) => (
<ExtensionRow icon="mcp" name={name}>
<Switch
checked={mcpEnabled(name)}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
hideLabel
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
>
{name}
</Switch>
</ExtensionRow>
)}
</For>
)
const pluginRows = (items: string[]) => <For each={items}>{(name) => <ExtensionRow icon="cube" name={name} />}</For>
const skillRows = (items: SkillItem[]) => (
<For each={items}>{(item) => <ExtensionRow icon="post-skill" name={item.name} />}</For>
)
return (
<div class="project-settings-extensions">
<div class="project-settings-page-header">
<h2>{language.t("settings.tab.extensions")}</h2>
<span>{language.t("project.settings.extensions.description")}</span>
</div>
<TabsV2 variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
<TabsV2.List>
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
</TabsV2.List>
<TabsV2.Content value="mcps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
</div>
<Show when={projectMcpNames().length > 0}>
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
</Show>
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="plugins">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
</div>
<Show when={projectPlugins().length > 0}>
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
</Show>
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="skills">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
{language.t("settings.extensions.addSkills")}
</ExternalLink>
</div>
<Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show>
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="lsps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
<span>{language.t("project.settings.extensions.lsp.description")}</span>
</div>
<Show when={sync().data.lsp.length > 0}>
<ExtensionCard>
<For each={sync().data.lsp}>
{(item) => (
<ExtensionRow
icon="code"
name={item.name || item.id}
status={
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
}
/>
)}
</For>
</ExtensionCard>
</Show>
</div>
</TabsV2.Content>
</TabsV2>
</div>
)
}
@@ -238,7 +238,6 @@ export function createProviderConnectionController(options: {
return {
loading: () => integration.loading,
integration: () => integration.latest,
methods,
currentMethod,
methodIndex: () => store.methodIndex,
@@ -452,10 +452,7 @@ function SettingsKeybindsV2View(props: {
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.shortcuts.description")}</span>
</div>
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
@@ -1,29 +0,0 @@
import { type ParentProps, Show } from "solid-js"
import { useGlobal } from "@/context/global"
import { ModelsProvider } from "@/context/models"
import { ServerConnection } from "@/context/server"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
const global = useGlobal()
return (
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
{(server) => (
<SettingsServerDataScope server={server} directory={props.directory}>
{props.children}
</SettingsServerDataScope>
)}
</Show>
)
}
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
return (
<ServerSDKProvider server={props.server}>
<ServerSyncProvider server={props.server}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
)
}
@@ -1,134 +0,0 @@
import { Component, createMemo } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useLanguage } from "@/context/language"
import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { createAppearanceSettingsController, type AppearanceSettingsController } from "./general-controllers"
import "./settings-v2.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = {
ui: {
action: "settings-ui-font",
title: "settings.general.row.uiFont.title",
description: "settings.general.row.uiFont.description",
font: "ui",
input: "setUI",
},
code: {
action: "settings-code-font",
title: "settings.general.row.font.title",
description: "settings.general.row.font.description",
font: "code",
input: "setCode",
},
terminal: {
action: "settings-terminal-font",
title: "settings.general.row.terminalFont.title",
description: "settings.general.row.terminalFont.description",
font: "terminal",
input: "setTerminal",
},
} as const
const FontSetting: Component<{
kind: "ui" | "code" | "terminal"
fonts: AppearanceSettingsController["fonts"]
}> = (props) => {
const language = useLanguage()
const config = () => fontSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action={config().action}
type="text"
appearance="base"
value={props.fonts[config().font]().value}
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
placeholder={props.fonts[config().font]().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t(config().title)}
style={{ "font-family": props.fonts[config().font]().family }}
/>
</div>
</SettingsRowV2>
)
}
export const SettingsAppearanceV2: Component = () => {
const language = useLanguage()
const appearance = createAppearanceSettingsController()
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.appearance")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.appearance.description")}</span>
</div>
</div>
</div>
<div class="settings-v2-tab-body">
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={schemeOptions}
current={schemeOptions.find((option) => option === appearance.scheme.current())}
placement="bottom-end"
gutter={6}
label={(option) => {
if (option === "system") return language.t("theme.scheme.system")
if (option === "light") return language.t("theme.scheme.light")
return language.t("theme.scheme.dark")
}}
onSelect={(option) => option && appearance.scheme.select(option)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</ExternalLink>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={appearance.theme.options()}
current={appearance.theme.current()}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => option.name}
onSelect={appearance.theme.select}
/>
</SettingsRowV2>
<FontSetting kind="ui" fonts={appearance.fonts} />
<FontSetting kind="code" fonts={appearance.fonts} />
<FontSetting kind="terminal" fonts={appearance.fonts} />
</SettingsListV2>
</div>
</div>
</>
)
}
@@ -5,22 +5,15 @@ import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { SettingsGeneralV2 } from "./general"
import { SettingsAppearanceV2 } from "./appearance"
import { SettingsKeybinds } from "../settings-keybinds"
import { SettingsNotificationsV2 } from "./notifications"
import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServersV2 } from "./servers"
import { SettingsProjectsV2 } from "./projects"
import { SettingsExtensionsV2 } from "./extensions"
import { SettingsServerScope } from "../settings-server-picker"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
import { useGlobal } from "@/context/global"
import { ServerConnection } from "@/context/server"
import "./settings-v2.css"
export const DialogSettings: Component<{
sessionID?: string
@@ -32,13 +25,8 @@ export const DialogSettings: Component<{
const layout = useLayout()
const tabs = useTabs()
const serverSync = useServerSync()
const global = useGlobal()
const currentServer = global.servers.list().find((server) => global.ensureServerCtx(server).sync === serverSync())
if (currentServer) global.settings.server.set(ServerConnection.key(currentServer))
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const directory = createMemo(() => {
const server = global.settings.server.selected()
if (!server || serverSync() !== global.ensureServerCtx(server).sync) return
const route = layout.route()
if (route.type === "dir-new-sesssion") return route.dir
if (route.type === "draft") {
@@ -64,92 +52,62 @@ export const DialogSettings: Component<{
>
<TabsV2.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-4 w-full">
{/* Group 1: Preferences */}
<div class="flex flex-col gap-1 w-full">
<TabsV2.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.preferences")}
</TabsV2.Trigger>
<TabsV2.Trigger value="appearance">
<Icon name="appearance" />
{language.t("settings.general.section.appearance")}
</TabsV2.Trigger>
<TabsV2.Trigger value="notifications">
<Icon name="notifications" />
{language.t("settings.tab.notifications")}
</TabsV2.Trigger>
<TabsV2.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</TabsV2.Trigger>
</div>
<div class="flex flex-col gap-3 w-full">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.general")}
</TabsV2.Trigger>
<TabsV2.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</TabsV2.Trigger>
</div>
</div>
{/* Group 2: Environment & Workspaces */}
<div class="flex flex-col gap-1 w-full">
<TabsV2.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</TabsV2.Trigger>
<TabsV2.Trigger value="projects">
<Icon name="folder" />
{language.t("settings.tab.projects")}
</TabsV2.Trigger>
</div>
{/* Group 3: Capabilities & Extensions */}
<div class="flex flex-col gap-1 w-full">
<TabsV2.Trigger value="providers">
<Icon name="providers" />
{language.t("settings.providers.title")}
</TabsV2.Trigger>
<TabsV2.Trigger value="models">
<Icon name="models" />
{language.t("settings.models.title")}
</TabsV2.Trigger>
<TabsV2.Trigger value="extensions">
<Icon name="extensions" />
{language.t("settings.tab.extensions")}
</TabsV2.Trigger>
<div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</TabsV2.Trigger>
<TabsV2.Trigger value="providers">
<Icon name="providers" />
{language.t("settings.providers.title")}
</TabsV2.Trigger>
<TabsV2.Trigger value="models">
<Icon name="models" />
{language.t("settings.models.title")}
</TabsV2.Trigger>
</div>
</div>
</div>
</div>
<div class="settings-v2-nav-footer">
<span>{language.t("app.name.desktop")}</span>
<span>v{platform.version}</span>
</div>
</div>
</TabsV2.List>
<TabsV2.Content value="general" class="settings-v2-panel">
<SettingsGeneralV2 sessionID={props.sessionID} />
</TabsV2.Content>
<TabsV2.Content value="appearance" class="settings-v2-panel">
<SettingsAppearanceV2 />
</TabsV2.Content>
<TabsV2.Content value="notifications" class="settings-v2-panel">
<SettingsNotificationsV2 />
</TabsV2.Content>
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="projects" class="settings-v2-panel">
<SettingsProjectsV2 />
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
</TabsV2.Content>
<SettingsServerScope directory={directory()}>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
</TabsV2.Content>
<TabsV2.Content value="extensions" class="settings-v2-panel">
<SettingsExtensionsV2 />
</TabsV2.Content>
</SettingsServerScope>
</TabsV2>
</Dialog>
)
@@ -1,156 +0,0 @@
import { Component, For, createMemo, createResource } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css"
interface McpRowItem {
name: string
enabled: boolean
}
interface PluginRowItem {
name: string
}
export const SettingsExtensionsV2: Component = () => {
const language = useLanguage()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const mcps = createMemo<McpRowItem[]>(() => {
const configMcp = serverSync().data.config.mcp ?? {}
return Object.entries(configMcp).map(([name, config]) => ({
name,
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
}))
})
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
const before = serverSync().data.config.mcp ?? {}
const config = before[item.name]
if (typeof config !== "object" || config === null) return
const next = { ...before, [item.name]: { ...config, enabled: checked } }
serverSync().set("config", "mcp", next)
void serverSync()
.updateConfig({ mcp: next })
.catch(() => serverSync().set("config", "mcp", before))
}
const plugins = createMemo<PluginRowItem[]>(() => {
const raw = serverSync().data.config.plugin ?? []
return raw.map((item) => {
const name = typeof item === "string" ? item : item[0]
return { name }
})
})
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
initialValue: [],
})
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-v2-tab-body">
<TabsV2 variant="pill" defaultValue="mcps" class="settings-v2-extensions-tabs">
<TabsV2.List>
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
</TabsV2.List>
<TabsV2.Content value="mcps">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={mcps()}>
{(item) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
</div>
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
{item.name}
</Switch>
</div>
)}
</For>
</div>
</div>
</TabsV2.Content>
<TabsV2.Content value="plugins">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={plugins()}>
{(plugin) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
</div>
</div>
)}
</For>
</div>
</div>
</TabsV2.Content>
<TabsV2.Content value="skills">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<ExternalLink
class="text-13-regular text-v2-text-accent hover:underline"
href="https://opencode.ai/docs/skills/"
>
{language.t("settings.extensions.addSkills")}
</ExternalLink>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={skills()}>
{(skill) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
</div>
</div>
)}
</For>
</div>
</div>
</TabsV2.Content>
</TabsV2>
</div>
</>
)
}
@@ -279,6 +279,8 @@ export const SettingsGeneralV2: Component<{
const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(() => props.sessionID)
const shell = createShellSettingsController()
const appearance = createAppearanceSettingsController()
const sounds = createSoundSettingsController()
const desktop = createMemo(() => platform.platform === "desktop")
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
@@ -296,7 +298,6 @@ export const SettingsGeneralV2: Component<{
const GeneralSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.general")}</h3>
<SettingsListV2>
<LanguageSetting />
@@ -509,19 +510,18 @@ export const SettingsGeneralV2: Component<{
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.preferences")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("settings.preferences.description")}
</span>
</div>
</div>
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
</div>
<div class="settings-v2-tab-body">
<GeneralSection />
<AppearanceSection controller={appearance} />
<NotificationsSection />
<SoundsSection controller={sounds} />
<Show when={desktop()}>
<UpdatesSection />
</Show>
@@ -11,7 +11,6 @@ import { useModels } from "@/context/models"
import { useServerSDK } from "@/context/server-sdk"
import { popularProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { InlineServerSelect } from "./parts/server-select"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css"
@@ -54,13 +53,7 @@ export const SettingsModelsV2: Component = () => {
return (
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
</div>
<InlineServerSelect />
</div>
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
@@ -1,124 +0,0 @@
import { Component } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { createSoundSettingsController, soundOptions, type SoundSettingsController } from "./general-controllers"
import "./settings-v2.css"
const soundSettings = {
agent: {
action: "settings-sounds-agent",
title: "settings.general.sounds.agent.title",
description: "settings.general.sounds.agent.description",
},
permissions: {
action: "settings-sounds-permissions",
title: "settings.general.sounds.permissions.title",
description: "settings.general.sounds.permissions.description",
},
errors: {
action: "settings-sounds-errors",
title: "settings.general.sounds.errors.title",
description: "settings.general.sounds.errors.description",
},
} as const
const SoundSetting: Component<{
kind: "agent" | "permissions" | "errors"
channel: SoundSettingsController["agent"]
}> = (props) => {
const language = useLanguage()
const config = () => soundSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<SelectV2
appearance="inline"
data-action={config().action}
options={soundOptions}
current={props.channel.current()}
value={(option) => option.id}
label={(option) => language.t(option.label)}
onHighlight={props.channel.highlight}
onSelect={props.channel.select}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
)
}
export const SettingsNotificationsV2: Component = () => {
const language = useLanguage()
const settings = useSettings()
const sounds = createSoundSettingsController()
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.notifications")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("settings.notifications.description")}
</span>
</div>
</div>
</div>
<div class="settings-v2-tab-body">
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsListV2>
<SoundSetting kind="agent" channel={sounds.agent} />
<SoundSetting kind="permissions" channel={sounds.permissions} />
<SoundSetting kind="errors" channel={sounds.errors} />
</SettingsListV2>
</div>
</div>
</>
)
}
@@ -1,49 +0,0 @@
import { Show, createMemo, type Component } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server"
const allServers = { type: "all" } as const
type ServerOption = ServerConnection.Any | typeof allServers
export const InlineServerSelect: Component<{
all?: {
label: string
selected: () => boolean
onSelect: () => void
}
onServerSelect?: () => void
}> = (props) => {
const global = useGlobal()
const options = createMemo<ServerOption[]>(() => [...(props.all ? [allServers] : []), ...global.servers.list()])
const current = () => (props.all?.selected() ? allServers : global.settings.server.selected())
return (
<Show when={options().length > 1}>
<SelectV2
appearance="inline"
data-action="settings-server-select"
options={options()}
current={current()}
value={(server) => (server.type === "all" ? server.type : ServerConnection.key(server))}
label={(server) =>
server.type === "all" ? (props.all?.label ?? "") : serverName(server) || ServerConnection.key(server)
}
optionDisabled={(server) =>
server.type === "all" ? false : global.servers.health[ServerConnection.key(server)]?.healthy === false
}
placement="bottom-end"
gutter={6}
onSelect={(server) => {
if (!server) return
if (server.type === "all") {
props.all?.onSelect()
return
}
global.settings.server.set(ServerConnection.key(server))
props.onServerSelect?.()
}}
/>
</Show>
)
}
@@ -1,139 +0,0 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useGlobal } from "@/context/global"
import { getProjectAvatarVariant } from "@/context/layout"
import { ServerConnection, serverName } from "@/context/server"
import { displayName } from "@/pages/layout/helpers"
import { InlineServerSelect } from "./parts/server-select"
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
import "./settings-v2.css"
export const SettingsProjectsV2: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const global = useGlobal()
const [allServers, setAllServers] = createSignal(true)
const selected = global.settings.server.selected
const projects = createMemo(() => {
const server = selected()
if (!server) return []
return global.ensureServerCtx(server).projects.list()
})
type ProjectItem = ReturnType<typeof projects>[number]
const groups = createMemo(() =>
global.servers
.list()
.map((server) => ({ server, projects: global.ensureServerCtx(server).projects.list() }))
.filter((group) => group.projects.length > 0),
)
const openProjectSettings = (project: ProjectItem, server = selected()) => {
if (!server) return
dialog.push(() => <DialogEditProjectV2 project={project} server={server} />)
}
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
const name = () => displayName(props.project)
const color = () => getProjectAvatarVariant(props.project.icon?.color)
return (
<div
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
onClick={() => openProjectSettings(props.project, props.server)}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectAvatar fallback={name()} variant={color()} class="shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
</div>
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
icon={<IconV2 name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
onClick={(event: MouseEvent) => {
event.stopPropagation()
openProjectSettings(props.project, props.server)
}}
/>
</div>
</div>
)
}
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.projects.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
</div>
<InlineServerSelect
all={{
label: language.t("settings.projects.server.all"),
selected: allServers,
onSelect: () => setAllServers(true),
}}
onServerSelect={() => setAllServers(false)}
/>
</div>
</div>
<div class="settings-v2-tab-body">
<Show
when={allServers()}
fallback={
<div class="flex flex-col gap-2 w-full">
<Show
when={projects().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<Show when={selected()} keyed>
{(server) => (
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
)}
</Show>
</Show>
</div>
}
>
<div class="flex flex-col gap-8 w-full">
<Show
when={groups().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<For each={groups()}>
{(group) => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">
{serverName(group.server) || ServerConnection.key(group.server)}
</h3>
<div class="flex flex-col gap-2 w-full">
<For each={group.projects}>
{(project) => <ProjectRow project={project} server={group.server} />}
</For>
</div>
</div>
)}
</For>
</Show>
</div>
</Show>
</div>
</>
)
}
@@ -10,8 +10,6 @@ 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"
@@ -44,23 +42,13 @@ export const SettingsProvidersV2: Component<{
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => (
<SettingsServerScope directory={props.directory}>
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
</SettingsServerScope>
))
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
}
const connected = createMemo(() => {
return providers.connected().filter(
(provider) =>
provider.id !== "opencode" ||
Object.values(provider.models).some((model) => {
if (typeof model !== "object" || model === null || !("cost" in model)) return false
const cost = model.cost
return typeof cost === "object" && cost !== null && "input" in cost
}),
)
return providers
.connected()
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
})
const popular = createMemo(() => {
@@ -104,6 +92,29 @@ 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()
@@ -130,13 +141,7 @@ export const SettingsProvidersV2: Component<{
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
</div>
<InlineServerSelect />
</div>
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
</div>
<div class="settings-v2-tab-body settings-v2-providers">
@@ -239,11 +244,7 @@ export const SettingsProvidersV2: Component<{
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => (
<SettingsServerScope directory={props.directory}>
<DialogCustomProvider onBack={dialog.close} />
</SettingsServerScope>
))
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
@@ -53,10 +53,7 @@ export const SettingsServersV2: Component = () => {
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
>
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
</div>
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<AddServerMenu onAddServer={openAdd} />
</div>
<Show when={showSearch()}>
@@ -39,14 +39,6 @@
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
}
.settings-v2-tab-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.settings-v2-tab-title {
font-size: 15px;
font-weight: 640;
@@ -182,13 +174,13 @@
max-width: 100%;
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] > [data-slot="tabs-v2-list"] {
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
background-color: var(--v2-background-bg-layer-01);
}
@media (max-width: 639px) {
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
[data-slot="tabs-v2-list"] {
width: 144px;
min-width: 144px;
padding-inline: 8px;
@@ -735,24 +727,3 @@
line-height: 1;
color: var(--v2-state-fg-danger);
}
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: 280px;
padding-inline: 0 !important;
}
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
display: none;
}
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"] {
border-radius: 6px;
}
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger"] {
padding-inline: 8px;
}
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: "src/new.ts",
event: "add",
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: "src/open.ts",
event: "change",
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: "src",
event: "change",
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: "src/file.ts",
event: "change",
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "filesystem.changed",
type: "file.watcher.updated",
properties: {
file: ".git/index.lock",
event: "change",
+1 -1
View File
@@ -16,7 +16,7 @@ type WatcherOps = {
}
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "filesystem.changed") return
if (event.type !== "file.watcher.updated") return
const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined
+114 -130
View File
@@ -6,8 +6,6 @@ import type {
CommandInfo,
CommandListInput,
CommandListOutput,
IntegrationListInput,
IntegrationListOutput,
LocationGetInput,
LocationGetOutput,
PermissionRequest,
@@ -26,9 +24,9 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State } from "./types"
import type { State, VcsCache } from "./types"
import type { ServerSession } from "../server-session"
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import { cmp, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
@@ -223,10 +221,6 @@ type CommandListApi = {
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
}
type IntegrationListApi = {
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
}
type ReferenceListApi = {
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
}
@@ -237,13 +231,6 @@ export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: Agen
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
})
export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) =>
queryOptions({
queryKey: [scope, directory, "integrations"] as const,
queryFn: () =>
retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)),
})
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
retry(() => api.list({ location: { directory } }).then((result) => result.data))
@@ -285,6 +272,7 @@ export async function bootstrapDirectory(input: {
}
store: Store<State>
setStore: SetStoreFunction<State>
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string
global: {
@@ -309,109 +297,104 @@ export async function bootstrapDirectory(input: {
const revKey = ScopedKey.from(input.scope, input.directory)
const rev = (providerRev.get(revKey) ?? 0) + 1
providerRev.set(revKey, rev)
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
.then((data) => input.setStore("agent", data)),
!seededProject &&
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.setStore("project", project.id),
)),
!seededPath &&
(() =>
;(async () => {
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
input.mcp &&
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
() =>
input.queryClient.fetchQuery(
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference),
),
() =>
retry(() =>
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
const value = reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
)
if (input.session) input.session.set("permission", sessionID, value)
if (!input.session) input.setStore("permission", sessionID, value)
}
}),
)
}),
),
() =>
retry(() =>
input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((questions) => {
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
const value = reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
)
if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value)
}
}),
)
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
input.mcp &&
(() =>
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
() =>
input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
.catch((err) => {
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
.then((data) => input.setStore("agent", data)),
!seededProject &&
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.setStore("project", project.id),
)),
!seededPath &&
(() =>
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
input.mcp &&
(() =>
loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.api.reference)),
() =>
retry(() =>
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
const value = reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
)
if (input.session) input.session.set("permission", sessionID, value)
if (!input.session) input.setStore("permission", sessionID, value)
}
}),
)
}),
),
() =>
retry(() =>
input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((questions) => {
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
const value = reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
)
if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value)
}
}),
)
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
() =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api)).catch((err) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
@@ -419,19 +402,20 @@ export async function bootstrapDirectory(input: {
description: formatServerError(err, input.translate),
})
}),
].filter(Boolean) as (() => Promise<any>)[]
].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint()
const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate),
})
}
await waitForPaint()
const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate),
})
}
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
})()
}
@@ -8,7 +8,6 @@ import { ServerScope } from "@/utils/server-scope"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
let providerQuerySuccess = true
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
store[0],
store[1],
@@ -59,17 +58,11 @@ beforeAll(async () => {
get isLoading() {
return options().queryKey?.[1] === "path"
},
get isSuccess() {
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess
},
get isRefetchError() {
return false
},
get data() {
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
if (options().queryKey?.[1] === "lsp") return []
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
if (options().queryKey?.[1] === "providers") return provider
return undefined
},
}
@@ -90,7 +83,6 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -122,7 +114,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -157,7 +148,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -183,37 +173,6 @@ describe("createChildStoreManager", () => {
}
})
test("writes refreshed VCS data to the child store", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project", { bootstrap: false })
manager.vcs("/project", { branch: "feature", default_branch: "main" })
expect(store.vcs).toEqual({ branch: "feature", default_branch: "main" })
} finally {
dispose()
}
})
test("enables MCP only when requested for the directory", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = querySingles.length
@@ -222,7 +181,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -272,7 +230,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
@@ -316,71 +273,4 @@ describe("createChildStoreManager", () => {
dispose()
}
})
test("does not mark a cancelled provider query as ready", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
providerQuerySuccess = false
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/cancelled")
expect(store.provider_ready).toBe(false)
} finally {
providerQuerySuccess = true
dispose()
}
})
test("does not enable location queries before the event handshake", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
let connected = false
const offset = querySingles.length
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => connected,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
manager.child("/handshake")
const queries = querySingles.slice(offset)
expect(queries[0]?.().enabled).toBe(false)
expect(queries[4]?.().enabled).toBe(false)
connected = true
expect(queries[0]?.().enabled).toBe(true)
expect(queries[4]?.().enabled).toBe(true)
} finally {
dispose()
}
})
})
@@ -1,4 +1,4 @@
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@/types"
@@ -22,7 +22,6 @@ import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: {
owner: Owner
connected: Accessor<boolean>
scope: ServerScope
persist: typeof persisted
isBooting: (directory: string) => boolean
@@ -189,29 +188,17 @@ export function createChildStoreManager(input: {
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const pathQuery = useQuery(() => ({
...input.queryOptions.path(key),
enabled: input.connected() && instanceQueriesEnabled(),
}))
const mcpQuery = useQuery(() => ({
...input.queryOptions.mcp(key),
enabled: input.connected() && mcpEnabled(),
}))
const mcpResourceQuery = useQuery(() => ({
...input.queryOptions.mcpResources(key),
enabled: input.connected() && mcpEnabled(),
}))
const lspQuery = useQuery(() => ({
...input.queryOptions.lsp(key),
enabled: input.connected() && instanceQueriesEnabled(),
}))
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: input.connected() && instanceQueriesEnabled(),
enabled: instanceQueriesEnabled(),
}))
const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key),
enabled: input.connected() && instanceQueriesEnabled(),
enabled: instanceQueriesEnabled(),
}))
const child = createStore<State>({
@@ -219,14 +206,13 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError)
return instanceQueriesEnabled() && !providerQuery.isLoading
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY
const provider = providerQuery.data
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
return provider
if (providerQuery.isLoading) return EMPTY
if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
return providerQuery.data ?? EMPTY
},
config: {},
get path() {
@@ -387,15 +373,6 @@ export function createChildStoreManager(input: {
setStore("icon", value)
}
function vcs(directory: string, value: VcsInfo) {
const key = directoryKey(directory)
const child = ensureChild(directory)
const cached = vcsCache.get(key)
if (!cached) return
cached.setStore("value", value)
child[1]("vcs", value)
}
return {
children,
ensureChild,
@@ -403,7 +380,6 @@ export function createChildStoreManager(input: {
peek,
projectMeta,
projectIcon,
vcs,
mark,
pin,
unpin,
@@ -120,7 +120,7 @@ describe("applyGlobalEvent", () => {
expect(refreshCount).toBe(1)
})
test("leaves server.connected refresh to the connection sync", () => {
test("handles server.connected by triggering refresh", () => {
let refreshCount = 0
applyGlobalEvent({
event: { type: "server.connected" },
@@ -131,7 +131,7 @@ describe("applyGlobalEvent", () => {
setGlobalProject() {},
})
expect(refreshCount).toBe(0)
expect(refreshCount).toBe(1)
})
})
@@ -38,10 +38,11 @@ export function applyGlobalEvent(input: {
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed") {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
input.refresh()
return
}
if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (s) => s.id)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -34,7 +34,6 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
@@ -45,7 +44,6 @@ describe("app session cache", () => {
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] },
form: { ses_1: [] as FormInfo[] },
part_text_accum_delta: { prt_1: "streamed text" },
}
@@ -59,7 +57,6 @@ describe("app session cache", () => {
expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.form.ses_1).toBeUndefined()
})
test("dropSessionCaches clears message-backed parts", () => {
@@ -73,7 +70,6 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
session_status: {},
@@ -84,7 +80,6 @@ describe("app session cache", () => {
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
question: {},
form: {},
part_text_accum_delta: {},
}
@@ -1,5 +1,5 @@
import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -14,7 +14,6 @@ type SessionCache = {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form?: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
}
@@ -39,7 +38,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
delete store.session_status[sessionID]
delete store.permission[sessionID]
delete store.question[sessionID]
if (store.form) delete store.form[sessionID]
}
}
+11
View File
@@ -7,6 +7,7 @@ import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
@@ -97,6 +98,15 @@ function createServerCtx(
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnReconnect: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
})
const sdk = createServerSdkContext(conn, scope)
const sync = createServerSyncContext(sdk)
@@ -131,6 +141,7 @@ function createServerCtx(
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
return {
queryClient,
sdk,
sync,
isLocal,
-2
View File
@@ -33,8 +33,6 @@ type PluralKey =
| "session.question.pending"
| "session.followupDock.summary"
| "session.revertDock.summary"
| "session.background.shell"
| "session.background.subagent"
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
+4 -9
View File
@@ -398,20 +398,15 @@ function createServerNotificationState(input: {
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.interrupted" &&
event.type !== "session.execution.failed"
)
return
if (event.type !== "session.idle" && event.type !== "session.execution.failed") return
const directory = e.name
const time = Date.now()
if (event.type === "session.execution.failed") {
handleSessionError(directory, event, time)
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionIdle(directory, event, time)
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
+52 -133
View File
@@ -3,8 +3,7 @@ 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 { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { batch, createMemo, onCleanup, onMount } from "solid-js"
import { createApiForServer, type ServerApi } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
@@ -13,6 +12,10 @@ import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract<
@@ -92,20 +95,15 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
}
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
type ServerSDKBase = {
server: ServerConnection.Any
scope: ServerScope
url: string
api: ServerApi
connection: {
status: Accessor<ServerConnectionStatus>
attempt: Accessor<number>
error: Accessor<string | undefined>
}
event: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
start: () => Promise<void> | undefined
}
}
@@ -132,15 +130,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const CONNECT_TIMEOUT_MS = 2_000
const RECONNECT_DELAY_MS = 1_000
const RECONNECT_DELAY_MS = 250
let queue: Queued[] = []
let buffer: Queued[] = []
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
function flush() {
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
@@ -160,132 +157,63 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
buffer.length = 0
}
function schedule() {
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
function publish(event: OpenCodeEvent) {
const directory = event.location?.directory ?? "global"
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
}
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
const [connection, setConnection] = createStore<{
status: ServerConnectionStatus
attempt: number
error?: string
}>({ status: "connecting", attempt: 0 })
async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> {
let connectedAt: number | undefined
// Bound the initial handshake and tie this request to the stream lifetime.
const request = new AbortController()
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
signal.addEventListener("abort", cancel, { once: true })
try {
// Open the event stream and validate its initial handshake.
const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done) {
const error =
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
return { error, connectedAt }
}
if (first.value.type !== "server.connected")
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
// Publish the connected state before forwarding live events.
clearTimeout(timeout)
publish(first.value)
connectedAt = Date.now()
setConnection({ status: "connected", attempt: 0, error: undefined })
// Forward events until the stream closes or this connection is cancelled.
let yielded = Date.now()
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
publish(event.value)
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0, signal)
}
return { error: undefined, connectedAt }
} catch (error) {
return { error, connectedAt }
} finally {
request.abort()
clearTimeout(timeout)
signal.removeEventListener("abort", cancel)
}
}
async function runStream(active: number) {
let retries = 0
// oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
while (!abort.signal.aborted && started && generation === active) {
setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
const controller = new AbortController()
attempt = controller
const onAbort = () => controller.abort()
abort.signal.addEventListener("abort", onAbort)
const result = await connect(controller.signal)
abort.signal.removeEventListener("abort", onAbort)
if (abort.signal.aborted || !started || generation !== active) {
if (attempt === controller) attempt = undefined
return
}
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
retries += 1
const message =
result.error === undefined
? undefined
: result.error instanceof Error
? result.error.message
: String(result.error)
console.info("[global-sdk] event stream disconnected", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
attempt: retries,
error: message,
})
setConnection({ status: "reconnecting", attempt: retries, error: message })
await wait(RECONNECT_DELAY_MS, controller.signal)
if (attempt === controller) attempt = undefined
}
}
function start() {
const start = () => {
if (started) return run
started = true
const active = ++generation
const previous = run
const current = (async () => {
if (previous) await previous
await runStream(active)
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = eventApi.event.subscribe({ signal: attempt.signal })
let yielded = Date.now()
for await (const event of events) {
streamErrorLogged = false
const directory = event.location?.directory ?? "global"
const payload = adaptServerEvent(event)
if (enqueueServerEvent(queue, { directory, payload })) schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
}
if (abort.signal.aborted || !started || generation !== active) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
if (run !== current) return
run = undefined
@@ -295,7 +223,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
return run
}
function stop() {
const stop = () => {
started = false
generation++
attempt?.abort()
@@ -304,17 +232,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
onMount(() => {
makeEventListener(window, "pagehide", stop)
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
void start()
})
onCleanup(() => {
stop()
abort.abort()
if (timer) clearTimeout(timer)
timer = undefined
queue = []
buffer = []
emitter.clear()
flush()
})
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
@@ -324,14 +247,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
scope,
url: server.http.url,
api,
connection: {
status: () => connection.status,
attempt: () => connection.attempt,
error: () => connection.error,
},
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
}
}
@@ -211,58 +211,6 @@ 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, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionInboxItem, SessionMessageInfo } from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -8,18 +8,13 @@ export type V2SessionReduction = {
sessionID: string
messages: SessionMessageInfo[]
touched: string[]
removed?: string[]
missing?: string
}
export function createV2SessionReducer() {
const pending = new Map<string, SessionInboxItem>()
const reduce = (
source: readonly SessionMessageInfo[],
event: OpenCodeEvent,
session?: Pick<SessionInfo, "location" | "projectID" | "subpath">,
): V2SessionReduction | undefined => {
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID
const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({
@@ -33,39 +28,13 @@ export function createV2SessionReducer() {
switch (event.type) {
case "session.inbox.enqueued":
pending.set(key(sessionID, event.data.inboxID), event.data.item)
if (event.data.item.type === "user")
return append({
id: event.data.inboxID,
type: "user",
metadata: event.data.item.payload.metadata,
text: event.data.item.payload.text,
files: event.data.item.payload.files,
agents: event.data.item.payload.agents,
time: { created: event.created },
})
if (event.data.item.type !== "synthetic") return result([...source])
return append({
id: event.data.inboxID,
type: "synthetic",
metadata: event.data.item.payload.metadata,
text: event.data.item.payload.text,
description: event.data.item.payload.description,
time: { created: event.created },
})
return result([...source])
case "session.inbox.cancelled":
pending.delete(key(sessionID, event.data.inboxID))
return {
...result(source.filter((item) => item.id !== event.data.inboxID)),
removed: source.some((item) => item.id === event.data.inboxID) ? [event.data.inboxID] : [],
}
return
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({
@@ -115,32 +84,6 @@ export function createV2SessionReducer() {
)?.model,
time: { created: event.created },
})
case "session.moved":
if (!session) return
return append({
id: messageID(event.id),
type: "location-switched",
metadata: event.metadata,
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous: {
location: session.location,
projectID: session.projectID,
subpath: session.subpath,
},
time: { created: event.created },
})
case "session.instructions.updated":
if (event.data.text === undefined) return
return append({
id: messageID(event.id),
type: "system",
text: event.data.text,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
})
case "session.synthetic":
return append({
id: messageID(event.id),
@@ -460,11 +403,8 @@ export function createV2SessionReducer() {
type: "compaction",
status: "failed",
metadata: current?.metadata ?? event.metadata,
reason: event.data.reason ?? "manual",
error: event.data.error ?? {
type: "compaction.failed",
message: "Compaction failed before recording an error",
},
reason: event.data.reason,
error: event.data.error,
time: current?.time ?? { created: event.created },
}
if (!current) return append(failed)
@@ -1,10 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry"
import type {
FormInfo,
OpenCodeEvent,
SessionApi,
SessionInboxInfo,
SessionInfo,
SessionMessageAssistant,
SessionMessageAssistantTool,
@@ -350,187 +348,6 @@ describe("server session", () => {
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
})
test("projects V2 pending inputs and forms", () => {
const ctx = setup({ child: session("child") })
const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
apply({
id: "evt_admitted",
created: 1,
type: "session.inbox.enqueued",
data: {
sessionID: "child",
inboxID: "msg_input",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
})
apply({
id: "evt_form",
created: 2,
type: "form.created",
data: { form: { id: "frm_1", sessionID: "child", title: "Choose", fields: [] } },
})
expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input", delivery: "steer" }])
expect(ctx.store.data.input.child).toEqual(["msg_input"])
expect(ctx.store.data.form.child).toMatchObject([{ id: "frm_1", title: "Choose" }])
expect(ctx.store.data.message.child).toMatchObject([{ id: "msg_input" }])
apply({
id: "evt_cancelled",
created: 3,
type: "session.inbox.cancelled",
data: { sessionID: "child", inboxID: "msg_input" },
})
apply({
id: "evt_form_done",
created: 4,
type: "form.cancelled",
data: { sessionID: "child", id: "frm_1" },
})
expect(ctx.store.data.pending.child).toEqual([])
expect(ctx.store.data.input.child).toEqual([])
expect(ctx.store.data.form.child).toEqual([])
expect(ctx.store.data.message.child).toEqual([])
})
test("does not let transient hydration overwrite newer events", async () => {
const ctx = setup({ child: session("child") })
const response = Promise.withResolvers<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>()
const form: FormInfo = {
id: "frm_1",
sessionID: "child",
title: "Choose",
fields: [{ key: "choice", type: "string" as const, title: "Choice" }],
}
let loads = 0
const hydration = ctx.store.hydrateTransient("child", () => {
loads += 1
if (loads === 1) return response.promise
return Promise.resolve({ pending: ctx.store.data.pending.child ?? [], forms: [form] })
})
ctx.store.applyV2({
id: "evt_admitted",
created: 1,
type: "session.inbox.enqueued",
data: {
sessionID: "child",
inboxID: "msg_input",
item: { type: "user", delivery: "queue", payload: { text: "new" } },
},
} as OpenCodeEvent)
response.resolve({
pending: [],
forms: [form],
})
await hydration
expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input" }])
expect(ctx.store.data.form.child).toMatchObject([{ id: "frm_1" }])
expect(loads).toBe(2)
})
test("removes only the compaction input named by the start event", () => {
const ctx = setup({ child: session("child") })
const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
apply({
id: "evt_compaction_inbox",
created: 1,
type: "session.inbox.enqueued",
data: {
sessionID: "child",
inboxID: "msg_compaction",
item: { type: "compaction", delivery: "queue", payload: { reason: "manual" } },
},
})
expect(ctx.store.data.input.child).toBeUndefined()
expect(ctx.store.data.pending.child).toHaveLength(1)
apply({
id: "evt_compaction_inbox_2",
created: 2,
type: "session.inbox.enqueued",
data: {
sessionID: "child",
inboxID: "msg_compaction_2",
item: { type: "compaction", delivery: "queue", payload: { reason: "manual" } },
},
})
apply({
id: "evt_compaction_ended",
created: 3,
type: "session.compaction.ended",
data: { sessionID: "child", reason: "manual", text: "summary", recent: "recent" },
})
expect(ctx.store.data.pending.child).toHaveLength(2)
apply({
id: "evt_compaction_started",
created: 4,
type: "session.compaction.started",
data: { sessionID: "child", inputID: "msg_compaction", reason: "manual" },
})
expect(ctx.store.data.pending.child?.map((item) => item.id)).toEqual(["msg_compaction_2"])
})
test("projects committed revert before server reconciliation", () => {
const ctx = setup({ child: session("child") })
ctx.store.remember({ ...session("child"), revert: { messageID: "msg_2", partID: "prt_1" } })
ctx.store.set("input", "child", ["msg_1", "msg_2"])
ctx.store.set("session_message", "child", [
{ id: "msg_1", type: "user", text: "keep", time: { created: 1 } },
{ id: "msg_2", type: "user", text: "remove", time: { created: 2 } },
])
ctx.store.applyV2({
id: "evt_revert",
created: 3,
type: "session.revert.committed",
data: { sessionID: "child", to: "msg_2" },
} as OpenCodeEvent)
expect(ctx.store.data.info.child?.revert).toBeUndefined()
expect(ctx.store.data.input.child).toEqual(["msg_1"])
expect(ctx.store.data.session_message.child?.map((message) => message.id)).toEqual(["msg_1"])
})
test("does not restore a message hydrated before a committed revert", async () => {
const response = Promise.withResolvers<SessionMessageInfo>()
const store = createServerSession({
session: {
get: async () => session("child"),
message: () => response.promise,
} as unknown as SessionApi,
message: {
list: async () => currentPage({ data: [], response: { headers: new Headers() } }),
} as unknown as MessageApi,
})
store.remember(session("child"))
store.applyV2({
id: "evt_delivered",
created: 1,
type: "session.inbox.delivered",
data: { sessionID: "child", inboxID: "msg_2" },
} as OpenCodeEvent)
store.applyV2({
id: "evt_revert",
created: 2,
type: "session.revert.committed",
data: { sessionID: "child", to: "msg_2" },
} as OpenCodeEvent)
response.resolve({ id: "msg_2", type: "user", text: "stale", time: { created: 1 } })
await response.promise
await Bun.sleep(0)
expect(store.data.session_message.child ?? []).toEqual([])
})
test("resolves lineage by session ID without directory", async () => {
const ctx = setup({ child: session("child", "root"), root: session("root") })
@@ -551,17 +368,6 @@ describe("server session", () => {
expect(ctx.store.data.message.root).toEqual([])
})
test("reloads cached sessions after reconnect invalidation", async () => {
const ctx = setup({ root: session("root") })
await ctx.store.sync("root")
ctx.store.invalidate()
await ctx.store.sync("root")
expect(ctx.get).toHaveLength(2)
expect(ctx.messages).toHaveLength(2)
})
test("loads current session content through the current message API", async () => {
const requests: unknown[] = []
const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } }
+8 -142
View File
@@ -1,14 +1,7 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { retry } from "@opencode-ai/core/util/retry"
import type {
FormInfo,
OpenCodeEvent,
SessionApi,
SessionInfo,
SessionInboxInfo,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Message, Part, Todo } from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import { batch } from "solid-js"
@@ -199,9 +192,6 @@ export function createServerSession(
todo: {} as Record<string, Todo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
form: {} as Record<string, FormInfo[]>,
pending: {} as Record<string, SessionInboxInfo[]>,
input: {} as Record<string, string[]>,
message: {} as Record<string, Message[]>,
session_message: {} as Record<string, SessionMessageInfo[]>,
part: {} as Record<string, Part[]>,
@@ -215,11 +205,6 @@ export function createServerSession(
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const v2 = createV2SessionReducer()
const pendingRevision = new Map<string, number>()
const formRevision = new Map<string, number>()
const messageHydrationRevision = new Map<string, number>()
const invalidated = new Set<string>()
let invalidationRevision = 0
const messageLoads = new Map<string, MessageLoadState>()
const pendingParts = new Map<string, Map<string, Set<string>>>()
const orphanParts = new Map<string, Set<string>>()
@@ -278,9 +263,6 @@ export function createServerSession(
...Object.entries(data.question)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.form)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.session_status)
.filter(([, status]) => status.type !== "idle")
.map(([sessionID]) => sessionID),
@@ -482,7 +464,6 @@ export function createServerSession(
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
}
sessionIDs.forEach((sessionID) => {
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
generations.delete(sessionID)
clearOptimistic(sessionID)
requests.delete(sessionID)
@@ -526,9 +507,6 @@ export function createServerSession(
...Object.entries(data.question)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.form)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.session_status)
.filter(([, status]) => status.type !== "idle")
.map(([sessionID]) => sessionID),
@@ -820,16 +798,13 @@ export function createServerSession(
touch(sessionID)
return runInflight(inflight, sessionID, async () => {
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
const invalid = invalidated.has(sessionID)
const revision = invalidationRevision
if (cached && data.info[sessionID] && !invalid && !options?.force) return
if (cached && data.info[sessionID] && !options?.force) return
await Promise.all([
resolve(sessionID, invalid ? { ...options, force: true } : options),
cached && !invalid && !options?.force
resolve(sessionID, options),
cached && !options?.force
? Promise.resolve()
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
])
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
})
}
@@ -869,7 +844,7 @@ export function createServerSession(
const projectV2 = (reduction: V2SessionReduction) => {
reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID))
setData("session_message", reduction.sessionID, reconcile(reduction.messages))
if (reduction.touched.length === 0 && !reduction.removed?.length) return
if (reduction.touched.length === 0) return
const touched = new Set(reduction.touched)
let parentID: string | undefined
@@ -886,9 +861,6 @@ export function createServerSession(
const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages)
batch(() => {
for (const messageID of reduction.removed ?? []) {
apply({ type: "message.removed", properties: { sessionID: reduction.sessionID, messageID } })
}
for (const message of normalized.messages) {
if (!touched.has(message.id)) continue
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
@@ -912,14 +884,9 @@ export function createServerSession(
const hydrateV2Message = (sessionID: string, messageID: string) => {
if (!sessionApi) return
const active = generation(sessionID)
const revision = messageHydrationRevision.get(sessionID) ?? 0
void sessionApi
.message({ sessionID, messageID })
.then((message) => {
if (generations.get(sessionID) !== active) return
if ((messageHydrationRevision.get(sessionID) ?? 0) !== revision) return
if (removedMessages.get(sessionID)?.has(message.id)) return
const current = data.session_message[sessionID] ?? []
const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages)
projectV2({ sessionID, messages, touched: [message.id] })
@@ -928,18 +895,6 @@ 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
@@ -953,61 +908,19 @@ export function createServerSession(
}
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID
if (
event.type === "session.inbox.enqueued" ||
event.type === "session.inbox.delivery.changed" ||
event.type === "session.inbox.cancelled" ||
event.type === "session.inbox.delivered" ||
event.type === "session.compaction.started" ||
event.type === "session.compaction.failed"
)
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
if (event.type === "session.inbox.enqueued") {
const current = data.pending[sessionID] ?? []
if (!current.some((item) => item.id === event.data.inboxID))
setData("pending", sessionID, [
...current,
{ id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item },
])
if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
}
if (event.type === "session.inbox.delivery.changed")
setData("pending", sessionID, (items) =>
items?.map((item) => (item.id === event.data.inboxID ? { ...item, delivery: event.data.delivery } : item)),
)
if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") {
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== event.data.inboxID))
setData("input", sessionID, (items) => items?.filter((id) => id !== event.data.inboxID))
}
if (event.type === "session.compaction.started" || event.type === "session.compaction.failed") {
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== event.data.inputID))
setData("input", sessionID, (items) => items?.filter((id) => id !== event.data.inputID))
}
const info = data.info[sessionID]
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event, info)
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
if (reduction) {
projectV2(reduction)
if (reduction.missing) hydrateV2Message(sessionID, reduction.missing)
}
if (event.type === "session.agent.selected" && info) remember({ ...info, agent: event.data.agent })
if (event.type === "session.model.selected") {
if (info) remember({ ...info, model: event.data.model })
if (data.session_message[sessionID]) hydrateV2Message(sessionID, event.id.replace(/^evt_/, "msg_"))
}
const info = data.info[sessionID]
if (event.type === "session.renamed" && info)
remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } })
if (event.type === "session.renamed" && !info)
void resolve(sessionID)
.then((current) =>
remember({ ...current, title: event.data.title, time: { ...current.time, updated: event.created } }),
)
.catch(() => undefined)
if (event.type === "session.moved" && info)
remember({
...info,
projectID: event.data.projectID,
projectID: event.data.projectID ?? info.projectID,
location: event.data.location,
subpath: event.data.subpath,
time: { ...info.time, updated: event.created },
@@ -1033,29 +946,12 @@ export function createServerSession(
next: event.data.at,
})
if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {})
if (event.type === "session.revert.staged" && info) remember({ ...info, revert: event.data.revert })
if (event.type === "session.revert.cleared" && info) remember({ ...info, revert: undefined })
if (event.type === "session.revert.committed") {
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
if (info) remember({ ...info, revert: undefined })
setData("input", sessionID, (items) => items?.filter((id) => id < event.data.to))
const source = data.session_message[sessionID] ?? []
const removed = source.filter((message) => message.id >= event.data.to).map((message) => message.id)
removedMessages.set(sessionID, new Set([...(removedMessages.get(sessionID) ?? []), ...removed]))
projectV2({
sessionID,
messages: source.filter((message) => message.id < event.data.to),
touched: [],
removed,
})
}
if (
event.type === "session.revert.staged" ||
event.type === "session.revert.cleared" ||
event.type === "session.revert.committed"
)
void resolve(sessionID, { force: true }).catch(() => {})
if (event.type === "session.revert.committed") void sync(sessionID, { force: true }).catch(() => {})
}
const apply = (event: { type: string; properties?: unknown }) => {
@@ -1384,36 +1280,6 @@ export function createServerSession(
},
},
sync,
async hydrateTransient(sessionID: string, load: () => Promise<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>) {
while (true) {
const pendingAt = pendingRevision.get(sessionID) ?? 0
const formAt = formRevision.get(sessionID) ?? 0
const result = await load()
const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt
const formStable = (formRevision.get(sessionID) ?? 0) === formAt
if (pendingStable) {
setData("pending", sessionID, reconcile(result.pending))
setData(
"input",
sessionID,
reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
}
if (formStable) setData("form", sessionID, reconcile(result.forms))
if (pendingStable && formStable) return
}
},
refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) {
return Promise.all(
[...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
).then(() => undefined)
},
invalidate() {
invalidationRevision += 1
Object.keys(data.info).forEach((sessionID) => invalidated.add(sessionID))
Object.keys(data.message).forEach((sessionID) => invalidated.add(sessionID))
setMeta("at", {})
},
prefetch,
shouldPrefetch(sessionID: string, limit: number) {
if (data.message[sessionID] === undefined) return true
+1 -18
View File
@@ -9,13 +9,7 @@ import type {
import { QueryClient } from "@tanstack/solid-query"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
import {
loadActiveSessionsQuery,
loadMcpQuery,
loadMcpResourcesQuery,
reconcileActiveSessionStatuses,
seedActiveSessionStatuses,
} from "./server-sync"
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server"
@@ -105,17 +99,6 @@ describe("active session query", () => {
next: 10,
})
})
test("replaces stale active statuses after reconnect", () => {
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
session.set("session_status", "ses_stale", { type: "busy" })
session.set("session_status", "ses_active", { type: "idle" })
reconcileActiveSessionStatuses(session, { ses_active: { type: "running" } })
expect(session.data.session_status.ses_stale).toEqual({ type: "idle" })
expect(session.data.session_status.ses_active).toEqual({ type: "busy" })
})
})
describe("pickDirectoriesToEvict", () => {
+110 -219
View File
@@ -1,11 +1,11 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { batch, createMemo, getOwner, onCleanup, untrack } from "solid-js"
import { batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { type ServerEvent, type ServerSDK } from "./server-sdk"
import { ServerSDK } from "./server-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
@@ -13,7 +13,6 @@ import {
loadAgentsQuery,
loadCommands,
loadGlobalConfigQuery,
loadIntegrationsQuery,
loadPathQuery,
loadProjectsQuery,
loadProvidersQuery,
@@ -30,7 +29,7 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from
import type { SolidQueryOptions } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { pathKey, PathKey } from "@/utils/path-key"
import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
@@ -54,8 +53,6 @@ import type {
} from "@opencode-ai/client/promise"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session"
import { createCatalogSync } from "./server-sync/catalog"
import { createConnectionSync } from "./server-sync/connection"
import { usePlatform } from "./platform"
type GlobalStore = {
@@ -69,16 +66,6 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
const SESSION_LIST_EVENTS = new Set([
"session.created",
"session.updated",
"session.deleted",
"session.moved",
"session.forked",
"session.renamed",
"session.usage.updated",
])
type McpListApi = {
readonly list: (input?: McpListInput) => Promise<McpListOutput>
}
@@ -172,22 +159,11 @@ export function seedActiveSessionStatuses(
}
}
export function reconcileActiveSessionStatuses(
session: Pick<ServerSession, "data" | "set">,
active: SessionActiveOutput,
) {
Object.keys(session.data.session_status)
.filter((sessionID) => active[sessionID] === undefined && session.data.session_status[sessionID]?.type !== "idle")
.forEach((sessionID) => session.set("session_status", sessionID, { type: "idle" }))
Object.keys(active).forEach((sessionID) => session.set("session_status", sessionID, { type: "busy" }))
}
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
return {
globalConfig: () => loadGlobalConfigQuery(scope),
projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
@@ -208,46 +184,29 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
const sessionRevision = new Map<string, number>()
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
const connected = () => serverSDK.connection.status() === "connected"
const hydrateSessionState = async (sessionID: string) => {
await session.hydrateTransient(sessionID, async () => {
const [pending, forms] = await Promise.all([
serverSDK.api.session.inbox.list({ sessionID }),
serverSDK.api.form.list({ sessionID }),
])
return { pending, forms }
})
}
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
{ ...queryOptionsApi.providers(null), enabled: connected() },
{ ...queryOptionsApi.path(null), enabled: connected() },
],
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
}))
const activeSessionsQuery = useQuery(() => ({
...loadActiveSessionsQuery(serverSDK.scope, {
const activeSessionsQuery = useQuery(() =>
loadActiveSessionsQuery(serverSDK.scope, {
active: async () => {
const active = await serverSDK.api.session.active()
reconcileActiveSessionStatuses(session, active)
Object.keys(active).forEach((sessionID) => {
void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined)
})
seedActiveSessionStatuses(session, active)
for (const sessionID of Object.keys(active)) {
void session.resolve(sessionID).catch(() => undefined)
}
return active
},
}),
enabled: connected(),
}))
)
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
get ready() {
return bootstrap.isSuccess
return !bootstrap.isPending
},
project: [],
provider_auth: {},
@@ -272,6 +231,21 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const queryClient = useQueryClient()
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
const refreshProviders = () =>
queryClient.refetchQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
})
let bootedAt = 0
let bootingRoot = false
let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
setGlobalStore("project", next)
}
@@ -296,9 +270,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
setGlobalStore: setBootStore,
queryClient,
})
return Date.now()
bootedAt = Date.now()
return bootedAt
},
enabled: connected(),
}))
const set = ((...input: unknown[]) => {
@@ -320,13 +294,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const children = createChildStoreManager({
owner,
connected,
scope: serverSDK.scope,
persist: persisted,
isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => {
if (!connected()) return
void bootstrapInstance(directory)
},
onMcp: (directory, setStore) => {
@@ -349,60 +321,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
translate: language.t,
queryOptions: queryOptionsApi,
global: {
get provider() {
return globalStore.provider
},
provider: globalStore.provider,
},
})
const catalog = createCatalogSync({
scope: serverSDK.scope,
queryClient,
active: () => Object.keys(children.children).filter(children.active).map(pathKey),
load: (directory) =>
Promise.all([
queryClient.fetchQuery(queryOptionsApi.providers(directory)),
queryClient.fetchQuery(queryOptionsApi.integrations(directory)),
]).then(() => undefined),
})
const refreshVcs = (directory: string) =>
serverSDK.api.vcs
.get({ location: { directory } })
.then((result) =>
children.vcs(directory, {
branch: result.data.branch.current,
default_branch: result.data.branch.default,
}),
)
.catch(() => undefined)
const connection = createConnectionSync({
status: serverSDK.connection.status,
invalidate: () => {
session.invalidate()
void queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope,
refetchType: "none",
})
},
connected: (info) => {
if (info.reconnect) void session.refreshPinned(hydrateSessionState).catch(() => undefined)
if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch()
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
Object.keys(children.children)
.filter(children.active)
.forEach((directory) => {
queue.push(directory)
if (children.children[directory]?.[0].status !== "loading") void refreshVcs(directory)
})
},
})
async function loadCurrentSessions(directory: string, key: PathKey, limit: number) {
while (true) {
const revision = sessionRevision.get(key) ?? 0
const result = await loadRootSessions({ api: serverSDK.api.session, directory, limit })
if ((sessionRevision.get(key) ?? 0) === revision) return result
}
}
async function loadSessions(directory: string, options?: { limit?: number }) {
const key = directoryKey(directory)
@@ -433,7 +354,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.fetchQuery({
...queryOptionsApi.sessions(key),
queryFn: () =>
loadCurrentSessions(directory, key, limit)
loadRootSessions({ api: serverSDK.api.session, directory, limit })
.then((x) => {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
@@ -489,28 +410,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
children.pin(key)
const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory)
const initial = child[0].status === "loading"
await Promise.all([
bootstrapDirectory({
directory,
scope: serverSDK.scope,
mcp: children.mcp(key),
global: {
config: globalStore.config,
path: globalStore.path,
project: globalStore.project,
provider: globalStore.provider,
},
api: serverSDK.api,
store: child[0],
setStore: child[1],
loadSessions,
translate: language.t,
queryClient,
session,
}),
initial ? refreshVcs(directory) : Promise.resolve(),
])
const cache = children.vcsCache.get(key)
if (!cache) return
await bootstrapDirectory({
directory,
scope: serverSDK.scope,
mcp: children.mcp(key),
global: {
config: globalStore.config,
path: globalStore.path,
project: globalStore.project,
provider: globalStore.provider,
},
api: serverSDK.api,
store: child[0],
setStore: child[1],
vcsCache: cache,
loadSessions,
translate: language.t,
queryClient,
session,
})
})
booting.set(key, promise)
@@ -537,43 +457,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
loadLsp() {},
})
}
const updateHomeSession = (info: Parameters<typeof session.remember>[0]) =>
homeSessions.apply({
type: "session.updated",
properties: { sessionID: info.id, info },
})
const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => {
if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) {
const key = directoryKey(directory)
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
}
if (!previousDirectory || previousDirectory === directory) return
const key = directoryKey(previousDirectory)
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
}
const toDirectoryEvent = (event: ServerEvent) => {
if (event.current?.type === "session.created") return
if (
event.current?.type !== "session.renamed" &&
event.current?.type !== "session.moved" &&
event.current?.type !== "session.usage.updated"
)
return event
const info = session.get(event.current.data.sessionID)
if (info) return { type: "session.updated", properties: { info } }
return event
}
const unsub = serverSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
const eventType: string = event.type
const previousDirectory =
event.current?.type === "session.moved"
? session.get(event.current.data.sessionID)?.location.directory
: undefined
markSessionListChanged(event, directory, previousDirectory)
const recent = bootingRoot || Date.now() - bootedAt < 1500
if (event.current) session.applyV2(event.current)
session.apply(event)
if (event.current?.type === "session.created")
@@ -596,41 +487,35 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (event.type === "session.created" || event.type === "session.deleted") {
if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
}
if (
event.current?.type === "session.renamed" ||
event.current?.type === "session.moved" ||
event.current?.type === "session.usage.updated"
) {
const sessionID = event.current.data.sessionID
const info = session.get(sessionID)
if (info) updateHomeSession(info)
if (!info)
void session
.resolve(sessionID)
.then(() => {
const current = session.get(sessionID)
if (current) updateHomeSession(current)
})
.catch(() => undefined)
}
homeSessions.refresh(event.type)
catalog.handleEvent({ type: eventType, directory })
connection.handleEvent({ type: eventType, directory })
if (eventType === "integration.connection.updated") void refreshProviders()
if (directory === "global") {
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
void activeSessionsQuery.refetch()
applyGlobalEvent({
event,
project: globalStore.project,
refresh: () => void bootstrap.refetch(),
refresh: () => {
if (recent) return
bootstrap.refetch()
},
setGlobalProject: setProjects,
})
if (
eventType === "config.updated" ||
eventType === "catalog.updated" ||
eventType === "agent.updated" ||
eventType === "project.directories.updated"
)
bootstrap.refetch()
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
if (eventType === "server.connected" || eventType === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
if (!children.active(directory)) continue
queue.push(directory)
}
}
return
}
@@ -651,6 +536,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
event.current?.type === "session.moved" ||
// event.current?.type === "session.archived" ||
event.current?.type === "session.forked" ||
eventType === "command.updated" ||
eventType === "config.updated" ||
eventType === "agent.updated"
)
@@ -658,39 +544,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
const [store, setStore] = existing
if (eventType === "agent.updated")
void queryClient
.fetchQuery(queryOptionsApi.agents(key))
.then((data) => setStore("agent", data))
.catch(() => {})
if (eventType === "command.updated")
void loadCommands(directory, serverSDK.api.command)
.then((commands) => setStore("command", commands))
.catch(() => {})
if (eventType === "project.directories.updated") void bootstrap.refetch()
const projected = toDirectoryEvent(event)
if (projected)
applyDirectoryEvent({
event: projected,
directory,
store,
setStore,
push: (directory) => {
if (children.active(directory)) queue.push(directory)
},
retainedLimit: sessionMeta.get(key)?.limit,
sessionContent: false,
permission: session.data.permission,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
loadReferences: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.references(key))
},
})
applyDirectoryEvent({
event,
directory,
store,
setStore,
push: (directory) => {
if (children.active(directory)) queue.push(directory)
},
retainedLimit: sessionMeta.get(key)?.limit,
sessionContent: false,
permission: session.data.permission,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
loadReferences: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.references(key))
},
})
})
onCleanup(unsub)
@@ -703,6 +577,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
})
onMount(() => {
if (typeof requestAnimationFrame === "function") {
eventFrame = requestAnimationFrame(() => {
eventFrame = undefined
eventTimer = setTimeout(() => {
eventTimer = undefined
void serverSDK.event.start()
}, 0)
})
} else {
eventTimer = setTimeout(() => {
eventTimer = undefined
void serverSDK.event.start()
}, 0)
}
})
const projectApi = {
loadSessions,
meta(directory: string, patch: ProjectMeta) {
@@ -743,11 +634,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
peek: children.peek,
disableMcp: children.disableMcp,
queryOptions: queryOptionsApi,
refreshProviders: catalog.refreshActive,
refreshProviders,
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
session: Object.assign(session, { hydrate: hydrateSession }),
session,
homeSessions,
mcp: {
toggle: async (directory: string, name: string) => {
@@ -1,51 +0,0 @@
import { expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query"
import { ServerScope } from "@/utils/server-scope"
import { createCatalogSync } from "./catalog"
import { pathKey } from "@/utils/path-key"
test("invalidates the catalog for the event location", async () => {
const queryClient = new QueryClient()
const one = [ServerScope.local, "/one", "providers"] as const
const integrations = [ServerScope.local, "/one", "integrations"] as const
const two = [ServerScope.local, "/two", "providers"] as const
queryClient.setQueryData(one, { providers: ["one"] })
queryClient.setQueryData(integrations, { integrations: ["one"] })
queryClient.setQueryData(two, { providers: ["two"] })
const catalog = createCatalogSync({
scope: ServerScope.local,
queryClient,
active: () => [pathKey("/one"), pathKey("/two")],
load: async () => {},
})
catalog.handleEvent({ type: "catalog.updated", directory: "/one" })
await Bun.sleep(0)
expect(queryClient.getQueryState(one)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(integrations)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(two)?.isInvalidated).toBe(false)
})
test("invalidates global and active catalogs after connection", async () => {
const queryClient = new QueryClient()
const global = [ServerScope.local, null, "providers"] as const
const active = [ServerScope.local, "/active", "providers"] as const
const passive = [ServerScope.local, "/passive", "providers"] as const
queryClient.setQueryData(global, {})
queryClient.setQueryData(active, {})
queryClient.setQueryData(passive, {})
const catalog = createCatalogSync({
scope: ServerScope.local,
queryClient,
active: () => [pathKey("/active")],
load: async () => {},
})
catalog.handleEvent({ type: "server.connected", directory: "global" })
await Bun.sleep(0)
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(active)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(passive)?.isInvalidated).toBe(false)
})
@@ -1,53 +0,0 @@
import type { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
import { pathKey, type PathKey } from "@/utils/path-key"
type CatalogEvent = {
type: string
directory: string
}
export function createCatalogSync(input: {
scope: ServerScope
queryClient: QueryClient
active: () => PathKey[]
load: (directory: PathKey | null) => Promise<void>
}) {
function handleEvent(event: CatalogEvent) {
if (event.type === "server.connected") {
void refreshActive().catch(() => undefined)
return
}
if (
event.type === "catalog.updated" ||
event.type === "integration.updated" ||
event.type === "integration.connection.updated"
) {
void refresh(event.directory === "global" ? null : pathKey(event.directory)).catch(() => undefined)
}
}
async function refresh(directory: PathKey | null) {
await Promise.all(
["providers", "integrations"].map((resource) =>
input.queryClient.invalidateQueries({
queryKey: [input.scope, directory, resource],
exact: true,
refetchType: "none",
}),
),
)
await input.load(directory)
}
function refreshActive() {
return Promise.all([null, ...new Set(input.active())].map(refresh)).then(() => undefined)
}
return {
handleEvent,
refresh,
refreshActive,
}
}
@@ -1,23 +0,0 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createConnectionSync } from "./connection"
test("invalidates disconnected data and synchronizes after the handshake", () => {
const calls: string[] = []
const dispose = createRoot((dispose) => {
const [status, setStatus] = createSignal<"connecting" | "connected" | "reconnecting">("connecting")
const connection = createConnectionSync({
status,
invalidate: () => calls.push("invalidate"),
connected: () => calls.push("connected"),
})
connection.handleEvent({ type: "server.connected", directory: "global" })
expect(calls).toContain("connected")
connection.handleEvent({ type: "server.connected", directory: "/repo" })
expect(calls.filter((call) => call === "connected")).toHaveLength(1)
setStatus("connected")
return dispose
})
dispose()
})
@@ -1,22 +0,0 @@
import { createEffect, type Accessor } from "solid-js"
import type { ServerConnectionStatus } from "../server-sdk"
export function createConnectionSync(input: {
status: Accessor<ServerConnectionStatus>
invalidate: () => void
connected: (info: { reconnect: boolean }) => void
}) {
createEffect(() => {
if (input.status() === "connected") return
input.invalidate()
})
let connectedOnce = false
function handleEvent(event: { type: string; directory: string }) {
if (event.directory !== "global" || event.type !== "server.connected") return
input.connected({ reconnect: connectedOnce })
connectedOnce = true
}
return { handleEvent }
}
@@ -1,21 +0,0 @@
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { createQuery } from "@tanstack/solid-query"
import type { Accessor } from "solid-js"
export function useIntegrations(directory: Accessor<string | undefined>) {
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const query = createQuery(() => {
const value = directory()
return {
...serverSync().queryOptions.integrations(value ? pathKey(value) : null),
enabled: serverSDK().connection.status() === "connected",
}
})
return {
list: () => (query.isSuccess || query.isRefetchError ? query.data : []),
}
}
+4 -6
View File
@@ -1,4 +1,6 @@
import { useServerSync } from "@/context/server-sync"
import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect"
import { createEffect, createMemo, type Accessor } from "solid-js"
import { selectProviderCatalog } from "./provider-catalog"
@@ -17,7 +19,8 @@ const popularProviderSet = new Set(popularProviders)
export function useProviders(directory: Accessor<string | undefined>) {
const serverSync = useServerSync()
const dir = directory
const params = useParams()
const dir = () => (directory ? directory() : decode64(params.dir))
const providers = () => {
const value = dir()
const projectStore = value ? serverSync().child(value)[0] : undefined
@@ -36,11 +39,6 @@ export function useProviders(directory: Accessor<string | undefined>) {
}
return {
ready: () => {
const value = dir()
if (!value) return false
return serverSync().child(value)[0].provider_ready
},
all: () => providers().all,
default: () => providers().default,
popular: () =>
+3 -46
View File
@@ -673,17 +673,6 @@ export const dict = {
"session.error.notFound": "This session cannot be found",
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.combine": "{{first}} and {{second}}",
"session.background.shell.one": "{{count}} shell",
"session.background.shell.other": "{{count}} shells",
"session.background.subagent.one": "{{count}} subagent",
"session.background.subagent.other": "{{count}} subagents",
"command.session.background": "Move to background",
"session.timeline.notice.finished": "{{actor}} finished",
"session.timeline.notice.failed": "{{actor}} failed",
"session.timeline.notice.cancelled": "{{actor}} cancelled",
"session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "Files Changed {{count}}",
"session.review.change.one": "Change",
@@ -903,46 +892,14 @@ export const dict = {
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
"settings.tab.general": "General",
"settings.tab.preferences": "Preferences",
"settings.tab.shortcuts": "Shortcuts",
"settings.tab.notifications": "Notifications",
"settings.tab.projects": "Projects",
"settings.tab.extensions": "Extensions",
"settings.preferences.description": "Customize preferences and theme and default behavior",
"settings.appearance.description": "Customize theme and fonts",
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
"settings.shortcuts.description": "Customize shortcuts for common actions",
"settings.servers.description": "Manage server connections",
"settings.projects.title": "Projects",
"settings.projects.description": "Manage project settings on this server",
"settings.projects.empty": "No projects found",
"settings.projects.server.all": "All servers",
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
"settings.extensions.description": "Manage extensions available on this server",
"settings.extensions.tab.mcps": "MCPs",
"settings.extensions.tab.skills": "Skills",
"settings.extensions.availableAll": "Available to all projects",
"settings.extensions.manageConfig": "Manage in opencode.json",
"settings.extensions.addSkills": "How to add skills",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"dialog.server.authenticate.title": "Authenticate",
"project.settings.general.description": "Manage project name and appearance",
"project.settings.scripts": "Scripts",
"project.settings.scripts.description": "Configure scripts for this project",
"project.settings.extensions.description": "View extensions available to this project",
"project.settings.extensions.tab.lsps": "LSPs",
"project.settings.extensions.added": "Added to this project",
"project.settings.extensions.shared": "Shared with all projects",
"project.settings.extensions.lsp.detected": "Detected language servers",
"project.settings.extensions.lsp.description": "Auto-detected from file types",
"project.settings.extensions.setupRequired": "Setup required",
"settings.general.section.appearance": "Appearance",
"settings.general.section.general": "General",
"settings.general.section.advanced": "Advanced",
"settings.general.section.notifications": "Desktop notifications",
"settings.general.section.notifications": "System notifications",
"settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects",
"settings.general.section.feed": "Feed",
@@ -1103,7 +1060,7 @@ export const dict = {
"settings.shortcuts.group.prompt": "Prompt",
"settings.providers.title": "Providers",
"settings.providers.description": "Connect and manage model providers",
"settings.providers.description": "Provider settings will be configurable here.",
"settings.providers.section.connected": "Connected providers",
"settings.providers.connected.empty": "No connected providers",
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
@@ -1114,7 +1071,7 @@ export const dict = {
"settings.providers.tag.custom": "Custom",
"settings.providers.tag.other": "Other",
"settings.models.title": "Models",
"settings.models.description": "Choose which models appear in model picker",
"settings.models.description": "Model settings will be configurable here.",
"settings.agents.title": "Agents",
"settings.agents.description": "Agent settings will be configurable here.",
"settings.commands.title": "Commands",
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, test } from "bun:test"
import { desktopNativePluralCategories } from "./desktop-native"
const appLocales = [
"ar",
"br",
"bs",
"da",
"de",
"es",
"fr",
"ja",
"ko",
"no",
"pl",
"ru",
"uk",
"th",
"tr",
"zh",
"zht",
"hi",
"nl",
"id",
"vi",
"it",
"ur",
"pa",
"az",
"fi",
"sv",
"am",
"bg",
"bn",
"ca",
"cs",
"dv",
"dz",
"el",
"et",
"fa",
"fo",
"hr",
"hu",
"hy",
"is",
"ka",
"km",
"lo",
"lt",
"lv",
"mk",
"mn",
"ms",
"my",
"ne",
"ro",
"si",
"sk",
"sl",
"sq",
"sr",
"tg",
"tk",
"uz",
] as const
const desktopLocales = appLocales
const pluralCategories = new Map(
appLocales.map(
(locale) =>
[
locale,
desktopNativePluralCategories(locale).filter((category) => category !== "one" && category !== "other"),
] as const,
),
)
const domains = [
{
name: "app",
source: "./en.ts",
target: (locale: string) => `./${locale}.ts`,
locales: appLocales,
},
{
name: "ui",
source: "../../../ui/src/i18n/en.ts",
target: (locale: string) => `../../../ui/src/i18n/${locale}.ts`,
locales: appLocales,
},
{
name: "desktop",
source: "../../../desktop/src/renderer/i18n/en.ts",
target: (locale: string) => `../../../desktop/src/renderer/i18n/${locale}.ts`,
locales: desktopLocales,
},
] as const
describe("i18n parity", () => {
test("non-English locales have every English key and required plural variants", async () => {
for (const domain of domains) {
const source = await dictionary(domain.source)
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key))
const extra = Object.keys(target)
.filter((key) => !Object.hasOwn(source, key))
.sort()
const expected = pluralFamilies(source)
.flatMap((key) => (pluralCategories.get(locale) ?? []).map((category) => `${key}.${category}`))
.sort()
expect({ domain: domain.name, locale, missing, extra }).toEqual({
domain: domain.name,
locale,
missing: [],
extra: expected,
})
}
}
})
test("non-English locales preserve English placeholders", async () => {
for (const domain of domains) {
const source = await dictionary(domain.source)
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const mismatched = Object.keys(source).filter(
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
)
const pluralMismatched = pluralFamilies(source).flatMap((key) =>
(pluralCategories.get(locale) ?? [])
.map((category) => `${key}.${category}`)
.filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()),
)
expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({
domain: domain.name,
locale,
mismatched: [],
pluralMismatched: [],
})
}
}
})
test("non-English locales translate targeted unseen session keys", async () => {
const source = await dictionary("./en.ts")
for (const locale of appLocales) {
const target = await dictionary(`./${locale}.ts`)
for (const key of ["command.session.previous.unseen", "command.session.next.unseen"]) {
expect(target[key]).toBeDefined()
expect(target[key]).not.toBe(source[key])
}
}
})
test("changed-file summary keys preserve rendered English copy and localize complete phrases", async () => {
const source = await dictionary("../../../ui/src/i18n/en.ts")
expect(source["ui.sessionTurn.diffs.changed.one"].replace("{{count}}", "1")).toBe("1 Changed file")
expect(source["ui.sessionTurn.diffs.changed.other"].replace("{{count}}", "2")).toBe("2 Changed files")
expect(source["ui.sessionTurn.diffs.changed"]).toBeUndefined()
for (const locale of appLocales) {
const target = await dictionary(`../../../ui/src/i18n/${locale}.ts`)
for (const key of ["ui.sessionTurn.diffs.changed.one", "ui.sessionTurn.diffs.changed.other"]) {
expect(target[key].trim()).not.toBe("")
expect(placeholders(target[key])).toEqual(["count"])
}
}
})
})
describe("i18n plural parity", () => {
test("locale-specific categories exist and preserve count placeholders", async () => {
for (const domain of domains.slice(0, 2)) {
const source = await dictionary(domain.source)
const families = pluralFamilies(source)
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const missing = families.flatMap((key) =>
(pluralCategories.get(locale) ?? [])
.map((category) => `${key}.${category}`)
.filter((variant) => !Object.hasOwn(target, variant)),
)
const mismatched = families.flatMap((key) =>
(pluralCategories.get(locale) ?? [])
.map((category) => `${key}.${category}`)
.filter(
(variant) =>
Object.hasOwn(target, variant) &&
placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join(),
),
)
expect({ domain: domain.name, locale, missing, mismatched }).toEqual({
domain: domain.name,
locale,
missing: [],
mismatched: [],
})
}
}
})
})
async function dictionary(file: string) {
const module: unknown = await import(file)
if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
throw new Error(`Invalid translation dictionary: ${file}`)
}
return module.dict
}
function isDictionary(value: unknown): value is Record<string, string> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false
return Object.values(value).every((item) => typeof item === "string")
}
function placeholders(value: string) {
return Array.from(value.matchAll(/{{\s*([^}]+?)\s*}}/g), (match) => match[1]).sort()
}
function pluralFamilies(dictionary: Record<string, string>) {
return Object.keys(dictionary)
.filter(
(key) =>
key.endsWith(".one") &&
dictionary[key].includes("{{count}}") &&
dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"),
)
.map((key) => key.slice(0, -4))
}
+2 -3
View File
@@ -25,7 +25,6 @@ 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) => {
@@ -45,8 +44,8 @@ export function DirectoryDataProvider(
createResource(
() => params.id,
(id) =>
serverSync()
.session.hydrate(id)
sync()
.session.sync(id)
.catch(() => {}),
)
@@ -60,7 +60,7 @@ export function createHomeSessionsController(home: HomeController) {
}))
const sessionLoad = useQuery(() => ({
queryKey: homeSessions().indexKey,
enabled: home.server.focusedContext()?.sdk.connection.status() === "connected",
enabled: !!home.server.focusedContext(),
queryFn: async ({ signal }) => {
const ctx = home.server.focusedContext()
if (!ctx) return { sessions: [], eventSequence: 0 }
@@ -2,7 +2,7 @@ import { createMemo, type Accessor } from "solid-js"
import { useGlobal } from "@/context/global"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { sessionPermissionRequest, sessionQuestionForm } from "@/pages/session/composer/session-request-tree"
import { sessionPermissionRequest, sessionQuestionRequest } from "@/pages/session/composer/session-request-tree"
import { ServerConnection } from "@/context/server"
export function useSessionTabAvatarState(
@@ -31,7 +31,7 @@ export function useSessionTabAvatarState(
const serverSync = sync()
if (!serverSync) return false
const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId())
return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const notificationState = createMemo(() => {
+11 -24
View File
@@ -651,17 +651,11 @@ export default function Page() {
})
const vcsKey = createMemo(
() =>
[
serverSDK().scope,
"session-vcs",
sdk().directory,
sync().data.vcs?.branch ?? "",
sync().data.vcs?.default_branch ?? "",
] as const,
["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const,
)
const vcsQuery = createQuery(() => {
const mode = vcsMode()
const enabled = serverSDK().connection.status() === "connected" && wantsReview() && sync().project?.vcs === "git"
const enabled = wantsReview() && sync().project?.vcs === "git"
return {
queryKey: [...vcsKey(), mode] as const,
@@ -673,17 +667,14 @@ export default function Page() {
sdk()
.api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
.then((result) => result.data)
.catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error })
return []
})
: skipToken,
}
})
const refreshVcs = debounce(() => {
void queryClient.invalidateQueries({ queryKey: vcsKey() })
}, 100)
onCleanup(
sdk().event.listen((event) => {
if (event.details.type === "filesystem.changed") refreshVcs()
}),
)
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
createEffect(
on(
() => desktopReviewOpen() || mobileChanges(),
@@ -727,7 +718,7 @@ export default function Page() {
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
queryKey: [...vcsKey(), mode, "directory", scope, context, version] as const,
queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () =>
@@ -1117,10 +1108,6 @@ export default function Page() {
useComposerCommands()
useSessionCommands({
session: controller,
background: {
blocking: () => composer.background.blocking().length > 0,
move: composer.background.move,
},
navigateMessageByOffset,
setActiveMessage,
focusInput,
@@ -1482,8 +1469,6 @@ export default function Page() {
working: () => true,
overflowAnchor: "none",
})
const shouldAnchorBottom = () =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
createEffect(
on(
() => controller.identity.params.id,
@@ -2092,7 +2077,9 @@ export default function Page() {
onUserScroll={markUserScroll}
onHistoryScroll={onHistoryScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={shouldAnchorBottom()}
shouldAnchorBottom={
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
@@ -1,84 +0,0 @@
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { For, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { SessionComposerPullout } from "./session-composer-pullout"
export function SessionBackgroundDock(props: {
blocking: { type: "shell" | "subagent"; id?: string; label?: string }[]
tasks: { id: string; type: "shell" | "subagent"; label: string }[]
onBackground: () => void
}) {
const language = useLanguage()
const command = useCommand()
const [store, setStore] = createStore({ collapsed: true })
const describe = (shells: number, subagents: number) => {
const shell = shells ? language.plural("session.background.shell", shells, { count: shells }) : undefined
const subagent = subagents
? language.plural("session.background.subagent", subagents, { count: subagents })
: undefined
if (shell && subagent) return language.t("session.background.combine", { first: shell, second: subagent })
return shell ?? subagent ?? ""
}
const summary = createMemo(() => {
const shells = props.tasks.filter((task) => task.type === "shell").length
return describe(shells, props.tasks.length - shells)
})
const moving = createMemo(() => {
const shells = props.blocking.filter((task) => task.type === "shell").length
const subagents = props.blocking.length - shells
const tasks = describe(shells, subagents)
return tasks ? language.t("session.background.moveTasks", { tasks }) : ""
})
const background = createMemo(() =>
summary() ? language.t("session.background.inBackground", { tasks: summary() }) : "",
)
const blocking = () => props.blocking.length > 0
const toggle = () => {
if (blocking()) {
props.onBackground()
return
}
setStore("collapsed", (value) => !value)
}
return (
<SessionComposerPullout
name="background"
label={
<span class="flex flex-col items-start">
{blocking() && (
<span>
<span class="text-v2-text-text-muted">{moving()}</span>
<span class="pl-2">
<KeybindV2 keys={command.keybindParts("session.background")} variant="neutral" />
</span>
</span>
)}
{!!props.tasks.length && <span class="text-v2-text-text-faint">{background()}</span>}
</span>
}
ariaLabel={[moving(), background()].filter(Boolean).join(". ")}
multiline={blocking() && props.tasks.length > 0}
collapsed={blocking() || store.collapsed}
collapsible={!blocking()}
onToggle={toggle}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
>
<div class="px-4 pb-11 flex flex-col gap-1.5">
<For each={props.tasks}>
{(task) => (
<div class="flex min-w-0 items-baseline gap-2 text-13-regular">
<span class="shrink-0 text-13-medium text-text-strong">
{language.t(task.type === "shell" ? "ui.tool.shell" : "ui.tool.agent.default")}
</span>
<span class="truncate text-text-weak">{task.label}</span>
</div>
)}
</For>
</div>
</SessionComposerPullout>
)
}
@@ -20,7 +20,7 @@ import { pathKey } from "@/utils/path-key"
export function createPromptInputController(input: {
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
queryOptions: Pick<QueryOptionsApi, "agents">
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
model?: ModelSelection
}) {
const layout = useLayout()
@@ -30,6 +30,8 @@ export function createPromptInputController(input: {
const providers = useProviders(() => sdk().directory)
const view = layout.view(input.sessionKey)
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
return createMemo<PromptInputControls>(() => {
return {
@@ -44,7 +46,10 @@ export function createPromptInputController(input: {
model: {
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading: (local.agent.visible() && agentsQuery.isLoading) || !providers.ready(),
loading:
(local.agent.visible() && agentsQuery.isLoading) ||
providersQuery.isLoading ||
globalProvidersQuery.isLoading,
},
session: {
id: input.sessionID(),
@@ -1,157 +0,0 @@
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { useSettings } from "@/context/settings"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, createMemo, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
export function SessionComposerPullout(props: {
name: "todo" | "background"
label: JSX.Element
ariaLabel: string
preview?: string
multiline?: boolean
collapsed: boolean
collapsible?: boolean
onToggle: () => void
collapseLabel: string
expandLabel: string
dockProgress?: number
children: JSX.Element
}) {
const settings = useSettings()
const [store, setStore] = createStore({ height: 78, header: 42 })
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
const shut = createMemo(() => 1 - dock())
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
const hide = createMemo(() => Math.max(value(), shut()))
const off = createMemo(() => hide() > 0.98)
const base = createMemo(() => Math.max(78, store.header + 36))
const full = createMemo(() => Math.max(base(), store.height))
let contentRef: HTMLDivElement | undefined
let headerRef: HTMLDivElement | undefined
createEffect(() => {
const element = contentRef
const header = headerRef
if (!element || !header) return
const update = () => {
setStore("height", (height) => Math.max(height, element.scrollHeight))
setStore("header", header.getBoundingClientRect().height)
}
update()
createResizeObserver([element, header], update)
})
return (
<Dynamic
component={settings.general.newLayoutDesigns() ? "div" : DockTray}
data-component={`session-${props.name}-dock`}
classList={{
"w-full overflow-hidden rounded-xl border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-01":
settings.general.newLayoutDesigns(),
}}
style={{
"overflow-x": "visible",
"overflow-y": "hidden",
"max-height": `${Math.max(base(), full() - value() * (full() - base()))}px`,
}}
>
<div ref={contentRef}>
<div
ref={headerRef}
data-action={`session-${props.name}-toggle`}
classList={{
"flex items-center gap-2 overflow-visible": true,
"pl-4 pr-2": settings.general.newLayoutDesigns(),
"h-[42px]": settings.general.newLayoutDesigns() && !props.multiline,
"min-h-[42px] py-2": settings.general.newLayoutDesigns() && props.multiline,
"pl-3 pr-2 py-2": !settings.general.newLayoutDesigns(),
}}
role="button"
tabIndex={0}
onClick={props.onToggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
props.onToggle()
}}
>
<span
classList={{
"cursor-default inline-flex items-baseline shrink-0 overflow-visible": true,
"font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted":
settings.general.newLayoutDesigns(),
"text-14-regular text-text-strong": !settings.general.newLayoutDesigns(),
}}
aria-label={props.ariaLabel}
style={{
"--tool-motion-odometer-ms": "600ms",
"--tool-motion-mask": "18%",
"--tool-motion-mask-height": "0px",
"--tool-motion-spring-ms": "560ms",
"white-space": "pre",
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
}}
>
{props.label}
</span>
<div
data-slot={`session-${props.name}-preview`}
class="ml-1 min-w-0 overflow-hidden"
style={{ flex: "1 1 auto", "max-width": "100%", transform: "translateY(1px)" }}
>
<TextReveal
class={
settings.general.newLayoutDesigns()
? "cursor-default text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint"
: "text-14-regular text-text-base cursor-default"
}
text={props.preview}
duration={600}
travel={25}
edge={17}
spring="cubic-bezier(0.34, 1, 0.64, 1)"
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
growOnly
truncate
/>
</div>
{props.collapsible !== false && (
<div class="ml-auto">
<IconButton
data-action={`session-${props.name}-toggle-button`}
data-collapsed={props.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${value() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
props.onToggle()
}}
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
)}
</div>
<div
data-slot={`session-${props.name}-list`}
aria-hidden={props.collapsed || off()}
classList={{ "pointer-events-none": hide() > 0.1 }}
style={{ visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, 1 - hide())}` }}
>
{props.children}
</div>
</div>
</Dynamic>
)
}
@@ -6,7 +6,6 @@ import { SessionQuestionDock } from "@/pages/session/composer/session-question-d
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import { SessionBackgroundDock } from "@/pages/session/composer/session-background-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
export function SessionComposerRegion(props: {
@@ -16,8 +15,6 @@ export function SessionComposerRegion(props: {
const language = useLanguage()
const controller = props.controller
const settings = useSettings()
const background = () =>
controller.state.background.blocking().length > 0 || controller.state.background.tasks().length > 0
const rolled = () => {
const revert = controller.revert()
return revert?.items.length ? revert : undefined
@@ -126,21 +123,12 @@ export function SessionComposerRegion(props: {
</div>
)}
</Show>
<Show when={background()}>
<div style={{ "margin-top": `${-controller.lift()}px` }}>
<SessionBackgroundDock
blocking={controller.state.background.blocking()}
tasks={controller.state.background.tasks()}
onBackground={() => void controller.state.background.move()}
/>
</div>
</Show>
<div
classList={{
"relative z-[70]": true,
}}
style={{
"margin-top": `${background() ? -36 : -controller.lift()}px`,
"margin-top": `${-controller.lift()}px`,
}}
>
<Show when={controller.followup()?.items.length}>
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { todoDockAtBoundary, todoState } from "./session-composer-state"
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -19,10 +19,8 @@ const question = (id: string, sessionID: string) =>
({
id,
sessionID,
title: "Questions",
metadata: { kind: "question" },
fields: [{ key: "q0", type: "string" }],
}) as FormInfo
questions: [],
}) as QuestionRequest
describe("sessionPermissionRequest", () => {
test("prefers the current session permission", () => {
@@ -82,7 +80,7 @@ describe("sessionPermissionRequest", () => {
})
})
describe("sessionQuestionForm", () => {
describe("sessionQuestionRequest", () => {
test("prefers the current session question", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const questions = {
@@ -90,7 +88,7 @@ describe("sessionQuestionForm", () => {
child: [question("q-child", "child")],
}
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-root")
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-root")
})
test("returns a nested child question", () => {
@@ -103,16 +101,7 @@ describe("sessionQuestionForm", () => {
grand: [question("q-grand", "grand")],
}
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-grand")
})
test("skips forms that are not questions", () => {
const sessions = [session({ id: "root" })]
const forms = {
root: [{ ...question("form", "root"), metadata: { kind: "integration" } }],
}
expect(sessionQuestionForm(sessions, forms, "root")).toBeUndefined()
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
})
})
@@ -1,17 +1,15 @@
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { Todo } from "@/types"
import type { FormInfo, PermissionRequest } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
import { useParams } from "@solidjs/router"
import { showToast } from "@/utils/toast"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
import { createQuery, useQueryClient } from "@tanstack/solid-query"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
export const todoState = (input: {
count: number
@@ -33,33 +31,11 @@ export function createSessionComposerController(options?: { closeMs?: number | (
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const queryClient = useQueryClient()
const language = useLanguage()
const permission = usePermission()
const shellKey = () => [serverSDK().scope, sdk().directory, "shell"] as const
const shells = createQuery(() => ({
queryKey: shellKey(),
enabled: !!params.id && serverSDK().connection.status() === "connected",
queryFn: () =>
sdk()
.api.shell.list({ location: { directory: sdk().directory } })
.then((result) => result.data ?? []),
}))
onCleanup(
sdk().event.listen((event) => {
if (
event.details.type !== "shell.created" &&
event.details.type !== "shell.exited" &&
event.details.type !== "shell.deleted"
)
return
void queryClient.invalidateQueries({ queryKey: shellKey(), exact: true })
}),
)
const questionRequest = createMemo((): FormInfo | undefined => {
return sessionQuestionForm(sync().data.session, serverSync().session.data.form, params.id)
const questionRequest = createMemo((): QuestionRequest | undefined => {
return sessionQuestionRequest(sync().data.session, sync().data.question, params.id)
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
@@ -85,119 +61,6 @@ export function createSessionComposerController(options?: { closeMs?: number | (
)
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
const primary = () => {
const id = params.id
return !!id && !serverSync().session.get(id)?.parentID
}
const backgroundBlocking = createMemo(() => {
if (!primary()) return []
const id = params.id
if (!id) return []
const assistant = (serverSync().session.data.session_message[id] ?? []).findLast(
(message) => message.type === "assistant" && message.time.completed === undefined,
)
if (assistant?.type !== "assistant") return []
return assistant.content.flatMap((part) => {
if (part.type !== "tool" || part.state.status !== "running") return []
if (part.name !== "shell" && part.name !== "subagent") return []
const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID
const label = part.name === "shell" ? part.state.input.command : part.state.input.description
return [
{
type: part.name as "shell" | "subagent",
id: typeof value === "string" ? value : undefined,
label: typeof label === "string" ? label : undefined,
},
]
})
})
const backgroundTasks = createMemo(() => {
if (!primary()) return []
const id = params.id
if (!id) return []
const blocking = backgroundBlocking()
const messages = serverSync().session.data.session_message[id] ?? []
const completed = new Set(
messages.flatMap((message) => {
if (message.type !== "synthetic") return []
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
return [message.metadata.childID]
if (message.metadata?.source === "shell" && typeof message.metadata.jobID === "string")
return [message.metadata.jobID]
return []
}),
)
const backgrounded = messages.flatMap((message) => {
if (message.type !== "assistant") return []
return message.content.flatMap((part) => {
if (part.type !== "tool" || part.name !== "subagent") return []
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
const sessionID = part.state.metadata.sessionID
if (typeof sessionID !== "string" || completed.has(sessionID)) return []
const description = part.state.input.description
return [
{
id: sessionID,
type: "subagent" as const,
label: typeof description === "string" ? description : sessionID,
},
]
})
})
const active = Object.values(serverSync().session.data.info).flatMap((info) => {
if (info?.parentID !== id) return []
if ((serverSync().session.data.session_status[info.id]?.type ?? "idle") === "idle") return []
if (
blocking.some(
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
)
)
return []
return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }]
})
const backgroundShells = messages.flatMap((message) => {
if (message.type !== "assistant") return []
return message.content.flatMap((part) => {
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
const shellID = part.state.metadata.shellID
const command = part.state.input.command
return [
{
id: typeof shellID === "string" ? shellID : part.id,
type: "shell" as const,
label: typeof command === "string" ? command : part.id,
},
]
})
})
const running = (shells.isSuccess || shells.isRefetchError ? shells.data : []).flatMap((shell) => {
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
if (
blocking.some(
(item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)),
)
)
return []
return [{ id: shell.id, type: "shell" as const, label: shell.command }]
})
return [
...new Map([...backgrounded, ...active, ...backgroundShells, ...running].map((task) => [task.id, task])).values(),
]
})
const moveToBackground = async () => {
if (!primary()) return
const sessionID = params.id
if (!sessionID) return
await sdk()
.api.session.background({ sessionID })
.catch((error) => {
showToast({
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
})
}
const [store, setStore] = createStore({
sessionID: params.id,
@@ -328,11 +191,6 @@ export function createSessionComposerController(options?: { closeMs?: number | (
questionRequest,
permissionRequest,
permissionResponding,
background: {
blocking: backgroundBlocking,
tasks: backgroundTasks,
move: moveToBackground,
},
decide,
todos,
dock: () =>
@@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { showToast } from "@/utils/toast"
import type { FormAnswer, FormInfo, FormMultiselectField, FormStringField } from "@opencode-ai/client/promise"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/client/promise"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -14,13 +14,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useServerSDK } from "@/context/server-sdk"
import { ScopedKey } from "@/utils/server-scope"
const cache = new Map<string, { tab: number; answers: string[][]; custom: string[]; customOn: boolean[] }>()
type QuestionField = FormStringField | FormMultiselectField
function questionField(field: FormInfo["fields"][number]): field is QuestionField {
return field.type === "string" || field.type === "multiselect"
}
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
return (
@@ -67,27 +61,19 @@ function Option(props: {
)
}
export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => void }> = (props) => {
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK()
const serverSDK = useServerSDK()
const language = useLanguage()
const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id)
const questions = createMemo(() =>
props.request.fields.filter(questionField).map((field) => ({
field,
header: field.title ?? "",
question: field.description ?? field.title ?? "",
options: field.type === "string" ? (field.options ?? []) : field.options,
multiple: field.type === "multiselect",
})),
)
const questions = createMemo(() => props.request.questions)
const total = createMemo(() => questions().length)
const cached = cache.get(cacheKey)
const [store, setStore] = createStore({
tab: cached?.tab ?? 0,
answers: cached?.answers ?? ([] as string[][]),
answers: cached?.answers ?? ([] as QuestionAnswer[]),
custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
@@ -171,7 +157,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
if (store.customOn[tab] === true) return list.length
return Math.max(
0,
list.findIndex((item) => store.answers[tab]?.includes(item.value) ?? false),
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
)
}
@@ -237,8 +223,8 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
}
const replyMutation = useMutation(() => ({
mutationFn: (answer: FormAnswer) =>
sdk().api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
mutationFn: (answers: QuestionAnswer[]) =>
sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }),
onMutate: () => {
props.onSubmit()
},
@@ -250,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => sdk().api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
@@ -263,26 +249,17 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
const reply = (answer: FormAnswer) => {
const reply = async (answers: QuestionAnswer[]) => {
if (sending()) return
replyMutation.mutate(answer)
await replyMutation.mutateAsync(answers)
}
const reject = () => {
const reject = async () => {
if (sending()) return
rejectMutation.mutate()
await rejectMutation.mutateAsync()
}
const submit = () =>
reply(
Object.fromEntries(
questions().flatMap((question, index) => {
const answers = store.answers[index] ?? []
if (answers.length === 0) return []
return [[question.field.key, question.multiple ? answers : answers[0]]]
}),
),
)
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
const answered = (i: number) => {
if ((store.answers[i]?.length ?? 0) > 0) return true
@@ -348,7 +325,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
if (event.key === "Escape") {
event.preventDefault()
reject()
void reject()
return
}
@@ -401,10 +378,10 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
if (!opt) return
if (multi()) {
setStore("editing", false)
toggle(opt.value)
toggle(opt.label)
return
}
pick(opt.value)
pick(opt.label)
}
const commitCustom = () => {
@@ -572,7 +549,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
{(opt, i) => (
<Option
multi={multi()}
picked={picked(opt.value)}
picked={picked(opt.label)}
label={opt.label}
description={opt.description}
disabled={sending()}
@@ -1,4 +1,5 @@
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
function sessionTreeRequest<T>(
session: SessionInfo[],
@@ -42,10 +43,11 @@ export function sessionPermissionRequest(
return sessionTreeRequest(session, request, sessionID, include)
}
export function sessionQuestionForm(
export function sessionQuestionRequest(
session: SessionInfo[],
request: Record<string, FormInfo[] | undefined>,
request: Record<string, QuestionRequest[] | undefined>,
sessionID?: string,
include?: (item: QuestionRequest) => boolean,
) {
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
return sessionTreeRequest(session, request, sessionID, include)
}
@@ -1,11 +1,17 @@
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 { Index, Match, Switch, createMemo } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Index, Match, Switch, createEffect, createMemo } from "solid-js"
import { Dynamic } from "solid-js/web"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { SessionComposerPullout } from "./session-composer-pullout"
import { useSettings } from "@/context/settings"
const doneToken = "\u0000done\u0000"
const totalToken = "\u0000total\u0000"
@@ -44,6 +50,10 @@ 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)
@@ -63,33 +73,147 @@ 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 (
<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}
<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`,
}}
>
<TodoList todos={props.todos} />
</SessionComposerPullout>
<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>
)
}
@@ -54,7 +54,6 @@ 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[] = []
@@ -241,12 +240,10 @@ 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 && shouldAnchorBottom()
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const sessionID = props.data.sessionID
@@ -265,46 +262,10 @@ 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
@@ -377,7 +338,7 @@ function MessageTimelineView(
},
getScrollElement: () => listRoot() ?? null,
observeElementOffset: observeElementOffsetReconnectAware,
initialOffset: () => (shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
initialOffset: () => (props.shouldAnchorBottom ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => timelineFallbackItemSize,
scrollToFn: (offset, options, instance) => {
@@ -415,11 +376,11 @@ function MessageTimelineView(
const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
const anchorResizedBottom = () => {
if (resizeAnchorScheduled || hasScrollGesture()) return
if (resizeAnchorScheduled || props.hasScrollGesture) return
resizeAnchorScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
if (!shouldAnchorBottom() || hasScrollGesture()) return
if (!props.shouldAnchorBottom || props.hasScrollGesture) return
virtualizer.scrollToEnd()
})
}
@@ -444,10 +405,10 @@ function MessageTimelineView(
})
}
resizeItem(index, size)
if (root && shouldAnchorBottom()) anchorResizedBottom()
if (root && props.shouldAnchorBottom) anchorResizedBottom()
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
if (shouldAnchorBottom()) return false
if (props.shouldAnchorBottom) return false
const first = virtualizer.range?.startIndex
return first !== undefined && item.index < first
}
@@ -468,18 +429,18 @@ function MessageTimelineView(
let overscanFrame: number | undefined
onMount(() => {
overscanFrame = requestAnimationFrame(() => {
if (shouldAnchorBottom()) virtualizer.scrollToEnd()
if (props.shouldAnchorBottom) virtualizer.scrollToEnd()
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
if (renderOverscan() < 20) setRenderOverscan(20)
if (shouldAnchorBottom()) virtualizer.scrollToEnd()
if (props.shouldAnchorBottom) virtualizer.scrollToEnd()
})
})
})
const maybeAnchorBottom = () => {
if (timelineRows().length === 0) return
if (!shouldAnchorBottom() || hasScrollGesture()) return
if (!props.shouldAnchorBottom || props.hasScrollGesture) return
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
@@ -855,25 +816,6 @@ 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,9 +16,6 @@ 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) => {
@@ -80,6 +77,5 @@ export function createTimelineProjection(input: {
messageLastRowIndex,
rowByKey,
rows,
sessionMessageByID,
}
}
@@ -92,84 +92,6 @@ 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 } },
+57 -91
View File
@@ -17,7 +17,6 @@ export type TimelineRowMap = {
userMessageID: string
anchor: boolean
}
Notice: { userMessageID: string; messageID: string }
TurnDivider: {
userMessageID: string
label: "compaction" | "interrupted"
@@ -43,57 +42,39 @@ export namespace Timeline {
inlineComments: boolean,
projectedUserMessages: UserMessage[],
) {
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 turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
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,
entries: assistant?.role === "assistant" ? [{ type: "assistant" as const, message: assistant }] : [],
}
const turn = { user: projected, assistants: assistant?.role === "assistant" ? [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, entries: [] }
const turn = { user: projected, assistants: [] }
turns.push(turn)
turnByUserID.set(projected.id, turn)
current = turn
return
}
if (projected?.role !== "assistant") return
const existing = current ?? turnByUserID.get(projected.parentID)
const existing = turnByUserID.get(projected.parentID)
if (existing) {
existing.entries.push({ type: "assistant", message: projected })
current = existing
existing.assistants.push(projected)
return
}
const user = getMessage(projected.parentID)
if (user?.role !== "user") return
const turn = { user, entries: [{ type: "assistant" as const, message: projected }] }
const turn = { user, assistants: [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, entries: [] }
const turn = { user, assistants: [] }
const index = turns.findIndex((item) => compareMessages(user, item.user) < 0)
if (index < 0) turns.push(turn)
if (index >= 0) turns.splice(index, 0, turn)
@@ -102,34 +83,25 @@ export namespace Timeline {
const activeMessageID = turns.at(-1)?.user.id
return {
activeMessageID,
rows: [
...leading.map(
(message) =>
new TimelineRow.Notice({ userMessageID: turns[0]?.user.id ?? message.id, messageID: message.id }),
rows: turns.flatMap((turn, index) =>
constructMessageRows(
turn.user,
getMessageParts,
turn.assistants,
index,
showReasoning,
status,
turn.user.id === activeMessageID,
inlineComments,
),
...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[],
entries: Array<
| { type: "assistant"; message: AssistantMessage }
| { type: "notice"; message: Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }> }
>,
assistantMessages: AssistantMessage[],
index: number,
showReasoning: boolean,
status: SessionStatus["type"],
@@ -138,14 +110,13 @@ 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") &&
!entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
const compaction = userParts.some((p) => p.type === "compaction")
const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError")
const interrupted = interruptedMessageIndex !== -1
const latestError = assistantMessages.at(-1)?.error
const error = latestError?.name === "MessageAbortedError" ? undefined : latestError
@@ -154,6 +125,24 @@ 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)
@@ -180,41 +169,26 @@ export namespace Timeline {
}
let assistantGroupIndex = 0
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)
assistantItems.forEach((item) => {
if (item.type === "interrupted") {
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
label: "interrupted",
}),
)
return
}
appendAssistants(assistantSegment)
assistantSegment = []
rows.push(new TimelineRow.Notice({ userMessageID: userMessage.id, messageID: entry.message.id }))
rows.push(
new TimelineRow.AssistantPart({
userMessageID: userMessage.id,
group: item.group,
previousAssistantPart: assistantGroupIndex > 0,
}),
)
assistantGroupIndex += 1
})
appendAssistants(assistantSegment)
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
const heading = assistantMessages
@@ -342,14 +316,6 @@ 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,10 +15,6 @@ 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"
@@ -48,7 +44,6 @@ export namespace TimelineRow {
| TurnGap
| CommentStrip
| UserMessage
| Notice
| TurnDivider
| AssistantPart
| Thinking
@@ -64,8 +59,6 @@ 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,10 +30,6 @@ type SessionCommandSource = {
export type SessionCommandContext = {
session: SessionCommandSource
background: {
blocking: () => boolean
move: () => Promise<void>
}
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
focusInput: () => void
@@ -438,13 +434,6 @@ 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,7 +8,6 @@ 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"
@@ -39,7 +38,6 @@ 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("")
@@ -49,8 +47,8 @@ export function SessionFileBrowserTab(props: {
const search = createQuery(() => {
const value = query()
return {
queryKey: [serverSDK().scope, "session-open-file", workspaceKey(), value] as const,
enabled: serverSDK().connection.status() === "connected" && value.length > 0,
queryKey: ["session-open-file", workspaceKey(), value] as const,
enabled: 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 === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
if (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())
}),
})
}),
+32 -38
View File
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Clock, Context, Duration, Effect, Layer, Schema, Scope } from "effect"
import { Context, Duration, Effect, Layer, Schema, Stream } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem.js"
@@ -10,6 +10,7 @@ import { Location } from "../location.js"
import { Ripgrep } from "../ripgrep.js"
import { RelativePath } from "../schema.js"
import { Protected } from "./protected.js"
import { Watcher } from "./watcher.js"
export interface Interface {
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
@@ -22,58 +23,47 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
export const ripgrepLayer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const scope = yield* Scope.Scope
const clock = yield* Clock.Clock
const watcher = yield* Watcher.Service
const home = Protected.isHome(location.directory)
let index = { files: [] as string[], directories: new Set<string>() }
let initialized = false
let settledAt = Number.NEGATIVE_INFINITY
let refreshing = false
const scan = Effect.gen(function* () {
const next = { files: [] as string[], directories: new Set<string>() }
if (!initialized) index = next
yield* ripgrep.find({
const scan = ripgrep
.find({
cwd: location.directory,
pattern: "*",
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
onEntry: (entry) =>
Effect.sync(() => {
next.files.push(entry.path)
const parts = entry.path.split("/")
parts
.slice(0, -1)
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
}),
})
index = next
initialized = true
}).pipe(
Effect.orDie,
Effect.ensuring(
Effect.sync(() => {
settledAt = clock.currentTimeMillisUnsafe()
refreshing = false
.pipe(
Effect.orDie,
Effect.map((entries) => {
const files = entries.map((entry) => entry.path)
return {
files,
directories: new Set(
files.flatMap((file) => {
const parts = file.split("/")
return parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join("/") + path.sep)
}),
),
}
}),
),
)
const [snapshot, invalidate] = yield* Effect.cachedInvalidateWithTTL(scan, Duration.infinity)
const updates = yield* watcher.subscribe({ path: location.directory, type: "directory" })
yield* updates.pipe(
Stream.runForEach(() => invalidate),
Effect.forkScoped,
)
const refresh = Effect.sync(() => {
if (refreshing || clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return
refreshing = true
return scan
}).pipe(Effect.flatMap((effect) => (effect ? effect.pipe(Effect.forkIn(scope)) : Effect.void)))
yield* refresh
yield* Effect.yieldNow
yield* snapshot.pipe(Effect.forkScoped)
return Service.of({
find: (input) =>
Effect.gen(function* () {
yield* refresh
const index = yield* snapshot
const items =
input.type === "file"
? index.files
@@ -173,7 +163,11 @@ export const layer = (options?: Options) =>
)
export function configured(options?: Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [Location.node, Ripgrep.node] })
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Location.node, Ripgrep.node, Watcher.node],
})
}
export const node = configured()
+1 -2
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", jobID: id, state },
metadata: { source: "shell", state },
})
}),
Effect.forkIn(scope, { startImmediately: true }),
@@ -300,7 +300,6 @@ 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 } : {}),
+1 -1
View File
@@ -55,7 +55,7 @@ export class RegistryService extends Context.Service<RegistryService, Registry>(
export const registry = (drivers: Readonly<Record<string, Interface>>): Registry => ({
get: (provider) => {
const driver = Object.hasOwn(drivers, provider) ? drivers[provider] : undefined
const driver = drivers[provider]
return driver ? Effect.succeed(driver) : Effect.fail(new ProviderNotFound({ provider }))
},
})
-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
+31 -49
View File
@@ -1,12 +1,12 @@
import { describe, expect, test } from "bun:test"
import os from "os"
import path from "path"
import { Deferred, Effect, Layer } from "effect"
import { TestClock } from "effect/testing"
import { Effect, Layer, PubSub, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Protected } from "@opencode-ai/core/filesystem/protected"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Location } from "@opencode-ai/core/location"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
@@ -34,15 +34,14 @@ describe("FileSystemSearch", () => {
find: (input) =>
Effect.gen(function* () {
observed = input
if (input.onEntry)
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" }))
return []
return [FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
[Watcher.node, Watcher.testLayer],
])
await Effect.runPromise(
@@ -58,69 +57,52 @@ describe("FileSystemSearch", () => {
)
})
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
test("refreshes the ripgrep index after files change", async () => {
const root = AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-refresh"))
let scans = 0
const initial = Effect.runSync(Deferred.make<void>())
const started = Effect.runSync(Deferred.make<void>())
const release = Effect.runSync(Deferred.make<void>())
const updates = Effect.runSync(PubSub.unbounded<Watcher.Update>())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
),
),
],
[Location.node, Layer.succeed(Location.Service, Location.Service.of(location({ directory: root })))],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
find: () =>
Effect.sync(() => {
scans++
if (scans > 1) {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
const entry = FileSystem.Entry.make({
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
if (scans === 1) yield* Deferred.succeed(initial, undefined)
return [entry]
return [
FileSystem.Entry.make({ path: RelativePath.make("src/old.ts"), type: "file" }),
...(scans > 1
? [FileSystem.Entry.make({ path: RelativePath.make("src/new.ts"), type: "file" })]
: []),
]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
[
Watcher.node,
Layer.succeed(
Watcher.Service,
Watcher.Service.of({ subscribe: () => Effect.succeed(Stream.fromPubSub(updates)) }),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Deferred.await(initial)
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
expect(scans).toBe(1)
yield* TestClock.adjust("10 seconds")
yield* search.find({ query: "old", type: "file" })
yield* Deferred.await(started)
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
expect(scans).toBe(2)
yield* Deferred.succeed(release, undefined)
const refreshed = yield* Effect.gen(function* () {
yield* Effect.yieldNow
return yield* search.find({ query: "new", type: "file" })
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
expect(scans).toBe(2)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
expect((yield* search.find({ query: "new", type: "file" })).length).toBe(0)
yield* PubSub.publish(
updates,
{ type: "create", path: path.join(root, "src/new.ts") } satisfies Watcher.Update,
)
yield* Effect.sleep("100 millis")
expect((yield* search.find({ query: "new", type: "file" }))[0]?.path).toBe(RelativePath.make("src/new.ts"))
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
})
-12
View File
@@ -48,18 +48,6 @@ beforeEach(() => {
failConnect = false
})
it.effect("rejects unregistered workspace providers", () =>
Effect.gen(function* () {
const registry = WorkspaceDriver.registry({ fake: driver })
for (const provider of ["missing", "constructor", "toString", "__proto__"]) {
expect(yield* registry.get(provider).pipe(Effect.flip)).toEqual(
new WorkspaceDriver.ProviderNotFound({ provider }),
)
}
}),
)
it.effect("persists the workspace lifecycle and reconnects after idle suspension", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
@@ -1,99 +0,0 @@
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,6 +1,5 @@
// @refresh reload
import "./diagnostics"
import {
ACCEPTED_FILE_EXTENSIONS,
AppBaseProviders,
@@ -515,10 +515,11 @@ export function getToolInfo(
title: webSearchProviderLabel(metadata?.provider, i18n),
subtitle: input.query,
}
case "task":
case "subagent": {
const raw = input.agent ?? input.subagent_type
const type = typeof raw === "string" && raw ? raw[0]!.toUpperCase() + raw.slice(1) : undefined
case "task": {
const type =
typeof input.subagent_type === "string" && input.subagent_type
? input.subagent_type[0]!.toUpperCase() + input.subagent_type.slice(1)
: undefined
return {
icon: "task",
title: agentTitle(i18n, type),
@@ -593,12 +594,19 @@ 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) {
function taskSession(
input: Record<string, any>,
parentID: string | undefined,
sessions: SessionSummary[] | undefined,
agents?: readonly { name: string; color?: string }[],
) {
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
}
@@ -1545,16 +1553,16 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
// @ts-expect-error
const partMetadata = () => part().state?.metadata ?? emptyMetadata
const taskId = createMemo(() => {
if (part().tool !== "task" && part().tool !== "subagent") return
const value = partMetadata().sessionID ?? partMetadata().sessionId
if (part().tool !== "task") return
const value = partMetadata().sessionId
if (typeof value === "string" && value) return value
})
const taskHref = createMemo(() => {
if (part().tool !== "task" && part().tool !== "subagent") return
if (part().tool !== "task") return
return sessionLink(taskId(), data.sessionHref)
})
const taskSubtitle = createMemo(() => {
if (part().tool !== "task" && part().tool !== "subagent") return undefined
if (part().tool !== "task") return undefined
const value = input().description
if (typeof value === "string" && value) return value
return taskId()
@@ -1971,17 +1979,18 @@ ToolRegistry.register({
)
},
})
ToolRegistry.register({
name: "task",
render(props) {
const data = useData()
const i18n = useI18n()
const childSessionId = createMemo(() => {
const value = props.metadata.sessionID ?? props.metadata.sessionId
const value = props.metadata.sessionId
if (typeof value === "string" && value) return value
return taskSession(props.input, data.sessionID, data.store.session)
return taskSession(props.input, data.sessionID, data.store.session, data.store.agent)
})
const agent = createMemo(() => taskAgent(props.input.agent ?? props.input.subagent_type, data.store.agent))
const agent = createMemo(() => taskAgent(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)
@@ -1991,8 +2000,7 @@ ToolRegistry.register({
? props.input.description
: childSessionId()
if (!value) return value
if (props.input.background === true || props.metadata.background === true || props.metadata.status === "running")
return `${value} (background)`
if (props.metadata.background === true) return `${value} (background)`
return value
})
const running = createMemo(() => props.status === "pending" || props.status === "running")
@@ -2079,14 +2087,11 @@ 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" || props.metadata.status === "running"
const pending = () => props.status === "pending" || props.status === "running"
const sawPending = pending()
const text = createMemo(() => {
const cmd = props.input.command ?? props.metadata.command ?? ""
+1 -2
View File
@@ -115,10 +115,9 @@ export function elements(renderer: CliRenderer): Element[] {
}
export function state(harness: Harness) {
const renderable = harness.renderer.currentFocusedRenderable?.num
return {
focused: {
...(renderable === undefined ? {} : { renderable }),
renderable: harness.renderer.currentFocusedRenderable?.num,
editor: Boolean(harness.renderer.currentFocusedEditor),
},
elements: elements(harness.renderer),
-12
View File
@@ -14,18 +14,6 @@ test("matches literal screen text", () => {
expect(matches(harness, "opencode")).toBe(false)
})
test("omits an absent focused renderable from state", () => {
const harness = {
renderer: {
root: { getChildren: () => [] },
currentFocusedRenderable: undefined,
currentFocusedEditor: undefined,
},
} as unknown as Harness
expect(state(harness)).toEqual({ focused: { editor: false }, elements: [] })
})
test("normalizes named keys for OpenTUI", async () => {
const pressed: Array<readonly [string, object | undefined]> = []
const harness = {
-6
View File
@@ -7771,12 +7771,6 @@ body {
}
}
@media (min-width: 48rem) and (max-width: 58rem) {
[data-page="stats"] [data-slot="top-models-bar"][data-active="true"] {
z-index: 2;
}
}
@media (max-width: 47.999rem) {
[data-page="stats"] [data-section="top-models"],
[data-page="stats"] [data-section="leaderboard"],
@@ -1,11 +1,10 @@
import { createMemo, createSignal } from "solid-js"
import { useConfig } from "../config"
import { DialogSelect } from "../ui/dialog-select"
import { useTheme } from "../context/theme"
import { useToast } from "../ui/toast"
type Experiment = {
id: string
id: "tab_drafts"
title: string
description: string
}
@@ -15,33 +14,34 @@ type Experiment = {
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = [
{
id: "tab_scroll",
title: "Remember tab scroll",
description: "Keep each open tab's reading position and show a shortcut back to the bottom.",
id: "tab_drafts",
title: "Per-tab prompt drafts",
description: "Keep unsent prompt drafts on the tab where they were written. New sessions start blank.",
},
]
export function DialogExperiments() {
const config = useConfig()
const theme = useTheme()
const toast = useToast()
const [selected, setSelected] = createSignal<Experiment>()
const [selected, setSelected] = createSignal(0)
const [saving, setSaving] = createSignal(false)
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
const options = createMemo(() =>
experiments.map((experiment) => ({
experiments.map((experiment, index) => ({
title: experiment.title,
category: "Experiments",
searchText: experiment.description,
footer: enabled(experiment) ? "on" : "off",
value: experiment,
value: index,
})),
)
// All experiments are booleans, so either direction toggles.
async function change(experiment = selected()) {
async function change(index = selected()) {
if (saving()) return
const experiment = experiments[index]
if (!experiment) return
const next = !enabled(experiment)
setSaving(true)
@@ -58,33 +58,23 @@ export function DialogExperiments() {
<DialogSelect
title="Experiments"
options={options()}
renderFilter={experiments.length > 0}
onMove={(option) => setSelected(option.value)}
onSelect={(option) => void change(option.value)}
emptyView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No experiments available</text>
</box>
}
footerHints={experiments.length > 0 ? [{ title: "←/→", label: "change" }] : []}
bindings={
experiments.length > 0
? [
{
bind: "left",
title: "Previous value",
group: "Experiments",
run: () => void change(),
},
{
bind: "right",
title: "Next value",
group: "Experiments",
run: () => void change(),
},
]
: []
}
footerHints={[{ title: "←/→", label: "change" }]}
bindings={[
{
bind: "left",
title: "Previous value",
group: "Experiments",
run: () => void change(),
},
{
bind: "right",
title: "Next value",
group: "Experiments",
run: () => void change(),
},
]}
/>
)
}
@@ -1,18 +1,30 @@
import type { PromptInfo } from "../../prompt/history"
// Holds one in-progress draft per tab across Prompt remounts. A draft is
// consumed on take: restoring it moves it out of the stash, so a stale copy
// never shadows newer input.
// Holds one in-progress draft per slot across Prompt remounts. The undefined
// key is the default single global slot that follows focus across tabs; the
// tab_drafts experiment keys drafts by the tab (sessionID or "home") they
// were written in. A draft is consumed on take: restoring it moves it out of
// the stash, so a stale copy never shadows newer input.
export type DraftEntry = { prompt: PromptInfo; cursor: number }
const byTab = new Map<string | undefined, DraftEntry>()
let global: DraftEntry | undefined
const byTab = new Map<string, DraftEntry>()
export function takeDraft(sessionID: string | undefined) {
const entry = byTab.get(sessionID)
byTab.delete(sessionID)
export function takeDraft(key: string | undefined) {
if (key === undefined) {
const entry = global
global = undefined
return entry
}
const entry = byTab.get(key)
byTab.delete(key)
return entry
}
export function saveDraft(sessionID: string | undefined, entry: DraftEntry) {
byTab.set(sessionID, entry)
export function saveDraft(key: string | undefined, entry: DraftEntry) {
if (key === undefined) {
global = entry
return
}
byTab.set(key, entry)
}
+3 -2
View File
@@ -678,9 +678,10 @@ export function Prompt(props: PromptProps) {
// instance belongs to exactly one tab. Reading props.sessionID lazily would
// observe the *next* route during onCleanup and stash under the wrong tab.
const stashSessionID = props.sessionID
const stashKey = () => (config.experimental?.tab_drafts === true ? (stashSessionID ?? "home") : undefined)
onMount(() => {
const saved = takeDraft(stashSessionID)
const saved = takeDraft(stashKey())
if (store.prompt.text) return
if (saved && saved.prompt.text) {
input.setText(saved.prompt.text)
@@ -693,7 +694,7 @@ export function Prompt(props: PromptProps) {
onCleanup(() => {
disposed = true
if (store.prompt.text) {
saveDraft(stashSessionID, { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
saveDraft(stashKey(), { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
}
setInputTarget(undefined)
props.ref?.(undefined)
+3 -22
View File
@@ -21,7 +21,6 @@ import {
moveSessionTab,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
sessionTabDetail,
sessionTabShortcutLabel,
seedSessionTabMotion,
sessionTabOverflowWidth,
@@ -409,20 +408,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
const fixture = tabs.detail?.(tab.sessionID)
if (fixture !== undefined) return fixture
if (fixture !== undefined) return Locale.takeWidth(fixture, titleWidth())
const value = session()
const currentProject = project()
const projectLabel = projectName(currentProject, value?.location.directory) ?? ""
const vcs = value ? data.location.vcs.info(value.location) : undefined
const location = value ? data.location.info(value.location) : undefined
const worktree = !!location && location.project.directory !== location.project.canonical
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default, worktree)
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
})
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
const detailFades = createMemo(
() => marqueeOverflows(detail(), titleWidth()) && titleWidth() > FADE_WIDTH,
)
const background = createMemo(() => {
if (selected()) return theme.background.action.primary.selected
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
@@ -464,10 +453,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.42))
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const detailTextColor = (index: number) =>
detailFades()
? fadeTitleColor(detailColor(), pulseBackground(), index, visibleDetailParts().length, 0)
: detailColor()
const glows = () => status().glows
const previous = createMemo(() => items()[index() - 1])
const previousStatus = createMemo(() => {
@@ -685,11 +670,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
<Show when={detailFades()} fallback={visibleDetail()}>
<For each={visibleDetailParts()}>
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
</For>
</Show>
{detail()}
</text>
</box>
</box>
+7 -3
View File
@@ -192,9 +192,13 @@ export const Info = Schema.Struct({
}),
}),
).annotate({ description: "Debugging settings" }),
experimental: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "Experimental features that may change or be removed at any time",
}),
experimental: Schema.optional(
Schema.Struct({
tab_drafts: Schema.optional(Schema.Boolean).annotate({
description: "Keep unsent prompt drafts on the tab where they were written",
}),
}),
).annotate({ description: "Experimental features that may change or be removed at any time" }),
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
cursor: Schema.optional(Cursor),
+3 -6
View File
@@ -94,7 +94,7 @@ type Store = {
location: Record<string, LocationData>
}
export function locationKey(location: LocationRef) {
function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
@@ -1214,9 +1214,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
default() {
return defaultLocation()
},
syncInfo(ref?: LocationRef) {
async sync(ref?: LocationRef) {
const current = ref ?? defaultLocation()
return sync.run(`location:${locationKey(current)}`, async () => {
await sync.run(`location:${locationKey(current)}`, async () => {
const location = await client.api.location.get({ location: locationQuery(current) })
const key = locationKey(location)
if (!store.location[key]) setStore("location", key, {})
@@ -1225,9 +1225,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
}
})
},
async sync(ref?: LocationRef) {
await result.location.syncInfo(ref)
const location = ref ?? defaultLocation()
await Promise.all([
result.location.vcs.sync(location),
@@ -13,16 +13,6 @@ export function sessionTabShortcutLabel(index: number) {
return "·"
}
export function sessionTabDetail(
project: string,
current: string | undefined,
defaultBranch: string | undefined,
worktree: boolean,
) {
const branch = worktree && current !== defaultBranch ? current : undefined
return branch && project ? `${project}${branch}` : (branch ?? project)
}
export type SessionTabHistory = {
entries: readonly string[]
index: number
+7 -42
View File
@@ -2,7 +2,7 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { locationKey, useData } from "./data"
import { useData } from "./data"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { useEvent } from "./event"
import { useRoute } from "./route"
@@ -66,12 +66,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
let history: SessionTabHistory = { entries: [], index: -1 }
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
const scrollPositions = new Map<string, number>()
createEffect(() => {
if (config.experimental?.tab_scroll === true) return
scrollPositions.clear()
})
function state() {
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
@@ -165,9 +159,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
// Load lightweight session and location metadata concurrently so persisted tabs can resolve
// their project and branch labels. Delay the heavier per-tab data so the visible session keeps
// the first connection slots and switches still render from a warm cache.
// Load lightweight session metadata concurrently so persisted tabs can resolve their project
// labels immediately. Delay the heavier per-tab data so the visible session keeps the first
// connection slots and switches still render from a warm cache.
const openTabSessions = createMemo(() =>
state()
.tabs.map((tab) => tab.sessionID)
@@ -177,25 +171,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
createEffect(() => {
if (!enabled()) return
if (client.connection.status() !== "connected") return
const signature = openTabSessions()
if (signature === "") return
const sessionIDs = signature.split("\n")
const sessionIDs = openTabSessions()
if (sessionIDs === "") return
void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
let stale = false
void (async () => {
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID)))
if (stale) return
const locations = new Map(
sessionIDs
.map((sessionID) => data.session.get(sessionID)?.location)
.filter((location) => location !== undefined)
.map((location) => [locationKey(location), location]),
)
await Promise.allSettled(
Array.from(locations.values(), (location) =>
Promise.all([data.location.syncInfo(location), data.location.vcs.sync(location)]),
),
)
})()
const timer = setTimeout(async () => {
const sessions = state()
.tabs.map((tab) => tab.sessionID)
@@ -237,7 +216,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
scrollPositions.delete(target)
const closed = closeSessionTab(state().tabs, target)
const selected = navigate && current() === target
if (closed.tabs === state().tabs && !selected) return
@@ -269,19 +247,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
current,
status,
scrollPosition(sessionID: string) {
const target = root(sessionID)
if (!state().tabs.some((tab) => tab.sessionID === target)) return
return scrollPositions.get(target)
},
setScrollPosition(sessionID: string, position: number | undefined) {
const target = root(sessionID)
if (position === undefined || !state().tabs.some((tab) => tab.sessionID === target)) {
scrollPositions.delete(target)
return
}
scrollPositions.set(target, position)
},
select(sessionID: string) {
if (!enabled()) return
route.navigate({ type: "session", sessionID: root(sessionID) })
+43 -94
View File
@@ -274,7 +274,6 @@ export function Session() {
const [navigationSlack, setNavigationSlack] = createSignal(0)
const [synced, setSynced] = createSignal(false)
const sessionTabs = useSessionTabs()
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
const clearMessageNavigation = () => {
setNavigationSlack(0)
@@ -320,7 +319,7 @@ export function Session() {
return
}
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) restoreScrollPosition(sessionID)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
setSynced(true)
})().catch((error) => {
if (route.sessionID !== sessionID) return
@@ -336,13 +335,6 @@ export function Session() {
let seeded = false
let sent = false
let scroll: ScrollBoxRenderable
onCleanup(() => {
if (!scroll || scroll.isDestroyed) return
sessionTabs.setScrollPosition(
route.sessionID,
config.experimental?.tab_scroll === true && isAwayFromBottom() ? scroll.scrollTop : undefined,
)
})
const [prompt, setPrompt] = createSignal<PromptRef>()
const bind = (r: PromptRef | undefined) => {
setPrompt(r)
@@ -395,31 +387,6 @@ export function Session() {
afterLayout(continuation)
}
function isAwayFromBottom() {
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
}
function updateAwayFromBottom() {
if (config.experimental?.tab_scroll !== true) return
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
const away = isAwayFromBottom()
setAwayFromBottom(away)
if (!away) sessionTabs.setScrollPosition(route.sessionID, undefined)
})
}
function restoreScrollPosition(sessionID: string) {
const position = config.experimental?.tab_scroll === true ? sessionTabs.scrollPosition(sessionID) : undefined
if (position === undefined) {
scroll.scrollTo(scroll.scrollHeight)
setAwayFromBottom(false)
return
}
ensureAllRows(() => {
scroll.scrollTo(position)
updateAwayFromBottom()
})
}
createEffect(() => {
const current = prompt()
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return
@@ -528,8 +495,6 @@ export function Session() {
function toBottom() {
clearMessageNavigation()
setAwayFromBottom(false)
sessionTabs.setScrollPosition(route.sessionID, undefined)
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight)
@@ -545,7 +510,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 2)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -557,7 +521,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 2)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -569,7 +532,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-1)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -581,7 +543,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(1)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -593,7 +554,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 4)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -605,7 +565,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 4)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -620,7 +579,6 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollTo(0)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -630,7 +588,8 @@ export function Session() {
group: "Session",
palette: undefined,
run: () => {
toBottom()
clearMessageNavigation()
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
},
},
@@ -1047,6 +1006,8 @@ export function Session() {
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
}))
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
createEffect(
on(
() => route.sessionID,
@@ -1082,56 +1043,47 @@ export function Session() {
paddingBottom={1}
paddingLeft={dimensions().width < 44 ? 1 : 2}
paddingRight={dimensions().width < 44 ? 1 : 2}
gap={1}
>
<Show when={session()}>
<box flexGrow={1} minHeight={0} position="relative">
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.raise(theme.background.surface.offset),
foregroundColor: theme.border.default,
},
}}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
onMouseScroll={updateAwayFromBottom}
>
<For each={visibleRows()}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={messagesFromRevert().filter((message) => message.type === "user").length}
files={session()!.revert!.files ?? []}
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.raise(theme.background.surface.offset),
foregroundColor: theme.border.default,
},
}}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={visibleRows()}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
/>
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
</box>
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
<Show when={config.experimental?.tab_scroll === true && awayFromBottom()}>
<text fg={theme.text.subdued} onMouseUp={toBottom}>
Latest
</text>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={messagesFromRevert().filter((message) => message.type === "user").length}
files={session()!.revert!.files ?? []}
/>
</Show>
</box>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<box flexShrink={0}>
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
@@ -2137,7 +2089,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
const theme = useTheme("elevated")
const [hover, setHover] = createSignal(false)
const next = createMemo(() => props.prompts[0]?.text.replaceAll("\n", " "))
return (
@@ -2145,8 +2096,6 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
border={["left"]}
borderColor={theme.border.default}
customBorderChars={SplitBorder.customBorderChars}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={props.onOpen}
>
<box
@@ -2155,7 +2104,7 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
backgroundColor={theme.background.default}
flexDirection="row"
>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
@@ -11,20 +11,11 @@ import {
reopenSessionTab,
seedSessionTabMotion,
sessionTabComplete,
sessionTabDetail,
sessionTabOverflowWidth,
sessionTabShortcutLabel,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("appends the branch to the project detail", () => {
expect(sessionTabDetail("opencode", "feature/sidebar", "main", true)).toBe("opencode ⎇ feature/sidebar")
expect(sessionTabDetail("opencode", "feature/sidebar", undefined, true)).toBe("opencode ⎇ feature/sidebar")
expect(sessionTabDetail("opencode", "feature/sidebar", "main", false)).toBe("opencode")
expect(sessionTabDetail("opencode", "main", "main", true)).toBe("opencode")
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
})
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
"1",
@@ -28,14 +28,7 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
async function renderSessionTabs(
initialSessionID: string,
options?: {
state?: string
title?: string
home?: boolean
persisted?: string[]
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
},
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
) {
const temporary = options?.state ? undefined : await tmpdir()
const state = options?.state ?? temporary!.path
@@ -52,25 +45,7 @@ async function renderSessionTabs(
}
const events = createEventStream()
const sessions: string[] = []
const locations: string[] = []
const vcsLocations: string[] = []
const calls = createFetch(async (url) => {
if (url.pathname === "/api/location") {
const requested = url.searchParams.get("location[directory]") ?? directory
locations.push(requested)
return json({
directory: requested,
project: { id: "project", directory: requested, canonical: directory },
})
}
if (url.pathname === "/api/vcs") {
const requested = url.searchParams.get("location[directory]") ?? directory
vcsLocations.push(requested)
return json({
location: { directory: requested },
data: { branch: { current: "main", default: "main" } },
})
}
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
@@ -80,7 +55,7 @@ async function renderSessionTabs(
id: sessionID,
title: sessionID === initialSessionID ? options?.title : undefined,
projectID: "project",
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
@@ -132,8 +107,6 @@ async function renderSessionTabs(
route,
data,
sessions,
locations,
vcsLocations,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
async destroy() {
@@ -164,22 +137,6 @@ test("loads persisted tab metadata concurrently on connect", async () => {
}
})
test("loads VCS metadata for each persisted tab location", async () => {
const other = `${directory}/other-worktree`
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionDirectories: { second: other },
})
try {
await wait(() => setup.locations.includes(other))
await wait(() => setup.vcsLocations.includes(other))
} finally {
await setup.destroy()
}
})
test("stores session tabs for the current working directory by default", async () => {
const setup = await renderSessionTabs("first")

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