mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 15:03:43 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f0eef5c2a |
@@ -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")
|
||||
})
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 : []),
|
||||
}
|
||||
}
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
})()
|
||||
|
||||
@@ -122,10 +122,7 @@ const layer = Layer.effect(
|
||||
return { id: info?.id ?? defaultID, info }
|
||||
}),
|
||||
list: Effect.fn("Agent.list")(function* () {
|
||||
const agents = Array.fromIterable(state.get().agents.values())
|
||||
const selected = selectedDefault()
|
||||
if (!selected) return agents
|
||||
return [selected, ...agents.filter((agent) => agent.id !== selected.id)]
|
||||
return Array.fromIterable(state.get().agents.values())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -2,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, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { Fff } from "#fff"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { FileSystem } from "../filesystem.js"
|
||||
@@ -22,64 +22,38 @@ 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 files: string[] = []
|
||||
const directories = new Set<string>()
|
||||
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({
|
||||
yield* 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)
|
||||
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))
|
||||
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
|
||||
}),
|
||||
})
|
||||
index = next
|
||||
initialized = true
|
||||
}).pipe(
|
||||
Effect.orDie,
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
settledAt = clock.currentTimeMillisUnsafe()
|
||||
refreshing = false
|
||||
}),
|
||||
),
|
||||
)
|
||||
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
|
||||
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
||||
return Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* refresh
|
||||
const items =
|
||||
input.type === "file"
|
||||
? index.files
|
||||
? files
|
||||
: input.type === "directory"
|
||||
? Array.from(index.directories)
|
||||
: [...index.files, ...index.directories]
|
||||
? Array.from(directories)
|
||||
: [...files, ...directories]
|
||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||
const relative = item.target
|
||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 }))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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 } 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"
|
||||
@@ -57,70 +56,4 @@ describe("FileSystemSearch", () => {
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
|
||||
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 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")) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
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]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
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),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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 ?? ""
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -190,7 +190,7 @@ class PulseState {
|
||||
}
|
||||
|
||||
get live() {
|
||||
return this.active || this.breathing || this.envelopes.some(envelopeActive)
|
||||
return this.enabled && (this.active || this.breathing || this.envelopes.some(envelopeActive))
|
||||
}
|
||||
|
||||
get running() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { expect, test } from "bun:test"
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import {
|
||||
TabPulse,
|
||||
blendTabPulseColor,
|
||||
completionPulseOpacity,
|
||||
glowIgnitionLevel,
|
||||
@@ -9,6 +13,26 @@ import {
|
||||
} from "../../src/component/tab-pulse"
|
||||
import { tint } from "../../src/theme/color"
|
||||
|
||||
test("a disabled pulse stays idle when it becomes active", async () => {
|
||||
const background = RGBA.fromHex("#101010")
|
||||
const [active, setActive] = createSignal(false)
|
||||
const app = await testRender(
|
||||
() => <TabPulse enabled={false} active={active()} color={background} backgroundColor={background} />,
|
||||
{ width: 8, height: 1 },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
|
||||
setActive(true)
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
||||
expect(completionPulseOpacity(0)).toBe(0)
|
||||
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user