Compare commits

..

2 Commits

Author SHA1 Message Date
Dax Raad fa012bebcb fix(project): preserve cached project identity 2026-05-26 17:48:19 -04:00
Dax Raad 390f49b4b8 fix(opencode): preserve session update time during project migration 2026-05-24 19:09:18 -04:00
88 changed files with 960 additions and 2481 deletions
-1
View File
@@ -719,7 +719,6 @@
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Nol27sqG6za6oR32MqZrmRxch0/XFeQ61C9Yqtxo7Hk=",
"aarch64-linux": "sha256-+jfkGr4tuv/s/v890XaFTRqUxwA8H8KVMiys6sEXi/4=",
"aarch64-darwin": "sha256-FYtKluNazzuQcrr2iHm7B2pJfYrAHkbcj4kWs2FtBgc=",
"x86_64-darwin": "sha256-PpyWDkzb0p50US7Bs3m3A4PZ0JM3dccGgTKYN9JFYR0="
"x86_64-linux": "sha256-szOnLhAI4d3O4NaQvCRBufklmtRU8AR8p5LyFxjlTXo=",
"aarch64-linux": "sha256-mfyL4Pswl6HeObPg+TMK1r7RjvLCMgDWYrFnfHkXGj4=",
"aarch64-darwin": "sha256-XMK5IFCIxlSwH21Nqya8ZkAQ4Skp6GghukIl7NMp+so=",
"x86_64-darwin": "sha256-W7nLMO6qhR32442yMebEJJgaZRNpqTbZ8BJhdqMK3S4="
}
}
-1
View File
@@ -140,7 +140,6 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch"
}
}
@@ -1,395 +0,0 @@
import { expect, test, type Locator, type Page, type Route } from "@playwright/test"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
const sessionID = "ses_timeline_state_regression"
const userMessageID = "msg_user_regression"
const assistantMessageID = "msg_assistant_regression"
const editPartID = "prt_0001_edit"
const textPartID = "prt_9999_text"
const title = "Timeline collapse state regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
declare global {
interface Window {
__timelineDiffProbe: {
reset: () => void
shadowRoots: () => number
}
}
}
const userMessage = {
info: {
id: userMessageID,
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [
{
id: "prt_user_text",
sessionID,
messageID: userMessageID,
type: "text",
text: "Please edit the file.",
},
],
}
const editPart = {
id: editPartID,
sessionID,
messageID: assistantMessageID,
type: "tool",
callID: "call_edit_regression",
tool: "edit",
state: {
status: "completed",
input: { filePath: "src/regression.ts" },
output: "Edited src/regression.ts",
title: "src/regression.ts",
metadata: {
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
},
time: { start: 1700000001000, end: 1700000002000 },
},
}
const streamedTextPart = {
id: textPartID,
sessionID,
messageID: assistantMessageID,
type: "text",
text: "Streaming added a later assistant text part.",
}
const assistantMessage = {
info: {
id: assistantMessageID,
sessionID,
role: "assistant",
time: { created: 1700000001000 },
parentID: userMessageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
},
parts: [editPart],
}
test.describe("regression: session timeline local row state", () => {
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
const events: EventPayload[] = []
await mockServer(page, events)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expect(wrapper).toBeVisible()
await expectExpanded(wrapper, true)
await wrapper.evaluate((element) => {
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
})
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
await expectExpanded(wrapper, false)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: streamedTextPart },
},
})
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
expect(await readToolState(page)).toEqual({
expanded: false,
row: "AssistantPart",
streamedTextVisible: true,
})
})
test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
const events: EventPayload[] = []
await installDiffProbe(page)
await mockServer(page, events)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expect(wrapper).toBeVisible()
await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible()
await markDiffProbe(page)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: streamedTextPart },
},
})
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
await markDiffProbe(page)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: editPartWithAdditions(2) },
},
})
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
{ timeout: 10_000 },
)
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
})
})
async function configurePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
async function expectExpanded(locator: Locator, expected: boolean) {
await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
}
async function readToolState(page: Page) {
return page
.locator(`[data-timeline-part-id="${editPartID}"]`)
.first()
.evaluate(
(element, textPartID) => ({
expanded: (() => {
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
const root = element.querySelector('[data-component="collapsible"]')
if (root?.hasAttribute("data-expanded")) return true
if (root?.hasAttribute("data-closed")) return false
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
return !!content && content.getBoundingClientRect().height > 0
})(),
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
}),
textPartID,
)
}
async function installDiffProbe(page: Page) {
await page.addInitScript(() => {
let shadowRootCount = 0
const attachShadow = Element.prototype.attachShadow
Element.prototype.attachShadow = function (init) {
shadowRootCount += 1
return attachShadow.call(this, init)
}
window.__timelineDiffProbe = {
reset: () => {
shadowRootCount = 0
},
shadowRoots: () => shadowRootCount,
}
})
}
async function markDiffProbe(page: Page) {
await page
.locator(`[data-timeline-part-id="${editPartID}"]`)
.first()
.evaluate((element) => {
const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
if (!file) throw new Error("missing edit diff file")
tool.dataset.timelineProbe = "before"
file.dataset.timelineProbe = "before"
window.__timelineDiffProbe.reset()
})
}
async function readDiffProbe(page: Page) {
return page
.locator(`[data-timeline-part-id="${editPartID}"]`)
.first()
.evaluate((element) => {
const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
return {
fileMarker: file?.dataset.timelineProbe,
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
toolMarker: tool.dataset.timelineProbe,
}
})
}
function editPartWithAdditions(additions: number) {
return {
...editPart,
state: {
...editPart.state,
metadata: {
...editPart.state.metadata,
filediff: {
...editPart.state.metadata.filediff,
additions,
},
},
},
}
}
function readExpanded(element: Element) {
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
const root = element.querySelector('[data-component="collapsible"]')
if (root?.hasAttribute("data-expanded")) return true
if (root?.hasAttribute("data-closed")) return false
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
return !!content && content.getBoundingClientRect().height > 0
}
async function mockServer(page: Page, events: EventPayload[]) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event") return sse(route, events.splice(0))
if (
path === "/global/config" ||
path === "/config" ||
path === "/provider/auth" ||
path === "/mcp" ||
path === "/session/status"
)
return json(route, {})
if (
["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(
path,
)
)
return json(route, [])
if (path === "/provider") return json(route, provider())
if (path === "/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
if (path === "/project") return json(route, [project()])
if (path === "/project/current") return json(route, project())
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (path === "/session") return json(route, [session()])
if (path === `/session/${sessionID}`) return json(route, session())
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
if (path === `/session/${sessionID}/message`) return json(route, [userMessage, assistantMessage])
return json(route, {})
})
}
function project() {
return {
id: projectID,
worktree: directory,
vcs: "git",
name: "timeline-state-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
}
function session() {
return {
id: sessionID,
slug: "timeline-state-regression",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
}
}
function provider() {
return {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route, events: EventPayload[]) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
headers: { "access-control-allow-origin": "*" },
body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
})
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -1,300 +0,0 @@
import { expect, test, type Page, type Route } from "@playwright/test"
const directory = "C:/OpenCode/ContextResizeRegression"
const projectID = "proj_context_resize_regression"
const sessionID = "ses_context_resize_regression"
const title = "Context resize regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
const followingTextID = "prt_0104_text"
type Message = {
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
parts: Record<string, unknown>[]
}
const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
test.describe("regression: session timeline context group resize", () => {
test("remeasures a recent explored context group before the next paint", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockServer(page)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible()
await settle(page)
const samples = await sampleExpansion(page)
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
console.log("context resize samples", JSON.stringify(samples, null, 2))
expect(samples[0]?.overlap).toBe(0)
expect(visibleOverlap).toEqual([])
expect(samples.at(-1)?.expanded).toBe("true")
})
})
async function configurePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
async function sampleExpansion(page: Page) {
return page.evaluate(
({ contextIDs, followingTextID }) =>
new Promise<
{
frame: number
label: string
scrollTop: number
scrollHeight: number
contextBottom: number
textTop: number
overlap: number
gap: number
expanded: string | null
}[]
>((resolve) => {
const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
if (!context || !text || !scroller || !trigger || !contextRow || !textRow)
throw new Error("missing regression nodes")
scroller.scrollTop = scroller.scrollHeight
const samples: {
frame: number
label: string
scrollTop: number
scrollHeight: number
contextBottom: number
textTop: number
overlap: number
gap: number
expanded: string | null
}[] = []
const capture = (frame: number, label: string) => {
const contextRect = contextRow.getBoundingClientRect()
const textRect = textRow.getBoundingClientRect()
samples.push({
frame,
label,
scrollTop: Math.round(scroller.scrollTop * 10) / 10,
scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
contextBottom: Math.round(contextRect.bottom * 10) / 10,
textTop: Math.round(textRect.top * 10) / 10,
overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
expanded: trigger.getAttribute("aria-expanded"),
})
}
capture(-1, "before")
trigger.click()
capture(0, "sync-after-click")
let frame = 1
const tick = () => {
capture(frame, "raf")
frame += 1
if (frame > 8) {
resolve(samples)
return
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}),
{ contextIDs, followingTextID },
)
}
function turn(index: number, target: boolean): Message[] {
const userID = id("msg_user", index)
const assistantID = id("msg_assistant", index)
return [
{
info: {
id: userID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
},
{
info: {
id: assistantID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
parentID: userID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts: target
? [
contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }),
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
{
id: followingTextID,
sessionID,
messageID: assistantID,
type: "text",
text: "This assistant text is immediately after the explored context group.",
},
]
: [
{
id: id("prt_text", index),
sessionID,
messageID: assistantID,
type: "text",
text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
},
],
},
]
}
function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
return {
id: partID,
sessionID,
messageID,
type: "tool",
callID: `call_${partID}`,
tool,
state: {
status: "completed",
input,
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
title: input.filePath || input.path || input.pattern || "completed",
metadata: {},
time: { start: 1700000000000, end: 1700000000100 },
},
}
}
async function mockServer(page: Page) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route)
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(path))
return json(route, {})
if (
["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(
path,
)
)
return json(route, [])
if (path === "/provider") return json(route, provider())
if (path === "/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
if (path === "/project") return json(route, [project()])
if (path === "/project/current") return json(route, project())
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (path === "/session") return json(route, [session()])
if (path === `/session/${sessionID}`) return json(route, session())
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
if (path === `/session/${sessionID}/message`) return json(route, messages)
return json(route, {})
})
}
async function settle(page: Page) {
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
}
function id(prefix: string, index: number) {
return `${prefix}_${String(index).padStart(4, "0")}`
}
function project() {
return {
id: projectID,
worktree: directory,
vcs: "git",
name: "context-resize-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
}
function session() {
return {
id: sessionID,
slug: "context-resize-regression",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
}
}
function provider() {
return {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
+6 -6
View File
@@ -30,8 +30,8 @@ import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
@@ -304,8 +304,8 @@ export function AppInterface(props: {
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<QueryProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
@@ -316,8 +316,8 @@ export function AppInterface(props: {
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</ServerSyncProvider>
</ServerSDKProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
</QueryProvider>
</ServerKey>
</ConnectionGate>
@@ -12,15 +12,15 @@ import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const language = useLanguage()
const providers = useProviders()
@@ -41,7 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
() => providers.all().get(props.provider) ?? globalSync.data.provider.all.get(props.provider)!,
)
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
@@ -52,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) {
const [auth] = createResource(
() => props.provider,
async () => {
const cached = serverSync.data.provider_auth[props.provider]
const cached = globalSync.data.provider_auth[props.provider]
if (cached) return cached
const res = await serverSDK.client.provider.auth()
const res = await globalSDK.client.provider.auth()
if (!alive.value) return fallback()
serverSync.set("provider_auth", res.data ?? {})
globalSync.set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
)
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -158,7 +158,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}
dispatch({ type: "auth.pending" })
const start = Date.now()
await serverSDK.client.provider.oauth
await globalSDK.client.provider.oauth
.authorize(
{
providerID: props.provider,
@@ -330,7 +330,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
async function complete() {
await serverSDK.client.global.dispose()
await globalSDK.client.global.dispose()
dialog.close()
showToast({
variant: "success",
@@ -407,7 +407,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}
setFormStore("error", undefined)
await serverSDK.client.auth.set({
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
type: "api",
@@ -478,7 +478,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}
setFormStore("error", undefined)
const result = await serverSDK.client.provider.oauth
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
@@ -531,7 +531,7 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => {
void (async () => {
const result = await serverSDK.client.provider.oauth
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
@@ -9,8 +9,8 @@ import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
import { DialogSelectProvider } from "./dialog-select-provider"
@@ -21,8 +21,8 @@ type Props = {
export function DialogCustomProvider(props: Props) {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const language = useLanguage()
const [form, setForm] = createStore<FormState>({
@@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
const output = validateCustomProvider({
form,
t: language.t,
disabledProviders: serverSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(globalSync.data.provider.all.keys()),
})
batch(() => {
setForm("err", output.err)
@@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
const disabledProviders = serverSync.data.config.disabled_providers ?? []
const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) {
await serverSDK.client.auth.set({
await globalSDK.client.auth.set({
providerID: result.providerID,
auth: {
type: "api",
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
})
}
await serverSync.updateConfig({
await globalSync.updateConfig({
provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled,
})
@@ -6,8 +6,8 @@ import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
@@ -18,8 +18,8 @@ const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] a
export function DialogEditProject(props: { project: LocalProject }) {
const dialog = useDialog()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const language = useLanguage()
const folderName = createMemo(() => getFilename(props.project.worktree))
@@ -78,19 +78,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverSDK.client.project.update({
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverSync.project.icon(props.project.worktree, store.iconOverride || undefined)
globalSync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverSync.project.meta(props.project.worktree, {
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
@@ -6,8 +6,8 @@ import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useLanguage } from "@/context/language"
@@ -128,7 +128,7 @@ function uniqueRows(rows: Row[]) {
}
function useDirectorySearch(args: {
sdk: ReturnType<typeof useServerSDK>
sdk: ReturnType<typeof useGlobalSDK>
start: () => string | undefined
home: () => string
}) {
@@ -246,8 +246,8 @@ function useDirectorySearch(args: {
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useServerSync()
const sdk = useServerSDK()
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const layout = useLayout()
const dialog = useDialog()
const language = useLanguage()
@@ -9,8 +9,8 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
@@ -175,7 +175,7 @@ function createFileEntries(props: {
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
serverSDK: ReturnType<typeof useServerSDK>
globalSDK: ReturnType<typeof useGlobalSDK>
language: ReturnType<typeof useLanguage>
}) {
const state: {
@@ -207,7 +207,7 @@ function createSessionEntries(props: {
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props.serverSDK.client.session
return props.globalSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
@@ -268,8 +268,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files"
const state = { cleanup: undefined as (() => void) | void, committed: false }
@@ -292,21 +292,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync.data.path.home)
const homedir = createMemo(() => globalSync.data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync.child(directory, { bootstrap: false })
const [store] = globalSync.child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
}
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
const items = async (text: string) => {
const query = text.trim()
@@ -6,7 +6,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language"
import { useQueryOptions } from "@/context/server-sync"
import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key"
const statusLabels = {
+1 -1
View File
@@ -66,7 +66,7 @@ import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync"
import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key"
import { getFilename } from "@opencode-ai/core/util/path"
@@ -162,8 +162,8 @@ beforeAll(async () => {
}),
}))
mock.module("@/context/server-sync", () => ({
useServerSync: () => ({
mock.module("@/context/global-sync", () => ({
useGlobalSync: () => ({
child: (directory: string) => {
syncedDirectories.push(directory)
storedSessions[directory] ??= []
@@ -5,7 +5,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
@@ -38,7 +38,7 @@ export type FollowupDraft = {
type FollowupSendInput = {
client: ReturnType<typeof useSDK>["client"]
serverSync: ReturnType<typeof useServerSync>
globalSync: ReturnType<typeof useGlobalSync>
sync: ReturnType<typeof useSync>
draft: FollowupDraft
messageID?: string
@@ -53,7 +53,7 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
export async function sendFollowupDraft(input: FollowupSendInput) {
const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
const [, setStore] = input.serverSync.child(input.draft.sessionDirectory)
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
const setBusy = () => {
if (!input.optimisticBusy) return
@@ -205,7 +205,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
@@ -226,8 +226,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
serverSync.todo.set(sessionID, [])
const [, setStore] = serverSync.child(sdk.directory)
globalSync.todo.set(sessionID, [])
const [, setStore] = globalSync.child(sdk.directory)
setStore("todo", sessionID, [])
input.onAbort?.()
@@ -273,7 +273,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const seed = (dir: string, info: Session) => {
const [, setStore] = serverSync.child(dir)
const [, setStore] = globalSync.child(dir)
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
@@ -354,7 +354,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
directory: sessionDirectory,
throwOnError: true,
})
serverSync.child(sessionDirectory)
globalSync.child(sessionDirectory)
}
input.onNewSessionWorktreeReset?.()
@@ -557,7 +557,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
void sendFollowupDraft({
client,
sync,
serverSync,
globalSync,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
@@ -1,7 +1,7 @@
import type { JSX } from "solid-js"
import { createMemo } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
@@ -15,7 +15,7 @@ import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
const MAIN_WORKTREE = "main"
export function NewSessionDesignView(props: { worktree: string; children: JSX.Element }) {
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const layout = useLayout()
const navigate = useNavigate()
const sdk = useSDK()
@@ -24,7 +24,7 @@ export function NewSessionDesignView(props: { worktree: string; children: JSX.El
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const projects = createMemo(() => {
const roots = serverSync.data.project.map((project) => project.worktree)
const roots = globalSync.data.project.map((project) => project.worktree)
if (roots.includes(projectRoot())) return roots
return [projectRoot(), ...roots]
})
@@ -12,8 +12,8 @@ import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import {
monoDefault,
monoFontFamily,
@@ -175,8 +175,8 @@ export const SettingsGeneral: Component = () => {
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
const globalSdk = useServerSDK()
const globalSync = useGlobalSync()
const globalSdk = useGlobalSDK()
const [shells] = createResource(
() =>
@@ -204,11 +204,11 @@ export const SettingsGeneral: Component = () => {
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync.data.config.shell
const current = globalSync.data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
@@ -343,7 +343,7 @@ export const SettingsGeneral: Component = () => {
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync.updateConfig({ shell: option.value })
globalSync.updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
@@ -6,8 +6,8 @@ import { showToast } from "@opencode-ai/ui/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
@@ -30,8 +30,8 @@ const PROVIDER_NOTES = [
export const SettingsProviders: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const providers = useProviders()
const connected = createMemo(() => {
@@ -74,7 +74,7 @@ export const SettingsProviders: Component = () => {
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = serverSync.data.config.provider?.[providerID]
const provider = globalSync.data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -82,11 +82,11 @@ export const SettingsProviders: Component = () => {
}
const disableProvider = async (providerID: string, name: string) => {
const before = serverSync.data.config.disabled_providers ?? []
const before = globalSync.data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync.set("config", "disabled_providers", next)
globalSync.set("config", "disabled_providers", next)
await serverSync
await globalSync
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
@@ -97,7 +97,7 @@ export const SettingsProviders: Component = () => {
})
})
.catch((err: unknown) => {
serverSync.set("config", "disabled_providers", before)
globalSync.set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@@ -105,14 +105,14 @@ export const SettingsProviders: Component = () => {
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSDK.client.auth.remove({ providerID }).catch(() => undefined)
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSDK.client.auth
await globalSDK.client.auth
.remove({ providerID })
.then(async () => {
await serverSDK.client.global.dispose()
await globalSDK.client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
@@ -15,7 +15,7 @@ import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { useQueryOptions } from "@/context/server-sync"
import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key"
const pollMs = 10_000
+3 -3
View File
@@ -16,7 +16,7 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSync } from "@/context/global-sync"
import { decodeDirectory } from "@/pages/directory-layout"
import { iife } from "@opencode-ai/core/util/iife"
import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -221,7 +221,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch>
<Match when={USE_V2_TITLEBAR}>
{(_) => {
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const navigate = useNavigate()
const homeMatch = useMatch(() => "/")
@@ -391,7 +391,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const base = mapArray(
() => tabsStore,
(tab) => {
const sync = serverSync.createDirSyncContext(tab.dir)
const sync = globalSync.createDirSyncContext(tab.dir)
const session = sync.session.get(tab.sessionId)
return session ? { ...tab, info: session } : null
},
+21 -23
View File
@@ -8,11 +8,10 @@ import {
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { createServerSyncContext, useServerSync } from "./server-sync"
import { useGlobalSync } from "./global-sync"
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { useServerSDK } from "./server-sdk"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -165,17 +164,16 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
})
}
export const createDirSyncContext = (directory: string, serverSync: ReturnType<typeof createServerSyncContext>) => {
const serverSDK = useServerSDK()
const client = serverSDK.createClient({ directory, throwOnError: true })
export const createDirSyncContext = (client: OpencodeClient, directory: string) => {
const globalSync = useGlobalSync()
type Child = ReturnType<(typeof serverSync)["child"]>
type Child = ReturnType<(typeof globalSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => serverSync.child(directory))
const current = createMemo(() => globalSync.child(directory))
const target = (directory?: string) => {
if (!directory || directory === directory) return current()
return serverSync.child(directory)
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
@@ -241,7 +239,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = serverSync.child(first, { bootstrap: false })
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
@@ -269,7 +267,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
serverSync.todo.set(sessionID, undefined)
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
@@ -326,7 +324,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = serverSync.child(input.directory, { bootstrap: false })
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
@@ -375,8 +373,8 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
},
get project() {
const store = current()[0]
const match = Binary.search(serverSync.data.project, store.project, (p) => p.id)
if (match.found) return serverSync.data.project[match.index]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {
@@ -420,7 +418,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = serverSync.child(directory)
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
@@ -490,7 +488,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
})
},
async diff(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = serverSync.child(directory)
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
@@ -503,13 +501,13 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = serverSync.child(directory)
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = serverSync.data.session_todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
serverSync.todo.set(sessionID, existing)
globalSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
@@ -524,7 +522,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
serverSync.todo.set(sessionID, list)
globalSync.todo.set(sessionID, list)
}),
)
},
@@ -542,7 +540,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const [, setStore] = serverSync.child(directory)
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
@@ -563,12 +561,12 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
},
},
evict(sessionID: string, _directory = directory) {
const [, setStore] = serverSync.child(_directory)
const [, setStore] = globalSync.child(_directory)
seenFor(_directory).delete(sessionID)
evict(_directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const [store, setStore] = serverSync.child(directory)
const [store, setStore] = globalSync.child(directory)
setStore("limit", (x) => x + count)
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
@@ -580,7 +578,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const [, setStore] = serverSync.child(directory)
const [, setStore] = globalSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
+309
View File
@@ -0,0 +1,309 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { useServer } from "./server"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
init: () => {
const language = useLanguage()
const server = useServer()
const platform = usePlatform()
const abort = new AbortController()
const eventFetch = (() => {
if (!platform.fetch || !server.current) return
try {
const url = new URL(server.current.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
return
}
})()
const currentServer = server.current
if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable"))
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: currentServer.http,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
let queue: Queued[] = []
let buffer: Queued[] = []
const coalesced = new Map<string, number>()
const staleDeltas = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
if (queue.length === 0) return
const events = queue
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
queue = buffer
buffer = events
queue.length = 0
coalesced.clear()
staleDeltas.clear()
last = Date.now()
batch(() => {
for (const event of events) {
if (skip && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
}
emitter.emit(event.directory, event.payload)
}
})
buffer.length = 0
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = isAbortError
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
const start = () => {
if (started) return run
started = true
run = (async () => {
// 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) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
if (event.payload.type === "sync") {
continue
}
const payload = event.payload as Event
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (abort.signal.aborted || !started) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
run = undefined
flush()
})
return run
}
const stop = () => {
started = false
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
})
onCleanup(() => {
stop()
abort.abort()
flush()
})
const sdk = createSdkForServer({
server: server.current.http,
fetch: platform.fetch,
throwOnError: true,
})
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSdkContext>>()
const dirSdkContextRefCounts = new Map<string, number>()
return {
url: currentServer.http.url,
client: sdk,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
return createSdkForServer({
server: s.http,
fetch: platform.fetch,
...opts,
})
},
createDirSyncContext: (directory: string) => {
onCleanup(() => {
dirSdkContextRefCounts.set(directory, (dirSdkContextRefCounts.get(directory) ?? 0) - 1)
if (dirSdkContextRefCounts.get(directory) === 0) {
dirSyncContexts.delete(directory)
dirSdkContextRefCounts.delete(directory)
}
})
const cached = dirSyncContexts.get(directory)
if (cached) {
dirSdkContextRefCounts.set(directory, (dirSdkContextRefCounts.get(directory) ?? 0) + 1)
return cached
}
const ctx = createDirSdkContext(directory)
dirSyncContexts.set(directory, ctx)
dirSdkContextRefCounts.set(directory, 1)
return ctx
},
}
},
})
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
function createDirSdkContext(directory: string) {
const globalSDK = useGlobalSDK()
const client = globalSDK.createClient({
directory,
throwOnError: true,
})
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = globalSDK.event.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
return {
directory,
client,
event: emitter,
get url() {
return globalSDK.url
},
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
return globalSDK.createClient(opts)
},
}
}
@@ -5,7 +5,7 @@ import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, u
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { useServerSDK } from "./server-sdk"
import { useGlobalSDK } from "./global-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
@@ -29,8 +29,7 @@ import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { createRefCountMap } from "@/utils/refcount"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
type GlobalStore = {
ready: boolean
@@ -58,13 +57,13 @@ export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
})
function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
return {
globalConfig: () => loadGlobalConfigQuery(serverSDK()),
projects: () => loadProjectsQuery(serverSDK()),
globalConfig: () => loadGlobalConfigQuery(globalSDK()),
projects: () => loadProjectsQuery(globalSDK()),
providers: (directory: PathKey | null) =>
loadProvidersQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
loadProvidersQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
@@ -73,11 +72,11 @@ function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: Path
}
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContext() {
const serverSDK = useServerSDK()
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const language = useLanguage()
const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner")
if (!owner) throw new Error("GlobalSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>()
@@ -88,7 +87,7 @@ export function createServerSyncContext() {
const key = directoryKey(directory)
const cached = sdkCache.get(key)
if (cached) return cached
const sdk = serverSDK.createClient({
const sdk = globalSDK.createClient({
directory,
throwOnError: true,
})
@@ -96,7 +95,7 @@ export function createServerSyncContext() {
return sdk
}
const queryOptionsApi = makeQueryOptionsApi(() => serverSDK.client, sdkFor)
const queryOptionsApi = makeQueryOptionsApi(() => globalSDK.client, sdkFor)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@@ -155,7 +154,7 @@ export function createServerSyncContext() {
queryKey: ["bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverSDK: serverSDK.client,
globalSDK: globalSDK.client,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
@@ -249,7 +248,7 @@ export function createServerSyncContext() {
loadRootSessionsWithFallback({
directory,
limit,
list: (query) => serverSDK.client.session.list(query),
list: (query) => globalSDK.client.session.list(query),
})
.then((x) => {
const nonArchived = (x.data ?? [])
@@ -335,7 +334,7 @@ export function createServerSyncContext() {
return promise
}
const unsub = serverSDK.event.listen((e) => {
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
@@ -394,13 +393,13 @@ export function createServerSyncContext() {
eventFrame = undefined
eventTimer = setTimeout(() => {
eventTimer = undefined
void serverSDK.event.start()
void globalSDK.event.start()
}, 0)
})
} else {
eventTimer = setTimeout(() => {
eventTimer = undefined
void serverSDK.event.start()
void globalSDK.event.start()
}, 0)
}
})
@@ -416,7 +415,7 @@ export function createServerSyncContext() {
}
const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }),
onSuccess: () => {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
@@ -426,6 +425,9 @@ export function createServerSyncContext() {
},
}))
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSyncContext>>()
const dirSyncContextRefCounts = new Map<string, number>()
return {
data: globalStore,
set,
@@ -444,21 +446,42 @@ export function createServerSyncContext() {
todo: {
set: setSessionTodo,
},
createDirSyncContext: (directory: string) => {
onCleanup(() => {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) - 1)
if (dirSyncContextRefCounts.get(directory) === 0) {
dirSyncContexts.delete(directory)
dirSyncContextRefCounts.delete(directory)
}
})
const cached = dirSyncContexts.get(directory)
if (cached) {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) + 1)
return cached
}
const ctx = createDirSyncContext(globalSDK.createClient({ directory, throwOnError: true }), directory)
dirSyncContexts.set(directory, ctx)
dirSyncContextRefCounts.set(directory, 1)
return ctx
},
}
}
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
name: "ServerSync",
init: () => {
const sync = createServerSyncContext()
const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
return {
...sync,
createDirSyncContext: createRefCountMap((dir) => createDirSyncContext(dir, sync)),
}
},
})
export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
}
export function useGlobalSync() {
const context = useContext(GlobalSyncContext)
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
return context
}
export function useQueryOptions() {
return useServerSync().queryOptions
return useGlobalSync().queryOptions
}
@@ -18,7 +18,7 @@ import type { State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../server-sync"
import { loadMcpQuery } from "../global-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
type GlobalStore = {
@@ -105,7 +105,7 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
})
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
globalSDK: OpencodeClient
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
@@ -113,12 +113,12 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient
}) {
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.serverSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.globalSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.globalSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(null, input.globalSDK)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.serverSDK))
.fetchQuery(loadProjectsQuery(input.globalSDK))
.then((data) => input.setGlobalStore("project", data)),
]
await runAll(slow)
@@ -3,7 +3,7 @@ import { createRoot, getOwner, type Owner } from "solid-js"
import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync"
import type { QueryOptionsApi } from "../global-sync"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
@@ -15,7 +15,7 @@ import {
} from "./types"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQueries } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync"
import { QueryOptionsApi } from "../global-sync"
import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
+13 -13
View File
@@ -2,8 +2,8 @@ import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
@@ -136,8 +136,8 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
const globalSdk = useServerSDK()
const serverSync = useServerSync()
const globalSdk = useGlobalSDK()
const globalSync = useGlobalSync()
const server = useServer()
const platform = usePlatform()
@@ -386,11 +386,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
const projectID = childStore.project
const metadata = projectID
? serverSync.data.project.find((x) => x.id === projectID)
: serverSync.data.project.find((x) => x.worktree === project.worktree)
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
@@ -404,7 +404,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of serverSync.data.project) {
for (const project of globalSync.data.project) {
const sandboxes = project.sandboxes ?? []
for (const sandbox of sandboxes) {
map.set(sandbox, project.worktree)
@@ -470,12 +470,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
if (!serverSync.ready) return
if (!globalSync.ready) return
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
serverSync.project.icon(project.worktree, project.icon?.override)
globalSync.project.icon(project.worktree, project.icon?.override)
}
})
@@ -509,7 +509,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
colorRequested.set(worktree, color)
if (project.id === "global") {
serverSync.project.meta(worktree, { icon: { color } })
globalSync.project.meta(worktree, { icon: { color } })
continue
}
@@ -531,7 +531,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return serverSync.project.loadSessions(project.worktree)
return globalSync.project.loadSessions(project.worktree)
}),
)
}, 0)
@@ -560,7 +560,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
open(directory: string) {
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return
void serverSync.project.loadSessions(root)
void globalSync.project.loadSessions(root)
server.projects.open(root)
},
close(directory: string) {
+7 -7
View File
@@ -2,8 +2,8 @@ import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk"
import { useServerSync } from "./server-sync"
import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
@@ -109,8 +109,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
name: "Notification",
init: () => {
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
@@ -207,10 +207,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const [syncStore] = serverSync.child(directory, { bootstrap: false })
const [syncStore] = globalSync.child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
if (match.found) return syncStore.session[match.index]
return serverSDK.client.session
return globalSDK.client.session
.get({ directory, sessionID })
.then((x) => x.data)
.catch(() => undefined)
@@ -285,7 +285,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
})
}
const unsub = serverSDK.event.listen((e) => {
const unsub = globalSDK.event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
+13 -13
View File
@@ -3,8 +3,8 @@ import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "./server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64"
import {
@@ -48,13 +48,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
name: "Permission",
init: () => {
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const permissionsEnabled = createMemo(() => {
const directory = decode64(params.dir)
if (!directory) return false
const [store] = serverSync.child(directory)
const [store] = globalSync.child(directory)
return hasPermissionPromptRules(store.config.permission)
})
@@ -86,7 +86,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (!ready()) return
const directory = decode64(params.dir)
if (!directory) return
const [childStore] = serverSync.child(directory)
const [childStore] = globalSync.child(directory)
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory)
@@ -118,7 +118,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
const respond: PermissionRespondFn = (input) => {
serverSDK.client.permission.respond(input).catch(() => {
globalSDK.client.permission.respond(input).catch(() => {
responded.delete(input.permissionID)
})
}
@@ -139,7 +139,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function isAutoAccepting(sessionID: string, directory?: string) {
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
}
@@ -148,7 +148,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, permission, directory)
}
@@ -159,7 +159,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return next
}
const unsubscribe = serverSDK.event.listen((e) => {
const unsubscribe = globalSDK.event.listen((e) => {
const event = e.details
if (event?.type !== "permission.asked") return
@@ -178,7 +178,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
serverSDK.client.permission
globalSDK.client.permission
.list({ directory })
.then((x) => {
if (!isAutoAcceptingDirectory(directory)) return
@@ -210,7 +210,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
serverSDK.client.permission
globalSDK.client.permission
.list({ directory })
.then((x) => {
if (enableVersion.get(key) !== version) return
@@ -268,7 +268,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
},
permissionsEnabled,
isPermissionAllowAll(directory: string) {
const [childStore] = serverSync.child(directory)
const [childStore] = globalSync.child(directory)
const perm = childStore.config.permission
return typeof perm === "string" && perm === "allow"
},
+3 -3
View File
@@ -1,11 +1,11 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk"
import { useGlobalSDK } from "./global-sdk"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: string }) => {
const serverSDK = useServerSDK()
const globalSDK = useGlobalSDK()
return serverSDK.createDirSdkContext(props.directory)
return globalSDK.createDirSyncContext(props.directory)
},
})
-290
View File
@@ -1,290 +0,0 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
function createServerSdkContext(server: ServerConnection.Any) {
const platform = usePlatform()
const abort = new AbortController()
const eventFetch = (() => {
if (!platform.fetch || !server) return
try {
const url = new URL(server.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
return
}
})()
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: server.http,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
let queue: Queued[] = []
let buffer: Queued[] = []
const coalesced = new Map<string, number>()
const staleDeltas = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
if (queue.length === 0) return
const events = queue
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
queue = buffer
buffer = events
queue.length = 0
coalesced.clear()
staleDeltas.clear()
last = Date.now()
batch(() => {
for (const event of events) {
if (skip && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
}
emitter.emit(event.directory, event.payload)
}
})
buffer.length = 0
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = isAbortError
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
const start = () => {
if (started) return run
started = true
run = (async () => {
// 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) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
if (event.payload.type === "sync") {
continue
}
const payload = event.payload as Event
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !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
clearHeartbeat()
}
if (abort.signal.aborted || !started) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
run = undefined
flush()
})
return run
}
const stop = () => {
started = false
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
})
onCleanup(() => {
stop()
abort.abort()
flush()
})
const sdk = createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
})
return {
url: server.http.url,
client: sdk,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
return createSdkForServer({
server: server.http,
fetch: platform.fetch,
...opts,
})
},
}
}
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
name: "ServerSDK",
init: () => {
const language = useLanguage()
const server = useServer()
if (!server.current) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const sdk = createServerSdkContext(server.current)
return {
...sdk,
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
}
},
})
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ReturnType<typeof createServerSdkContext>) {
const client = serverSDK.createClient({
directory,
throwOnError: true,
})
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
return {
directory,
client,
event: emitter,
get url() {
return serverSDK.url
},
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
return serverSDK.createClient(opts)
},
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { useServerSync } from "./server-sync"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@@ -109,8 +109,8 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
}
export const useSync = () => {
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const sdk = useSDK()
return serverSync.createDirSyncContext(sdk.directory)
return globalSync.createDirSyncContext(sdk.directory)
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { useServerSync } from "@/context/server-sync"
import { useGlobalSync } from "@/context/global-sync"
import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect"
@@ -17,15 +17,15 @@ export const popularProviders = [
const popularProviderSet = new Set(popularProviders)
export function useProviders() {
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const params = useParams()
const dir = createMemo(() => decode64(params.dir) ?? "")
const providers = () => {
if (dir()) {
const [projectStore] = serverSync.child(dir())
const [projectStore] = globalSync.child(dir())
if (projectStore.provider_ready) return projectStore.provider
}
return serverSync.data.provider
return globalSync.data.provider
}
return {
all: () => providers().all,
+3 -3
View File
@@ -413,7 +413,7 @@ export const dict = {
"error.page.version": "الإصدار: {{version}}",
"error.dev.rootNotFound":
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
"error.serverSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
"error.globalSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
"directory.error.invalidUrl": "دليل غير صالح في عنوان URL.",
"error.chain.unknown": "خطأ غير معروف",
"error.chain.causedBy": "بسبب:",
@@ -837,8 +837,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "غير معروف",
"error.page.circular": "[دائري]",
"error.serverSDK.noServerAvailable": "لا يوجد خادم متاح",
"error.serverSDK.serverNotAvailable": "الخادم غير متاح",
"error.globalSDK.noServerAvailable": "لا يوجد خادم متاح",
"error.globalSDK.serverNotAvailable": "الخادم غير متاح",
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
"error.childStore.persistedProjectMetadataCreateFailed": "فشل إنشاء بيانات تعريف المشروع الدائمة",
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
+3 -3
View File
@@ -414,7 +414,7 @@ export const dict = {
"error.page.version": "Versão: {{version}}",
"error.dev.rootNotFound":
"Elemento raiz não encontrado. Você esqueceu de adicioná-lo ao seu index.html? Ou talvez o atributo id foi escrito incorretamente?",
"error.serverSync.connectFailed": "Não foi possível conectar ao servidor. Há um servidor executando em `{{url}}`?",
"error.globalSync.connectFailed": "Não foi possível conectar ao servidor. Há um servidor executando em `{{url}}`?",
"directory.error.invalidUrl": "Diretório inválido na URL.",
"error.chain.unknown": "Erro desconhecido",
"error.chain.causedBy": "Causado por:",
@@ -850,8 +850,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "desconhecido",
"error.page.circular": "[Circular]",
"error.serverSDK.noServerAvailable": "Nenhum servidor disponível",
"error.serverSDK.serverNotAvailable": "Servidor indisponível",
"error.globalSDK.noServerAvailable": "Nenhum servidor disponível",
"error.globalSDK.serverNotAvailable": "Servidor indisponível",
"error.childStore.persistedCacheCreateFailed": "Falha ao criar cache persistente",
"error.childStore.persistedProjectMetadataCreateFailed": "Falha ao criar metadados de projeto persistentes",
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
+3 -3
View File
@@ -462,7 +462,7 @@ export const dict = {
"error.dev.rootNotFound":
"Korijenski element nije pronađen. Da li si zaboravio da ga dodaš u index.html? Ili je možda id atribut pogrešno napisan?",
"error.serverSync.connectFailed": "Nije moguće povezati se na server. Da li server radi na `{{url}}`?",
"error.globalSync.connectFailed": "Nije moguće povezati se na server. Da li server radi na `{{url}}`?",
"directory.error.invalidUrl": "Nevažeći direktorij u URL-u.",
"error.chain.unknown": "Nepoznata greška",
@@ -926,8 +926,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "nepoznato",
"error.page.circular": "[Kružno]",
"error.serverSDK.noServerAvailable": "Nema dostupnog servera",
"error.serverSDK.serverNotAvailable": "Server nije dostupan",
"error.globalSDK.noServerAvailable": "Nema dostupnog servera",
"error.globalSDK.serverNotAvailable": "Server nije dostupan",
"error.childStore.persistedCacheCreateFailed": "Nije uspjelo kreiranje trajnog keša",
"error.childStore.persistedProjectMetadataCreateFailed": "Nije uspjelo kreiranje trajnih metapodataka projekta",
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
+3 -3
View File
@@ -459,7 +459,7 @@ export const dict = {
"error.dev.rootNotFound":
"Rodelement ikke fundet. Har du glemt at tilføje det til din index.html? Eller måske er id-attributten stavet forkert?",
"error.serverSync.connectFailed": "Kunne ikke forbinde til server. Kører der en server på `{{url}}`?",
"error.globalSync.connectFailed": "Kunne ikke forbinde til server. Kører der en server på `{{url}}`?",
"directory.error.invalidUrl": "Ugyldig mappe i URL.",
"error.chain.unknown": "Ukendt fejl",
@@ -920,8 +920,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "ukendt",
"error.page.circular": "[Cirkulær]",
"error.serverSDK.noServerAvailable": "Ingen server tilgængelig",
"error.serverSDK.serverNotAvailable": "Server ikke tilgængelig",
"error.globalSDK.noServerAvailable": "Ingen server tilgængelig",
"error.globalSDK.serverNotAvailable": "Server ikke tilgængelig",
"error.childStore.persistedCacheCreateFailed": "Kunne ikke oprette vedvarende cache",
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke oprette vedvarende projektmetadata",
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
+3 -3
View File
@@ -421,7 +421,7 @@ export const dict = {
"error.page.version": "Version: {{version}}",
"error.dev.rootNotFound":
"Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?",
"error.serverSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
"error.globalSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
"directory.error.invalidUrl": "Ungültiges Verzeichnis in der URL.",
"error.chain.unknown": "Unbekannter Fehler",
"error.chain.causedBy": "Verursacht durch:",
@@ -862,8 +862,8 @@ export const dict = {
"common.key.insert": "Einfg",
"common.unknown": "unbekannt",
"error.page.circular": "[Zirkulär]",
"error.serverSDK.noServerAvailable": "Kein Server verfügbar",
"error.serverSDK.serverNotAvailable": "Server nicht verfügbar",
"error.globalSDK.noServerAvailable": "Kein Server verfügbar",
"error.globalSDK.serverNotAvailable": "Server nicht verfügbar",
"error.childStore.persistedCacheCreateFailed": "Dauerhafter Cache konnte nicht erstellt werden",
"error.childStore.persistedProjectMetadataCreateFailed": "Dauerhafte Projektmetadaten konnten nicht erstellt werden",
"error.childStore.persistedProjectIconCreateFailed": "Dauerhaftes Projekticon konnte nicht erstellt werden",
+3 -3
View File
@@ -482,9 +482,9 @@ export const dict = {
"error.dev.rootNotFound":
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
"error.serverSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
"error.serverSDK.noServerAvailable": "No server available",
"error.serverSDK.serverNotAvailable": "Server not available",
"error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
"error.globalSDK.noServerAvailable": "No server available",
"error.globalSDK.serverNotAvailable": "Server not available",
"error.childStore.persistedCacheCreateFailed": "Failed to create persisted cache",
"error.childStore.persistedProjectMetadataCreateFailed": "Failed to create persisted project metadata",
"error.childStore.persistedProjectIconCreateFailed": "Failed to create persisted project icon",
+3 -3
View File
@@ -462,7 +462,7 @@ export const dict = {
"error.dev.rootNotFound":
"Elemento raíz no encontrado. ¿Olvidaste añadirlo a tu index.html? ¿O tal vez el atributo id está mal escrito?",
"error.serverSync.connectFailed": "No se pudo conectar al servidor. ¿Hay un servidor ejecutándose en `{{url}}`?",
"error.globalSync.connectFailed": "No se pudo conectar al servidor. ¿Hay un servidor ejecutándose en `{{url}}`?",
"directory.error.invalidUrl": "URL de directorio inválida.",
"error.chain.unknown": "Error desconocido",
@@ -933,8 +933,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "desconocido",
"error.page.circular": "[Circular]",
"error.serverSDK.noServerAvailable": "Ningún servidor disponible",
"error.serverSDK.serverNotAvailable": "Servidor no disponible",
"error.globalSDK.noServerAvailable": "Ningún servidor disponible",
"error.globalSDK.serverNotAvailable": "Servidor no disponible",
"error.childStore.persistedCacheCreateFailed": "Error al crear caché persistente",
"error.childStore.persistedProjectMetadataCreateFailed": "Error al crear metadatos de proyecto persistentes",
"error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente",
+3 -3
View File
@@ -417,7 +417,7 @@ export const dict = {
"error.page.version": "Version : {{version}}",
"error.dev.rootNotFound":
"Élément racine introuvable. Avez-vous oublié de l'ajouter à votre index.html ? Ou peut-être que l'attribut id est mal orthographié ?",
"error.serverSync.connectFailed":
"error.globalSync.connectFailed":
"Impossible de se connecter au serveur. Y a-t-il un serveur en cours d'exécution à `{{url}}` ?",
"directory.error.invalidUrl": "Répertoire invalide dans l'URL.",
"error.chain.unknown": "Erreur inconnue",
@@ -860,8 +860,8 @@ export const dict = {
"common.key.insert": "Inser",
"common.unknown": "inconnu",
"error.page.circular": "[Circulaire]",
"error.serverSDK.noServerAvailable": "Aucun serveur disponible",
"error.serverSDK.serverNotAvailable": "Serveur non disponible",
"error.globalSDK.noServerAvailable": "Aucun serveur disponible",
"error.globalSDK.serverNotAvailable": "Serveur non disponible",
"error.childStore.persistedCacheCreateFailed": "Échec de la création du cache persistant",
"error.childStore.persistedProjectMetadataCreateFailed":
"Échec de la création des métadonnées de projet persistantes",
+3 -3
View File
@@ -413,7 +413,7 @@ export const dict = {
"error.page.version": "バージョン: {{version}}",
"error.dev.rootNotFound":
"ルート要素が見つかりません。index.htmlに追加するのを忘れていませんか?またはid属性のスペルが間違っていませんか?",
"error.serverSync.connectFailed": "サーバーに接続できませんでした。`{{url}}`でサーバーが実行されていますか?",
"error.globalSync.connectFailed": "サーバーに接続できませんでした。`{{url}}`でサーバーが実行されていますか?",
"directory.error.invalidUrl": "URL内のディレクトリが無効です。",
"error.chain.unknown": "不明なエラー",
"error.chain.causedBy": "原因:",
@@ -844,8 +844,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "不明",
"error.page.circular": "[循環]",
"error.serverSDK.noServerAvailable": "利用可能なサーバーがありません",
"error.serverSDK.serverNotAvailable": "サーバーが利用できません",
"error.globalSDK.noServerAvailable": "利用可能なサーバーがありません",
"error.globalSDK.serverNotAvailable": "サーバーが利用できません",
"error.childStore.persistedCacheCreateFailed": "永続キャッシュの作成に失敗しました",
"error.childStore.persistedProjectMetadataCreateFailed": "永続プロジェクトメタデータの作成に失敗しました",
"error.childStore.persistedProjectIconCreateFailed": "永続プロジェクトアイコンの作成に失敗しました",
+3 -3
View File
@@ -412,7 +412,7 @@ export const dict = {
"error.page.version": "버전: {{version}}",
"error.dev.rootNotFound":
"루트 요소를 찾을 수 없습니다. index.html에 추가하는 것을 잊으셨나요? 또는 id 속성의 철자가 틀렸을 수 있습니다.",
"error.serverSync.connectFailed": "서버에 연결할 수 없습니다. `{{url}}`에서 서버가 실행 중인가요?",
"error.globalSync.connectFailed": "서버에 연결할 수 없습니다. `{{url}}`에서 서버가 실행 중인가요?",
"directory.error.invalidUrl": "URL에 유효하지 않은 디렉터리가 있습니다.",
"error.chain.unknown": "알 수 없는 오류",
"error.chain.causedBy": "원인:",
@@ -839,8 +839,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "알 수 없음",
"error.page.circular": "[순환]",
"error.serverSDK.noServerAvailable": "사용 가능한 서버 없음",
"error.serverSDK.serverNotAvailable": "서버를 사용할 수 없음",
"error.globalSDK.noServerAvailable": "사용 가능한 서버 없음",
"error.globalSDK.serverNotAvailable": "서버를 사용할 수 없음",
"error.childStore.persistedCacheCreateFailed": "영구 캐시 생성 실패",
"error.childStore.persistedProjectMetadataCreateFailed": "영구 프로젝트 메타데이터 생성 실패",
"error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패",
+3 -3
View File
@@ -463,7 +463,7 @@ export const dict = {
"error.dev.rootNotFound":
"Rotelement ikke funnet. Glemte du å legge det til i index.html? Eller kanskje id-attributten er feilstavet?",
"error.serverSync.connectFailed": "Kunne ikke koble til server. Kjører det en server på `{{url}}`?",
"error.globalSync.connectFailed": "Kunne ikke koble til server. Kjører det en server på `{{url}}`?",
"directory.error.invalidUrl": "Invalid directory in URL.",
"error.chain.unknown": "Ukjent feil",
@@ -927,8 +927,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "ukjent",
"error.page.circular": "[Sirkulær]",
"error.serverSDK.noServerAvailable": "Ingen server tilgjengelig",
"error.serverSDK.serverNotAvailable": "Server ikke tilgjengelig",
"error.globalSDK.noServerAvailable": "Ingen server tilgjengelig",
"error.globalSDK.serverNotAvailable": "Server ikke tilgjengelig",
"error.childStore.persistedCacheCreateFailed": "Kunne ikke opprette vedvarende hurtigbuffer",
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke opprette vedvarende prosjektmetadata",
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke opprette vedvarende prosjektikon",
+3 -3
View File
@@ -414,7 +414,7 @@ export const dict = {
"error.page.version": "Wersja: {{version}}",
"error.dev.rootNotFound":
"Nie znaleziono elementu głównego. Czy zapomniałeś dodać go do swojego index.html? A może atrybut id został błędnie wpisany?",
"error.serverSync.connectFailed": "Nie można połączyć się z serwerem. Czy serwer działa pod adresem `{{url}}`?",
"error.globalSync.connectFailed": "Nie można połączyć się z serwerem. Czy serwer działa pod adresem `{{url}}`?",
"directory.error.invalidUrl": "Nieprawidłowy katalog w URL.",
"error.chain.unknown": "Nieznany błąd",
"error.chain.causedBy": "Spowodowany przez:",
@@ -848,8 +848,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "nieznany",
"error.page.circular": "[Cykliczne]",
"error.serverSDK.noServerAvailable": "Brak dostępnego serwera",
"error.serverSDK.serverNotAvailable": "Serwer niedostępny",
"error.globalSDK.noServerAvailable": "Brak dostępnego serwera",
"error.globalSDK.serverNotAvailable": "Serwer niedostępny",
"error.childStore.persistedCacheCreateFailed": "Nie udało się utworzyć trwałej pamięci podręcznej",
"error.childStore.persistedProjectMetadataCreateFailed": "Nie udało się utworzyć trwałych metadanych projektu",
"error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu",
+3 -3
View File
@@ -461,7 +461,7 @@ export const dict = {
"error.dev.rootNotFound":
"Корневой элемент не найден. Вы забыли добавить его в index.html? Или, может быть, атрибут id был написан неправильно?",
"error.serverSync.connectFailed": "Не удалось подключиться к серверу. Запущен ли сервер по адресу `{{url}}`?",
"error.globalSync.connectFailed": "Не удалось подключиться к серверу. Запущен ли сервер по адресу `{{url}}`?",
"directory.error.invalidUrl": "Недопустимая директория в URL.",
"error.chain.unknown": "Неизвестная ошибка",
@@ -929,8 +929,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "неизвестно",
"error.page.circular": "[Циклично]",
"error.serverSDK.noServerAvailable": "Нет доступного сервера",
"error.serverSDK.serverNotAvailable": "Сервер недоступен",
"error.globalSDK.noServerAvailable": "Нет доступного сервера",
"error.globalSDK.serverNotAvailable": "Сервер недоступен",
"error.childStore.persistedCacheCreateFailed": "Не удалось создать постоянный кэш",
"error.childStore.persistedProjectMetadataCreateFailed": "Не удалось создать постоянные метаданные проекта",
"error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта",
+3 -3
View File
@@ -459,7 +459,7 @@ export const dict = {
"error.dev.rootNotFound": "ไม่พบองค์ประกอบรูท คุณลืมเพิ่มใน index.html หรือบางทีแอตทริบิวต์ id อาจสะกดผิด?",
"error.serverSync.connectFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ มีเซิร์ฟเวอร์ทำงานอยู่ที่ `{{url}}` หรือไม่?",
"error.globalSync.connectFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ มีเซิร์ฟเวอร์ทำงานอยู่ที่ `{{url}}` หรือไม่?",
"directory.error.invalidUrl": "ไดเรกทอรีใน URL ไม่ถูกต้อง",
"error.chain.unknown": "ข้อผิดพลาดที่ไม่รู้จัก",
@@ -916,8 +916,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "ไม่ทราบ",
"error.page.circular": "[วงกลม]",
"error.serverSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์",
"error.serverSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน",
"error.globalSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์",
"error.globalSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน",
"error.childStore.persistedCacheCreateFailed": "ไม่สามารถสร้างแคชถาวร",
"error.childStore.persistedProjectMetadataCreateFailed": "ไม่สามารถสร้างเมตาดาต้าโปรเจกต์ถาวร",
"error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร",
+3 -3
View File
@@ -465,7 +465,7 @@ export const dict = {
"error.dev.rootNotFound":
"Kök eleman bulunamadı. index.html dosyanıza eklemeyi unuttunuz mu? Ya da id özelliği yanlış mı yazıldı?",
"error.serverSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?",
"error.globalSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?",
"directory.error.invalidUrl": "URL'de geçersiz dizin.",
"error.chain.unknown": "Bilinmeyen hata",
@@ -935,8 +935,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "bilinmiyor",
"error.page.circular": "[Döngüsel]",
"error.serverSDK.noServerAvailable": "Sunucu yok",
"error.serverSDK.serverNotAvailable": "Sunucu mevcut değil",
"error.globalSDK.noServerAvailable": "Sunucu yok",
"error.globalSDK.serverNotAvailable": "Sunucu mevcut değil",
"error.childStore.persistedCacheCreateFailed": "Kalıcı önbellek oluşturulamadı",
"error.childStore.persistedProjectMetadataCreateFailed": "Kalıcı proje meta verileri oluşturulamadı",
"error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı",
+3 -3
View File
@@ -483,9 +483,9 @@ export const dict = {
"error.dev.rootNotFound":
"Кореневий елемент не знайдено. Ви забули додати його до index.html? Або, можливо, атрибут id було написано з помилкою?",
"error.serverSync.connectFailed": "Не вдалося підключитися до сервера. Чи працює сервер за адресою `{{url}}`?",
"error.serverSDK.noServerAvailable": "Сервер недоступний",
"error.serverSDK.serverNotAvailable": "Сервер недоступний",
"error.globalSync.connectFailed": "Не вдалося підключитися до сервера. Чи працює сервер за адресою `{{url}}`?",
"error.globalSDK.noServerAvailable": "Сервер недоступний",
"error.globalSDK.serverNotAvailable": "Сервер недоступний",
"error.childStore.persistedCacheCreateFailed": "Не вдалося створити постійний кеш",
"error.childStore.persistedProjectMetadataCreateFailed": "Не вдалося створити постійні метадані проєкту",
"error.childStore.persistedProjectIconCreateFailed": "Не вдалося створити постійну іконку проєкту",
+3 -3
View File
@@ -462,7 +462,7 @@ export const dict = {
"error.page.report.discord": "在 Discord 上",
"error.page.version": "版本:{{version}}",
"error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?",
"error.serverSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
"error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
"directory.error.invalidUrl": "URL 中的目录无效。",
@@ -919,8 +919,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "未知",
"error.page.circular": "[循环]",
"error.serverSDK.noServerAvailable": "无可用服务器",
"error.serverSDK.serverNotAvailable": "服务器不可用",
"error.globalSDK.noServerAvailable": "无可用服务器",
"error.globalSDK.serverNotAvailable": "服务器不可用",
"error.childStore.persistedCacheCreateFailed": "创建持久化缓存失败",
"error.childStore.persistedProjectMetadataCreateFailed": "创建持久化项目元数据失败",
"error.childStore.persistedProjectIconCreateFailed": "创建持久化项目图标失败",
+3 -3
View File
@@ -457,7 +457,7 @@ export const dict = {
"error.dev.rootNotFound": "找不到根元素。你是不是忘了把它新增到 index.html? 或者 id 屬性拼錯了?",
"error.serverSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?",
"error.globalSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?",
"directory.error.invalidUrl": "URL 中的目錄無效。",
"error.chain.unknown": "未知錯誤",
@@ -907,8 +907,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "未知",
"error.page.circular": "[循環]",
"error.serverSDK.noServerAvailable": "無可用的伺服器",
"error.serverSDK.serverNotAvailable": "伺服器無法使用",
"error.globalSDK.noServerAvailable": "無可用的伺服器",
"error.globalSDK.serverNotAvailable": "伺服器無法使用",
"error.childStore.persistedCacheCreateFailed": "建立持續性快取失敗",
"error.childStore.persistedProjectMetadataCreateFailed": "建立持續性專案中繼資料失敗",
"error.childStore.persistedProjectIconCreateFailed": "建立持續性專案圖示失敗",
+5 -5
View File
@@ -19,7 +19,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useServer } from "@/context/server"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
@@ -55,7 +55,7 @@ export default function Home() {
}
function HomeDesign() {
const sync = useServerSync()
const sync = useGlobalSync()
const layout = useLayout()
const platform = usePlatform()
const dialog = useDialog()
@@ -358,10 +358,10 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
}
function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) {
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const notification = useNotification()
const permission = usePermission()
const [sessionStore] = serverSync.child(props.record.session.directory, { bootstrap: false })
const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
@@ -459,7 +459,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
}
function LegacyHome() {
const sync = useServerSync()
const sync = useGlobalSync()
const layout = useLayout()
const platform = usePlatform()
const dialog = useDialog()
+43 -43
View File
@@ -16,7 +16,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useQuery } from "@tanstack/solid-query"
import { useLayout, LocalProject } from "@/context/layout"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64"
@@ -35,7 +35,7 @@ import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, close
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useServerSDK } from "@/context/server-sdk"
import { useGlobalSDK } from "@/context/global-sdk"
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import {
@@ -113,8 +113,8 @@ export default function Layout(props: ParentProps) {
let dialogDead = false
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const layout = useLayout()
const layoutReady = createMemo(() => layout.ready())
const platform = usePlatform()
@@ -136,7 +136,7 @@ export default function Layout(props: ParentProps) {
if (!slug) return { slug, dir: "" }
const dir = decode64(slug)
if (!dir) return { slug, dir: "" }
const store = serverSync.peek(dir, { bootstrap: false })
const store = globalSync.peek(dir, { bootstrap: false })
return {
slug,
store,
@@ -226,7 +226,7 @@ export default function Layout(props: ParentProps) {
active: () => state.hoverProject,
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
onActivate: (directory) => {
serverSync.child(directory)
globalSync.child(directory)
setState("hoverProject", directory)
},
})
@@ -410,7 +410,7 @@ export default function Layout(props: ParentProps) {
alertedAtBySession.delete(sessionKey)
}
const unsub = serverSDK.event.listen((e) => {
const unsub = globalSDK.event.listen((e) => {
if (e.details?.type === "worktree.ready") {
setBusy(e.name, false)
WorktreeState.ready(e.name)
@@ -444,7 +444,7 @@ export default function Layout(props: ParentProps) {
const props = e.details.properties
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
const [store] = serverSync.child(directory, { bootstrap: false })
const [store] = globalSync.child(directory, { bootstrap: false })
const session = store.session.find((s) => s.id === props.sessionID)
const sessionKey = `${directory}:${props.sessionID}`
@@ -507,7 +507,7 @@ export default function Layout(props: ParentProps) {
if (!currentDir() || !currentSession) return
const sessionKey = `${currentDir()}:${currentSession}`
dismissSessionAlert(sessionKey)
const [store] = serverSync.child(currentDir(), { bootstrap: false })
const [store] = globalSync.child(currentDir(), { bootstrap: false })
const childSessions = store.session.filter((s) => s.parentID === currentSession)
for (const child of childSessions) {
dismissSessionAlert(`${currentDir()}:${child.id}`)
@@ -545,11 +545,11 @@ export default function Layout(props: ParentProps) {
const direct = projects.find((p) => pathKey(p.worktree) === key)
if (direct) return direct
const [child] = serverSync.child(directory, { bootstrap: false })
const [child] = globalSync.child(directory, { bootstrap: false })
const id = child.project
if (!id) return
const meta = serverSync.data.project.find((p) => p.id === id)
const meta = globalSync.data.project.find((p) => p.id === id)
const root = meta?.worktree
if (!root) return
@@ -640,7 +640,7 @@ export default function Layout(props: ParentProps) {
const result: Session[] = []
for (const dir of dirs) {
const [dirStore] = serverSync.child(dir, { bootstrap: true })
const [dirStore] = globalSync.child(dir, { bootstrap: true })
const dirSessions = sortedRootSessions(dirStore, now)
result.push(...dirSessions)
}
@@ -692,7 +692,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
route()
serverSDK.url
globalSDK.url
prefetchToken.value += 1
clearSessionPrefetchInflight()
@@ -739,13 +739,13 @@ export default function Layout(props: ParentProps) {
}
async function prefetchMessages(directory: string, sessionID: string, token: number) {
const [store, setStore] = serverSync.child(directory, { bootstrap: false })
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
return runSessionPrefetch({
directory,
sessionID,
task: (rev) =>
retry(() => serverSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
@@ -765,7 +765,7 @@ export default function Layout(props: ParentProps) {
if (stale.length > 0) {
clearSessionPrefetch(directory, stale)
for (const id of stale) {
serverSync.todo.set(id, undefined)
globalSync.todo.set(id, undefined)
}
}
@@ -830,7 +830,7 @@ export default function Layout(props: ParentProps) {
const directory = session.directory
if (!directory) return
const [store] = serverSync.child(directory, { bootstrap: false })
const [store] = globalSync.child(directory, { bootstrap: false })
const cached = untrack(() => {
const info = getSessionPrefetch(directory, session.id)
return shouldSkipSessionPrefetch({
@@ -935,7 +935,7 @@ export default function Layout(props: ParentProps) {
if (!target) return
// warm up child store to prevent flicker
serverSync.child(target.worktree)
globalSync.child(target.worktree)
void openProject(target.worktree)
}
@@ -944,7 +944,7 @@ export default function Layout(props: ParentProps) {
const target = projects[index]
if (!target) return
serverSync.child(target.worktree)
globalSync.child(target.worktree)
void openProject(target.worktree)
}
@@ -973,12 +973,12 @@ export default function Layout(props: ParentProps) {
}
async function archiveSession(session: Session) {
const [store, setStore] = serverSync.child(session.directory)
const [store, setStore] = globalSync.child(session.directory)
const sessions = store.session ?? []
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK.client.session.update({
await globalSDK.client.session.update({
directory: session.directory,
sessionID: session.id,
time: { archived: Date.now() },
@@ -1246,11 +1246,11 @@ export default function Layout(props: ParentProps) {
)
if (known) return known[0]
const [child] = serverSync.child(directory, { bootstrap: false })
const [child] = globalSync.child(directory, { bootstrap: false })
const id = child.project
if (!id) return directory
const meta = serverSync.data.project.find((item) => item.id === id)
const meta = globalSync.data.project.find((item) => item.id === id)
return meta?.worktree ?? directory
}
@@ -1298,7 +1298,7 @@ export default function Layout(props: ParentProps) {
}
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await serverSDK.client.worktree
const listed = await globalSDK.client.worktree
.list({ directory: root })
.then((x) => x.data ?? [])
.catch(() => [] as string[])
@@ -1307,13 +1307,13 @@ export default function Layout(props: ParentProps) {
}
const openSession = async (target: { directory: string; id: string }) => {
if (!canOpen(target.directory)) return false
const [data] = serverSync.child(target.directory, { bootstrap: false })
const [data] = globalSync.child(target.directory, { bootstrap: false })
if (data.session.some((item) => item.id === target.id)) {
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
return true
}
const resolved = await serverSDK.client.session
const resolved = await globalSDK.client.session
.get({ sessionID: target.id })
.then((x) => x.data)
.catch(() => undefined)
@@ -1333,7 +1333,7 @@ export default function Layout(props: ParentProps) {
}
const latest = latestRootSession(
dirs.map((item) => serverSync.child(item, { bootstrap: false })[0]),
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
Date.now(),
)
if (latest && (await openSession(latest))) {
@@ -1344,7 +1344,7 @@ export default function Layout(props: ParentProps) {
await Promise.all(
dirs.map(async (item) => ({
path: { directory: item },
session: await serverSDK.client.session
session: await globalSDK.client.session
.list({ directory: item })
.then((x) => x.data ?? [])
.catch(() => []),
@@ -1405,11 +1405,11 @@ export default function Layout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") {
await serverSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
await globalSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
return
}
serverSync.project.meta(project.worktree, { name })
globalSync.project.meta(project.worktree, { name })
}
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
@@ -1506,7 +1506,7 @@ export default function Layout(props: ParentProps) {
setBusy(directory, true)
const result = await serverSDK.client.worktree
const result = await globalSDK.client.worktree
.remove({ directory: root, worktreeRemoveInput: { directory } })
.then((x) => x.data)
.catch((err) => {
@@ -1525,7 +1525,7 @@ export default function Layout(props: ParentProps) {
clearLastProjectSession(root)
}
serverSync.set(
globalSync.set(
"project",
produce((draft) => {
const project = draft.find((item) => item.worktree === root)
@@ -1564,7 +1564,7 @@ export default function Layout(props: ParentProps) {
})
const dismiss = () => toaster.dismiss(progress)
const sessions: Session[] = await serverSDK.client.session
const sessions: Session[] = await globalSDK.client.session
.list({ directory })
.then((x) => x.data ?? [])
.catch(() => [])
@@ -1575,9 +1575,9 @@ export default function Layout(props: ParentProps) {
platform,
getTerminalServerScope(server.current, server.key),
)
await serverSDK.client.instance.dispose({ directory }).catch(() => undefined)
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
const result = await serverSDK.client.worktree
const result = await globalSDK.client.worktree
.reset({ directory: root, worktreeResetInput: { directory } })
.then((x) => x.data)
.catch((err) => {
@@ -1599,7 +1599,7 @@ export default function Layout(props: ParentProps) {
sessions
.filter((session) => session.time.archived === undefined)
.map((session) =>
serverSDK.client.session
globalSDK.client.session
.update({
sessionID: session.id,
directory: session.directory,
@@ -1640,7 +1640,7 @@ export default function Layout(props: ParentProps) {
})
onMount(() => {
serverSDK.client.file
globalSDK.client.file
.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
@@ -1699,7 +1699,7 @@ export default function Layout(props: ParentProps) {
})
const refresh = async () => {
const sessions = await serverSDK.client.session
const sessions = await globalSDK.client.session
.list({ directory: props.directory })
.then((x) => x.data ?? [])
.catch(() => [])
@@ -1708,7 +1708,7 @@ export default function Layout(props: ParentProps) {
}
onMount(() => {
serverSDK.client.file
globalSDK.client.file
.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
@@ -1842,7 +1842,7 @@ export default function Layout(props: ParentProps) {
const next = new Set(dirs)
for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue
void serverSync.project.loadSessions(directory)
void globalSync.project.loadSessions(directory)
}
loadedSessionDirs.clear()
@@ -1939,7 +1939,7 @@ export default function Layout(props: ParentProps) {
const createWorkspace = async (project: LocalProject) => {
clearSidebarHoverState()
const created = await serverSDK.client.worktree
const created = await globalSDK.client.worktree
.create({ directory: project.worktree })
.then((x) => x.data)
.catch((err) => {
@@ -1973,7 +1973,7 @@ export default function Layout(props: ParentProps) {
return [created.directory, ...next]
})
serverSync.child(created.directory)
globalSync.child(created.directory)
navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
}
@@ -2078,7 +2078,7 @@ export default function Layout(props: ParentProps) {
if (!item) return false
return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
})
const homedir = createMemo(() => serverSync.data.path.home)
const homedir = createMemo(() => globalSync.data.path.home)
return (
<div
@@ -7,7 +7,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path"
import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
@@ -23,7 +23,7 @@ export const ProjectIcon = (props: {
notify?: boolean
working?: boolean
}): JSX.Element => {
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const notification = useNotification()
const permission = usePermission()
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
@@ -33,7 +33,7 @@ export const ProjectIcon = (props: {
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
const hasPermissions = createMemo(() =>
dirs().some((directory) => {
const [store] = serverSync.child(directory, { bootstrap: false })
const [store] = globalSync.child(directory, { bootstrap: false })
return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory))
}),
)
@@ -146,10 +146,10 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const language = useLanguage()
const notification = useNotification()
const permission = usePermission()
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
const [sessionStore] = serverSync.child(props.session.directory)
const [sessionStore] = globalSync.child(props.session.directory)
const hasPermissions = createMemo(() => {
return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => {
return !permission.autoResponds(item, props.session.directory)
@@ -7,7 +7,7 @@ import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { createSortable } from "@thisbeyond/solid-dnd"
import { useLayout, type LocalProject } from "@/context/layout"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
@@ -274,7 +274,7 @@ export const SortableProject = (props: {
ctx: ProjectSidebarContext
sortNow: Accessor<number>
}): JSX.Element => {
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const language = useLanguage()
const sortable = createSortable(props.project.worktree)
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
@@ -294,23 +294,23 @@ export const SortableProject = (props: {
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
const label = (directory: string) => {
const [data] = serverSync.child(directory, { bootstrap: false })
const [data] = globalSync.child(directory, { bootstrap: false })
const kind =
directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
return `${kind} : ${name}`
}
const projectStore = createMemo(() => serverSync.child(props.project.worktree, { bootstrap: false })[0])
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
const isWorking = createMemo(() =>
dirs().some((directory) => {
const [store] = serverSync.child(directory, { bootstrap: false })
const [store] = globalSync.child(directory, { bootstrap: false })
return Object.keys(store.session_status).some((id) => store.session_working(id))
}),
)
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
const workspaceSessions = (directory: string) => {
const [data] = serverSync.child(directory, { bootstrap: false })
const [data] = globalSync.child(directory, { bootstrap: false })
return sortedRootSessions(data, props.sortNow())
}
const tile = () => (
@@ -14,7 +14,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { type LocalProject } from "@/context/layout"
import { useServerSync, useQueryOptions } from "@/context/server-sync"
import { useGlobalSync, useQueryOptions } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { pathKey } from "@/utils/path-key"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
@@ -60,7 +60,7 @@ export const WorkspaceDragOverlay = (props: {
activeWorkspace: Accessor<string | undefined>
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
}): JSX.Element => {
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const language = useLanguage()
const label = createMemo(() => {
const project = props.sidebarProject()
@@ -68,7 +68,7 @@ export const WorkspaceDragOverlay = (props: {
const directory = props.activeWorkspace()
if (!directory) return
const [workspaceStore] = serverSync.child(directory, { bootstrap: false })
const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
const kind =
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
@@ -299,11 +299,11 @@ export const SortableWorkspace = (props: {
}): JSX.Element => {
const navigate = useNavigate()
const params = useParams()
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const queryOptions = useQueryOptions()
const language = useLanguage()
const sortable = createSortable(props.directory)
const [workspaceStore, setWorkspaceStore] = serverSync.child(props.directory, { bootstrap: false })
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
const [menu, setMenu] = createStore({
open: false,
pendingRename: false,
@@ -328,7 +328,7 @@ export const SortableWorkspace = (props: {
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => {
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
await serverSync.project.loadSessions(props.directory)
await globalSync.project.loadSessions(props.directory)
}
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
@@ -357,7 +357,7 @@ export const SortableWorkspace = (props: {
createEffect(() => {
if (!boot()) return
serverSync.child(props.directory, { bootstrap: true })
globalSync.child(props.directory, { bootstrap: true })
})
return (
@@ -446,11 +446,11 @@ export const LocalWorkspace = (props: {
sortNow: Accessor<number>
mobile?: boolean
}): JSX.Element => {
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const queryOptions = useQueryOptions()
const language = useLanguage()
const workspace = createMemo(() => {
const [store, setStore] = serverSync.child(props.project.worktree)
const [store, setStore] = globalSync.child(props.project.worktree)
return { store, setStore }
})
const slug = createMemo(() => base64Encode(props.project.worktree))
@@ -461,7 +461,7 @@ export const LocalWorkspace = (props: {
const loading = () => fetching() > 0 && count() === 0
const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await serverSync.project.loadSessions(props.project.worktree)
await globalSync.project.loadSessions(props.project.worktree)
}
return (
+8 -8
View File
@@ -34,7 +34,7 @@ import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
@@ -182,7 +182,7 @@ function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
}
export default function Page() {
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const layout = useLayout()
const local = useLocal()
const file = useFile()
@@ -560,11 +560,11 @@ export default function Page() {
}
function upsert(next: Project) {
const list = serverSync.data.project
const list = globalSync.data.project
sync.set("project", next.id)
const idx = list.findIndex((item) => item.id === next.id)
if (idx >= 0) {
serverSync.set(
globalSync.set(
"project",
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
)
@@ -572,10 +572,10 @@ export default function Page() {
}
const at = list.findIndex((item) => item.id > next.id)
if (at >= 0) {
serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
return
}
serverSync.set("project", [...list, next])
globalSync.set("project", [...list, next])
}
const gitMutation = useMutation(() => ({
@@ -673,7 +673,7 @@ export default function Page() {
todoTimer = undefined
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync.data.todo[id] !== undefined || serverSync.data.session_todo[id] !== undefined)
const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
todoFrame = requestAnimationFrame(() => {
todoFrame = undefined
@@ -1386,7 +1386,7 @@ export default function Page() {
const ok = await sendFollowupDraft({
client: sdk.client,
sync,
serverSync,
globalSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
}).catch((err) => {
@@ -3,7 +3,7 @@ import { createStore } from "solid-js/store"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { showToast } from "@opencode-ai/ui/toast"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk"
@@ -27,7 +27,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const language = useLanguage()
const permission = usePermission()
@@ -50,7 +50,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return serverSync.data.session_todo[id] ?? []
return globalSync.data.session_todo[id] ?? []
})
const done = createMemo(
@@ -111,7 +111,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const clear = () => {
const id = params.id
if (!id) return
serverSync.todo.set(id, [])
globalSync.todo.set(id, [])
sync.set("todo", id, [])
}
@@ -23,6 +23,7 @@ export type TimelineRowMap = {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
lastAssistantPart: boolean
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
@@ -49,6 +50,7 @@ export namespace TimelineRow {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
lastAssistantPart: boolean
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
@@ -149,6 +151,8 @@ export namespace Timeline {
),
]
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
const assistantGroupCount = assistantItems.filter((item) => item.type === "part").length
if (comments.length > 0)
rows.push(
new TimelineRow.CommentStrip({
@@ -191,6 +195,7 @@ export namespace Timeline {
userMessageID: userMessage.id,
group: item.group,
previousAssistantPart: assistantGroupIndex > 0,
lastAssistantPart: assistantGroupIndex === assistantGroupCount - 1,
}),
)
assistantGroupIndex += 1
@@ -1,16 +1,4 @@
import {
createEffect,
createMemo,
createSignal,
For,
Index,
on,
onCleanup,
Show,
mapArray,
type Accessor,
type JSX,
} from "solid-js"
import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, mapArray, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useNavigate } from "@solidjs/router"
@@ -60,7 +48,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { useGlobalSDK } from "@/context/global-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
@@ -257,7 +245,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
return (
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic component={fileComponent} mode="diff" virtualize={false} fileDiff={view.fileDiff} />
<Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
</div>
)
}
@@ -285,7 +273,7 @@ export function MessageTimeline(props: {
let touchGesture: number | undefined
const navigate = useNavigate()
const serverSDK = useServerSDK()
const globalSDK = useGlobalSDK()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
@@ -427,7 +415,8 @@ export function MessageTimeline(props: {
if (rows.length === 0) return rows
return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
})
const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys })
const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)))
const timelineRowKeys = createMemo(() => [...timelineRowByKey().keys()], [] as string[], { equals: sameKeys })
const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
const messageRowIndex = createMemo(() => {
const result = new Map<string, number>()
@@ -438,14 +427,6 @@ export function MessageTimeline(props: {
})
return result
})
const lastAssistantGroupKey = createMemo(() => {
const result = new Map<string, string>()
timelineRows().forEach((row) => {
if (row._tag !== "AssistantPart") return
result.set(row.userMessageID, row.group.key)
})
return result
})
const keepMounted = createMemo(() => {
const id = activeMessageID()
if (!id) return
@@ -559,7 +540,6 @@ export function MessageTimeline(props: {
const [bar, setBar] = createStore({
ms: pace(640),
})
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
@@ -580,11 +560,6 @@ export function MessageTimeline(props: {
const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
const measureTimeline = () => {
virtualizer?.measure()
anchorMeasuredBottom()
}
function anchorMeasuredBottom() {
if (!listRoot) return false
if (!measuredBottomAnchored) return false
@@ -729,14 +704,14 @@ export function MessageTimeline(props: {
}
const shareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to unshare session", err)
},
@@ -1005,42 +980,29 @@ export function MessageTimeline(props: {
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
if (row().group.type === "context") {
const renderAssistantPartGroup = (row: TimelineRowMap["AssistantPart"]) => {
if (row.group.type === "context") {
const parts = createMemo(() => {
const group = row().group
const group = row.group
if (group.type !== "context") return emptyTools
return group.refs
.map((ref) => getMsgPart(ref.messageID, ref.partID))
.filter((part): part is ToolPart => part?.type === "tool")
})
return (
<ContextToolGroup
parts={parts()}
busy={
workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
}
onSizeChange={measureTimeline}
/>
)
return <ContextToolGroup parts={parts()} busy={workingTurn(row.userMessageID) && row.lastAssistantPart} />
}
const message = createMemo(() => {
const group = row().group
const group = row.group
if (group.type !== "part") return
return messageByID().get(group.ref.messageID)
})
const part = createMemo(() => {
const group = row().group
const group = row.group
if (group.type !== "part") return
return getMsgPart(group.ref.messageID, group.ref.partID)
})
const defaultOpen = createMemo(() => {
const item = part()
if (!item) return
return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())
})
return (
<Show when={message()}>
@@ -1050,13 +1012,14 @@ export function MessageTimeline(props: {
<MessagePart
part={part()}
message={message()}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)}
defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)}
showAssistantCopyPartID={assistantCopyPartID(row.userMessageID)}
turnDurationMs={turnDurationMs(row.userMessageID)}
defaultOpen={partDefaultOpen(
part(),
settings.general.shellToolPartsExpanded(),
settings.general.editToolPartsExpanded(),
)}
deferToolContent={false}
virtualizeDiff={false}
/>
)}
</Show>
@@ -1065,25 +1028,25 @@ export function MessageTimeline(props: {
)
}
function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) {
function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) {
const anchor = () => {
const row = input.row()
const row = input.row
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
}
const previousUserMessage = () => {
const row = input.row()
const row = input.row
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
}
const previousAssistantPart = () => {
const row = input.row()
const row = input.row
return row._tag === "AssistantPart" && row.previousAssistantPart
}
return (
<div
id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
data-message-id={input.row().userMessageID}
data-timeline-row={input.row()._tag}
id={anchor() ? props.anchor(input.row.userMessageID) : undefined}
data-message-id={input.row.userMessageID}
data-timeline-row={input.row._tag}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
@@ -1099,15 +1062,14 @@ export function MessageTimeline(props: {
)
}
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
switch (row()._tag) {
const renderTimelineRow = (row: TimelineRow.TimelineRow) => {
switch (row._tag) {
case "CommentStrip": {
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
const comments = createMemo(() =>
getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
getMsgParts(row.userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
)
return (
<TimelineRowFrame row={commentStripRow}>
<TimelineRowFrame row={row}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
@@ -1140,22 +1102,17 @@ export function MessageTimeline(props: {
)
}
case "UserMessage": {
const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
const message = createMemo(() => {
const m = messageByID().get(userMessageRow().userMessageID)
const m = messageByID().get(row.userMessageID)
if (m?.role === "user") return m
})
return (
<TimelineRowFrame row={userMessageRow}>
<TimelineRowFrame row={row}>
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-content" aria-live="off">
<Message
message={message()}
parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions}
/>
<Message message={message()} parts={getMsgParts(row.userMessageID)} actions={props.actions} />
</div>
</div>
)}
@@ -1164,14 +1121,13 @@ export function MessageTimeline(props: {
)
}
case "TurnDivider": {
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
return (
<TimelineRowFrame row={turnDividerRow}>
<TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-compaction">
<MessageDivider
label={language.t(
turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
row.label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
)}
/>
</div>
@@ -1180,27 +1136,22 @@ export function MessageTimeline(props: {
)
}
case "AssistantPart": {
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
return (
<TimelineRowFrame row={assistantPartRow}>
<TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div
data-slot="session-turn-assistant-content"
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
>
{renderAssistantPartGroup(assistantPartRow)}
<div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(row.userMessageID)}>
{renderAssistantPartGroup(row)}
</div>
</div>
</TimelineRowFrame>
)
}
case "Thinking": {
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
return (
<TimelineRowFrame row={thinkingRow}>
<TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineThinkingRow
reasoningHeading={thinkingRow().reasoningHeading}
reasoningHeading={row.reasoningHeading}
showReasoningSummaries={settings.general.showReasoningSummaries()}
/>
</div>
@@ -1208,32 +1159,29 @@ export function MessageTimeline(props: {
)
}
case "Retry": {
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
return (
<TimelineRowFrame row={retryRow}>
<TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
<SessionRetry status={sessionStatus()} show={activeMessageID() === row.userMessageID} />
</div>
</TimelineRowFrame>
)
}
case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
return (
<TimelineRowFrame row={diffSummaryRow}>
<TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
<TimelineDiffSummaryRow diffs={row.diffs} />
</div>
</TimelineRowFrame>
)
}
case "Error": {
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
return (
<TimelineRowFrame row={errorRow}>
<TimelineRowFrame row={row}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<Card variant="error" class="error-card">
{errorRow().text}
{row.text}
</Card>
</div>
</TimelineRowFrame>
@@ -1244,8 +1192,12 @@ export function MessageTimeline(props: {
}
}
function TimelineRowView(props: { row: TimelineRow.TimelineRow }) {
return renderTimelineRow(() => props.row)
function TimelineRowView(props: { rowKey: string }) {
return (
<Show when={timelineRowByKey().get(props.rowKey)} keyed>
{(item) => renderTimelineRow(item)}
</Show>
)
}
return (
@@ -1577,7 +1529,7 @@ export function MessageTimeline(props: {
<Show when={scrollRoot()}>
{(root) => (
<Virtualizer
data={timelineRows()}
data={timelineRowKeys()}
cache={virtualCache()}
itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
scrollRef={root()}
@@ -1597,7 +1549,7 @@ export function MessageTimeline(props: {
scheduleContentRoot(root())
}}
>
{(row) => <TimelineRowView row={row} />}
{(key) => <TimelineRowView rowKey={key} />}
</Virtualizer>
)}
</Show>
-26
View File
@@ -1,26 +0,0 @@
import { onCleanup } from "solid-js"
export function createRefCountMap<T>(create: (key: string) => T) {
const items = new Map<string, T>()
const refCounts = new Map<string, number>()
return (key: string) => {
onCleanup(() => {
refCounts.set(key, (refCounts.get(key) ?? 0) - 1)
if (refCounts.get(key) === 0) {
items.delete(key)
refCounts.delete(key)
}
})
const cached = items.get(key)
if (cached) {
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
return cached
}
const item = create(key)
items.set(key, item)
refCounts.set(key, 1)
return item
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ export const layer = Layer.effect(
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
const previous = yield* cached(repo.store)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
const id = previous ?? (yield* root(repo))
return {
previous,
+6 -34
View File
@@ -5,16 +5,11 @@ import path from "path"
import { Effect } from "effect"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/core/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Project.defaultLayer)
function remoteID(remote: string) {
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
}
function abs(value: string) {
return AbsolutePath.make(value)
}
@@ -91,7 +86,7 @@ describe("ProjectV2.resolve", () => {
}),
)
it.live("prefers normalized origin over root commit", () =>
it.live("uses root commit when origin exists", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -102,36 +97,13 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(remoteID("github.com/Acme/App"))
expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.vcs?.type).toBe("git")
}),
)
it.live("normalizes ssh and https remotes to the same id", () =>
Effect.gen(function* () {
const ssh = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const https = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
const project = yield* Project.Service
const a = yield* project.resolve(abs(ssh.path))
const b = yield* project.resolve(abs(https.path))
expect(a.id).toBe(remoteID("github.com/owner/repo"))
expect(b.id).toBe(a.id)
}),
)
it.live("ignores file remotes and falls back to root commit", () =>
it.live("uses root commit when local remote exists", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -146,7 +118,7 @@ describe("ProjectV2.resolve", () => {
}),
)
it.live("returns previous cached id from common dir", () =>
it.live("prefers previous cached id over origin", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -159,7 +131,7 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.id).toBe(Project.ID.make("old-id"))
}),
)
@@ -213,7 +185,7 @@ describe("ProjectV2.resolve", () => {
expect(result.directory).toBe(yield* real(worktree))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.id).toBe(Project.ID.make("old-id"))
expect(result.vcs?.type).toBe("git")
}),
)
@@ -509,11 +509,7 @@ export function Prompt(props: PromptProps) {
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
const value = text
const content = await Editor.open({
value,
renderer,
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
})
const content = await Editor.open({ value, renderer })
if (!content) return
input.setText(content)
@@ -6,14 +6,13 @@ export type ThinkingMode = "show" | "hide"
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded
// title block: "**Inspecting PR workflow**\n\n<body>". Treat that first block,
// or a complete title still awaiting its body while streaming, as disclosure
// metadata so the TUI can style its header independently from the markdown body.
export function reasoningSummary(text: string) {
const content = text.trim()
const match = content.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/)
if (!match) return { title: null, body: content }
return { title: match[1].trim(), body: content.slice(match[0].length).trimEnd() }
// title line: "**Inspecting PR workflow**\n\n<body>". GitHub Copilot routes
// through the same shape, and the opencode provider relays it too. Pull the
// title out for a nicer label; return null for providers that don't follow
// this convention so the caller can fall back to a generic "Thinking" string.
export function reasoningTitle(text: string): string | null {
const match = text.trimStart().match(/^\*\*([^*\n]+)\*\*/)
return match ? match[1].trim() : null
}
export function isThinkingMode(value: unknown): value is ThinkingMode {
@@ -5,9 +5,9 @@ import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local"
import { reasoningSummary, useThinkingMode } from "@tui/context/thinking"
import { reasoningTitle, useThinkingMode } from "@tui/context/thinking"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { useBindings } from "../../keymap"
import { Locale } from "@/util/locale"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
@@ -398,7 +398,7 @@ function AssistantReasoning(props: {
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
// in the v2 SDK); we settle on parent-message completion instead.
const isDone = createMemo(() => props.completedAt() !== undefined)
const summary = createMemo(() => reasoningSummary(content()))
const title = createMemo(() => reasoningTitle(content()))
const toggle = () => {
if (!inMinimal()) return
@@ -407,50 +407,41 @@ function AssistantReasoning(props: {
return (
<Show when={content()}>
<box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
done={isDone()}
title={summary().title}
/>
</box>
<Show when={(!inMinimal() || expanded()) && summary().body}>
<box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
<Switch>
<Match when={!inMinimal() || expanded()}>
<box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0} onMouseUp={toggle}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={props.subtleSyntax}
content={summary().body}
content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
conceal={true}
fg={theme.textMuted}
/>
</box>
</Show>
</box>
</Match>
<Match when={isDone()}>
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<CollapsedReasoningText title={title()} />
</box>
</Match>
<Match when={true}>
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
</box>
</Match>
</Switch>
</Show>
)
}
function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: boolean; title: string | null }) {
function CollapsedReasoningText(props: { title: string | null }) {
const { theme } = useTheme()
const fg = () =>
props.open
? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
: theme.warning
return (
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>{props.done ? "Thought" : "Thinking"}</span>
<Show when={props.title}>
<span>: </span>
<span>{props.title}</span>
</Show>
<text fg={theme.warning} wrapMode="none">
<span style={{ fg: theme.warning, italic: true }}>{props.title ? "+ Thought: " + props.title : "+ Thought"}</span>
</text>
)
}
@@ -81,7 +81,7 @@ import * as Model from "../../util/model"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
@@ -965,11 +965,7 @@ export function Session() {
if (options.openWithoutSaving) {
// Just open in editor without saving
await Editor.open({
value: transcript,
renderer,
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
})
await Editor.open({ value: transcript, renderer })
} else {
const exportDir = process.cwd()
const filename = options.filename.trim()
@@ -978,11 +974,7 @@ export function Session() {
await Filesystem.write(filepath, transcript)
// Open with EDITOR if available
const result = await Editor.open({
value: transcript,
renderer,
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
})
const result = await Editor.open({ value: transcript, renderer })
if (result !== undefined) {
await Filesystem.write(filepath, result)
}
@@ -1523,8 +1515,12 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
const end = props.part.time.end
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
})
const summary = createMemo(() => reasoningSummary(content()))
const syntax = createMemo(() => generateSubtleSyntax(theme))
// OpenAI / Copilot / opencode-via-OpenAI emit `**Title**\n\n<body>` summary
// blocks. Surface the title both while streaming and after settling so the
// collapsed line carries real signal, not just a duration.
const title = createMemo(() => reasoningTitle(content()))
// Keep markdown emphasis for the existing thinking color/concealment, but render it without italics.
const syntax = createMemo(() => generateSubtleSyntax(theme, { "markup.italic": { italic: false } }))
const toggle = () => {
if (!inMinimal()) return
@@ -1533,65 +1529,47 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
return (
<Show when={content()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
done={isDone()}
title={summary().title}
duration={isDone() ? Locale.duration(duration()) : undefined}
/>
</box>
<Show when={(!inMinimal() || expanded()) && summary().body}>
<box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
<Switch>
<Match when={!inMinimal() || expanded()}>
{/* Full markdown block: `show` mode, or `hide` after the user opens it. */}
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexDirection="column" onMouseUp={toggle}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={summary().body}
// `_Thinking:_`/`_Thought:_` still drives markdown emphasis color and conceals the underscores;
// the syntax override above removes only the italic attribute from that emphasis token.
content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
</Show>
</box>
</Match>
<Match when={isDone()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<CollapsedReasoningText title={title()} duration={duration()} />
</box>
</Match>
<Match when={true}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
</box>
</Match>
</Switch>
</Show>
)
}
function ReasoningHeader(props: {
toggleable: boolean
open: boolean
done: boolean
title: string | null
duration?: string
}) {
function CollapsedReasoningText(props: { title: string | null; duration: number }) {
const { theme } = useTheme()
const fg = () =>
props.open
? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
: theme.warning
const duration = () => Locale.duration(props.duration)
return (
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>{props.done ? "Thought" : "Thinking"}</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
<text fg={theme.warning} wrapMode="none">
<span style={{ fg: theme.warning }}>
{props.title ? "+ Thought: " + props.title + " · " + duration() : "+ Thought: " + duration()}
</span>
</text>
)
}
@@ -6,7 +6,7 @@ import { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
export async function open(opts: { value: string; renderer: CliRenderer; cwd?: string }): Promise<string | undefined> {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
const editor = process.env["VISUAL"] || process.env["EDITOR"]
if (!editor) return
@@ -19,7 +19,6 @@ export async function open(opts: { value: string; renderer: CliRenderer; cwd?: s
try {
const parts = editor.split(" ")
const proc = Process.spawn([...parts, filepath], {
cwd: opts.cwd,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
+30 -85
View File
@@ -1,4 +1,4 @@
import { Cache, Context, Deferred, Duration, Effect, Layer, Schema } from "effect"
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
@@ -75,19 +75,6 @@ export const Reply = Schema.Struct({
}).annotate({ identifier: "QuestionReply" })
export type Reply = Schema.Schema.Type<typeof Reply>
export const Result = Schema.Union([
Schema.Struct({
status: Schema.Literal("answered"),
answers: Schema.Array(Answer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
}),
Schema.Struct({
status: Schema.Literal("rejected"),
}),
]).annotate({ discriminator: "status", identifier: "QuestionResult" })
export type Result = Schema.Schema.Type<typeof Result>
const Replied = Schema.Struct({
sessionID: SessionID,
requestID: QuestionID,
@@ -117,28 +104,21 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Que
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<Result>
deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
}
interface State {
pending: Map<QuestionID, PendingEntry>
completed: Cache.Cache<QuestionID, Result, NotFoundError>
}
// Service
export interface Interface {
readonly create: (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
}) => Effect.Effect<Request>
readonly ask: (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
}) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
readonly wait: (requestID: QuestionID) => Effect.Effect<Result, NotFoundError>
readonly reply: (input: {
requestID: QuestionID
answers: ReadonlyArray<Answer>
@@ -157,17 +137,12 @@ export const layer = Layer.effect(
Effect.fn("Question.state")(function* () {
const state = {
pending: new Map<QuestionID, PendingEntry>(),
completed: yield* Cache.make<QuestionID, Result, NotFoundError>({
capacity: 10_000,
timeToLive: Duration.minutes(30),
lookup: (requestID) => Effect.fail(new NotFoundError({ requestID })),
}),
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.succeed(item.deferred, { status: "rejected" })
yield* Deferred.fail(item.deferred, new RejectedError())
}
state.pending.clear()
}),
@@ -177,7 +152,7 @@ export const layer = Layer.effect(
}),
)
const create = Effect.fn("Question.create")(function* (input: {
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
@@ -186,7 +161,7 @@ export const layer = Layer.effect(
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<Result>()
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
@@ -195,79 +170,49 @@ export const layer = Layer.effect(
}
pending.set(id, { info, deferred })
yield* bus.publish(Event.Asked, info)
return info
})
const wait = Effect.fn("Question.wait")(function* (requestID: QuestionID) {
const info = yield* InstanceState.get(state)
const pending = info.pending.get(requestID)
if (pending) return yield* Deferred.await(pending.deferred)
return yield* Cache.get(info.completed, requestID).pipe(
Effect.tapError(() => Cache.invalidateWhen(info.completed, requestID, () => true)),
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const settle = Effect.fn("Question.settle")(function* (requestID: QuestionID, result: Result) {
const info = yield* InstanceState.get(state)
const existing = info.pending.get(requestID)
if (!existing) return false
yield* Cache.set(info.completed, requestID, result)
info.pending.delete(requestID)
if (result.status === "answered") {
yield* bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: result.answers,
})
} else {
yield* bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
}
yield* Deferred.succeed(existing.deferred, result)
return true
})
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
}) {
const request = yield* create(input)
const result = yield* Effect.ensuring(
wait(request.id).pipe(Effect.orDie),
settle(request.id, { status: "rejected" }).pipe(Effect.asVoid),
)
if (result.status === "answered") return result.answers
return yield* new RejectedError()
})
const reply = Effect.fn("Question.reply")(function* (input: {
requestID: QuestionID
answers: ReadonlyArray<Answer>
}) {
const result: Result = {
status: "answered",
answers: input.answers.map((a) => [...a]),
}
const ok = yield* settle(input.requestID, result)
if (!ok) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return yield* new NotFoundError({ requestID: input.requestID })
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
yield* bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers.map((a) => [...a]),
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
const ok = yield* settle(requestID, { status: "rejected" })
if (!ok) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return yield* new NotFoundError({ requestID })
}
pending.delete(requestID)
log.info("rejected", { requestID })
yield* bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("Question.list")(function* () {
@@ -275,7 +220,7 @@ export const layer = Layer.effect(
return Array.from(pending.values(), (x) => x.info)
})
return Service.of({ create, ask, wait, reply, reject, list })
return Service.of({ ask, reply, reject, list })
}),
)
@@ -1,6 +1,5 @@
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { QuestionNotFoundError } from "../errors"
@@ -16,32 +15,10 @@ const ReplyPayload = Schema.Struct({
}),
})
export const AskPayload = Schema.Struct({
sessionID: SessionID,
questions: Schema.Array(Question.Info).annotate({
description: "Questions to ask",
}),
})
const AskResponse = Schema.Struct({
id: QuestionID,
})
export const QuestionApi = HttpApi.make("question")
.add(
HttpApiGroup.make("question")
.add(
HttpApiEndpoint.post("ask", root, {
query: WorkspaceRoutingQuery,
payload: AskPayload,
success: described(AskResponse, "Created question request"),
}).annotateMerge(
OpenApi.annotations({
identifier: "question.ask",
summary: "Ask questions",
description: "Create a question request and return its id.",
}),
),
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Question.Request), "List of pending questions"),
@@ -52,18 +29,6 @@ export const QuestionApi = HttpApi.make("question")
description: "Get all pending question requests across all sessions.",
}),
),
HttpApiEndpoint.get("wait", `${root}/:requestID/wait`, {
params: { requestID: QuestionID },
query: WorkspaceRoutingQuery,
success: described(Question.Result, "Question result"),
error: [QuestionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "question.wait",
summary: "Wait for question result",
description: "Wait for a question request to be answered or rejected.",
}),
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: QuestionID },
query: WorkspaceRoutingQuery,
@@ -4,7 +4,6 @@ import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { QuestionNotFoundError } from "../errors"
import { AskPayload } from "../groups/question"
export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question", (handlers) =>
Effect.gen(function* () {
@@ -14,24 +13,6 @@ export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question"
return yield* svc.list()
})
const ask = Effect.fn("QuestionHttpApi.ask")(function* (ctx: { payload: typeof AskPayload.Type }) {
const request = yield* svc.create(ctx.payload)
return { id: request.id }
})
const wait = Effect.fn("QuestionHttpApi.wait")(function* (ctx: { params: { requestID: QuestionID } }) {
return yield* svc.wait(ctx.params.requestID).pipe(
Effect.catchTag("Question.NotFoundError", (error) =>
Effect.fail(
new QuestionNotFoundError({
requestID: String(error.requestID),
message: `Question request not found: ${error.requestID}`,
}),
),
),
)
})
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
params: { requestID: QuestionID }
payload: Question.Reply
@@ -68,11 +49,6 @@ export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question"
return true
})
return handlers
.handle("ask", ask)
.handle("list", list)
.handle("wait", wait)
.handle("reply", reply)
.handle("reject", reject)
return handlers.handle("list", list).handle("reply", reply).handle("reject", reject)
}),
)
@@ -1,36 +0,0 @@
import { describe, expect, test } from "bun:test"
import { reasoningSummary } from "../../../src/cli/cmd/tui/context/thinking"
describe("reasoningSummary", () => {
test("extracts a leading summary title and leaves markdown body", () => {
expect(reasoningSummary("**Continuing Quality Review**\n\nDetails.\n\n**Next section**\n\nMore.")).toEqual({
title: "Continuing Quality Review",
body: "Details.\n\n**Next section**\n\nMore.",
})
})
test("extracts a completed title before its streamed body arrives", () => {
expect(reasoningSummary("**Continuing Quality Review**")).toEqual({
title: "Continuing Quality Review",
body: "",
})
})
test("preserves markdown-significant indentation in the extracted body", () => {
expect(reasoningSummary("**Continuing Quality Review**\n\n const value = true\n")).toEqual({
title: "Continuing Quality Review",
body: " const value = true",
})
})
test("does not consume ordinary leading bold content", () => {
expect(reasoningSummary("**Important:** keep this in the body.")).toEqual({
title: null,
body: "**Important:** keep this in the body.",
})
})
test("leaves content without a leading title in its body", () => {
expect(reasoningSummary("Details only.")).toEqual({ title: null, body: "Details only." })
})
})
+5 -81
View File
@@ -7,15 +7,6 @@ import path from "path"
import { tmpdirScoped } from "../fixture/fixture"
import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema"
import { Database } from "@/storage/db"
import { ProjectTable } from "@/project/project.sql"
import { SessionTable } from "@/session/session.sql"
import { PermissionTable } from "@/session/session.sql"
import { WorkspaceTable } from "@/control-plane/workspace.sql"
import { eq } from "drizzle-orm"
import { Hash } from "@opencode-ai/core/util/hash"
import { SessionID } from "@/session/schema"
import { WorkspaceID } from "@/control-plane/schema"
import { Cause, Effect, Exit, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
@@ -40,10 +31,6 @@ function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
})
}
function remoteProjectID(remote: string) {
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
}
/**
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
* matching `failArg` and returns exit code 128, while delegating everything
@@ -167,91 +154,28 @@ describe("Project.fromDirectory", () => {
}),
)
it.live("prefers normalized origin remote over root commit", () =>
it.live("keeps root commit identity when origin exists", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const root = (yield* Effect.promise(() => $`git rev-list --max-parents=0 HEAD`.cwd(tmp).text())).trim()
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
expect(project.id).toBe(ProjectID.make(root))
}),
)
it.live("normalizes equivalent origin URL forms to the same project ID", () =>
Effect.gen(function* () {
const ssh = yield* tmpdirScoped({ git: true })
const https = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh))
const { project: b } = yield* run((svc) => svc.fromDirectory(https))
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
expect(b.id).toBe(a.id)
}),
)
it.live("migrates cached root project data when origin becomes available", () =>
it.live("keeps cached project identity when origin becomes available", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project: rootProject } = yield* projects.fromDirectory(tmp)
const remoteID = remoteProjectID("github.com/acme/app")
const sessionID = crypto.randomUUID() as SessionID
const workspaceID = WorkspaceID.ascending()
yield* Effect.sync(() => {
Database.use((db) => {
db.insert(SessionTable)
.values({
id: sessionID,
project_id: rootProject.id,
slug: sessionID,
directory: tmp,
title: "test",
version: "0.0.0-test",
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
db.insert(PermissionTable)
.values({
project_id: rootProject.id,
data: [{ permission: "edit", pattern: "*", action: "allow" }],
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
db.insert(WorkspaceTable)
.values({
id: workspaceID,
type: "local",
name: "test",
project_id: rootProject.id,
})
.run()
})
})
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).toBe(remoteID)
expect(
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get()),
).toBeUndefined()
expect(
Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())?.project_id,
).toBe(remoteID)
expect(
Database.use((db) => db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get()),
).toBeDefined()
expect(
Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get())
?.project_id,
).toBe(remoteID)
expect(project.id).toBe(rootProject.id)
}),
)
})
@@ -23,20 +23,6 @@ const askEffect = Effect.fn("QuestionTest.ask")(function* (input: {
return yield* question.ask(input)
})
const createEffect = Effect.fn("QuestionTest.create")(function* (input: {
sessionID: SessionID
questions: ReadonlyArray<Question.Info>
tool?: Question.Tool
}) {
const question = yield* Question.Service
return yield* question.create(input)
})
const waitEffect = Effect.fn("QuestionTest.wait")(function* (requestID: QuestionID) {
const question = yield* Question.Service
return yield* question.wait(requestID)
})
const listEffect = Question.Service.use((svc) => svc.list())
const replyEffect = Effect.fn("QuestionTest.reply")(function* (input: {
@@ -197,64 +183,6 @@ it.instance(
{ git: true },
)
it.instance(
"wait - returns answers for pending and completed requests",
() =>
Effect.gen(function* () {
const request = yield* createEffect({
sessionID: SessionID.make("ses_test"),
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
const fiber = yield* waitEffect(request.id).pipe(Effect.forkScoped)
yield* replyEffect({
requestID: request.id,
answers: [["Option 1"]],
})
const result = { status: "answered" as const, answers: [["Option 1"]] }
expect(yield* Fiber.join(fiber)).toEqual(result)
expect(yield* waitEffect(request.id)).toEqual(result)
}),
{ git: true },
)
it.instance(
"wait - returns rejected when pending ask is interrupted",
() =>
Effect.gen(function* () {
const fiber = yield* askEffect({
sessionID: SessionID.make("ses_test"),
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [{ label: "Option 1", description: "First option" }],
},
],
}).pipe(Effect.forkScoped)
const pending = yield* waitForPending(1)
const waiter = yield* waitEffect(pending[0].id).pipe(Effect.forkScoped)
yield* Fiber.interrupt(fiber)
expect(yield* Fiber.join(waiter)).toEqual({ status: "rejected" })
expect(yield* waitEffect(pending[0].id)).toEqual({ status: "rejected" })
}),
{ git: true },
)
it.instance(
"reply - fails for unknown requestID",
() =>
@@ -331,28 +259,6 @@ it.instance(
{ git: true },
)
it.instance(
"wait - returns rejected for completed rejected requests",
() =>
Effect.gen(function* () {
const request = yield* createEffect({
sessionID: SessionID.make("ses_test"),
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [{ label: "Option 1", description: "First option" }],
},
],
})
yield* rejectEffect(request.id)
expect(yield* waitEffect(request.id)).toEqual({ status: "rejected" })
}),
{ git: true },
)
it.instance(
"reject - fails for unknown requestID",
() =>
@@ -235,34 +235,6 @@ const scenarios: Scenario[] = [
}))
.json(404, object, "status"),
http.protected.get("/question", "question.list").json(200, array),
http.protected
.post("/question", "question.ask")
.mutating()
.at((ctx) => ({
path: "/question",
headers: ctx.headers(),
body: {
sessionID: "ses_httpapi_question",
questions: [
{
question: "Proceed?",
header: "Proceed",
options: [{ label: "Yes", description: "Continue" }],
},
],
},
}))
.json(200, (body) => {
object(body)
check(typeof body.id === "string" && body.id.startsWith("que_"), "question ask should return request id")
}),
http.protected
.get("/question/{requestID}/wait", "question.wait.missing")
.at((ctx) => ({
path: route("/question/{requestID}/wait", { requestID: "que_httpapi_wait" }),
headers: ctx.headers(),
}))
.json(404, object, "status"),
http.protected
.post("/question/{requestID}/reply", "question.reply.invalid")
.at((ctx) => ({
-76
View File
@@ -151,17 +151,12 @@ import type {
PtyUpdateErrors,
PtyUpdateResponses,
QuestionAnswer,
QuestionAskErrors,
QuestionAskResponses,
QuestionInfo,
QuestionListErrors,
QuestionListResponses,
QuestionRejectErrors,
QuestionRejectResponses,
QuestionReplyErrors,
QuestionReplyResponses,
QuestionWaitErrors,
QuestionWaitResponses,
SessionAbortErrors,
SessionAbortResponses,
SessionChildrenErrors,
@@ -2692,77 +2687,6 @@ export class Question extends HeyApiClient {
})
}
/**
* Ask questions
*
* Create a question request and return its id.
*/
public ask<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
sessionID?: string
questions?: Array<QuestionInfo>
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "sessionID" },
{ in: "body", key: "questions" },
],
},
],
)
return (options?.client ?? this.client).post<QuestionAskResponses, QuestionAskErrors, ThrowOnError>({
url: "/question",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Wait for question result
*
* Wait for a question request to be answered or rejected.
*/
public wait<ThrowOnError extends boolean = false>(
parameters: {
requestID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "requestID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<QuestionWaitResponses, QuestionWaitErrors, ThrowOnError>({
url: "/question/{requestID}/wait",
...options,
...params,
})
}
/**
* Reply to question request
*
-82
View File
@@ -1717,18 +1717,6 @@ export type PtyForbiddenError = {
message: string
}
export type QuestionResult =
| {
status: "answered"
/**
* User answers in order of questions (each answer is an array of selected labels)
*/
answers: Array<QuestionAnswer>
}
| {
status: "rejected"
}
export type QuestionNotFoundError = {
_tag: "QuestionNotFoundError"
requestID: string
@@ -5755,76 +5743,6 @@ export type QuestionListResponses = {
export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses]
export type QuestionAskData = {
body?: {
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/question"
}
export type QuestionAskErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type QuestionAskError = QuestionAskErrors[keyof QuestionAskErrors]
export type QuestionAskResponses = {
/**
* Created question request
*/
200: {
id: string
}
}
export type QuestionAskResponse = QuestionAskResponses[keyof QuestionAskResponses]
export type QuestionWaitData = {
body?: never
path: {
requestID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/question/{requestID}/wait"
}
export type QuestionWaitErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* QuestionNotFoundError
*/
404: QuestionNotFoundError
}
export type QuestionWaitError = QuestionWaitErrors[keyof QuestionWaitErrors]
export type QuestionWaitResponses = {
/**
* Question result
*/
200: QuestionResult
}
export type QuestionWaitResponse = QuestionWaitResponses[keyof QuestionWaitResponses]
export type QuestionReplyData = {
body?: {
/**
@@ -24,7 +24,7 @@ const [store, setStore] = createStore({
config: { permission: {} },
})
export function useServerSync() {
export function useGlobalSync() {
return {
data: {
provider,
@@ -36,6 +36,7 @@ describe("apply patch file", () => {
])[0]
expect(file).toBeDefined()
expect(file?.view.patch).toContain("@@ -1,1 +1,1 @@")
expect(text(file!.view, "deletions")).toBe("one\n")
expect(text(file!.view, "additions")).toBe("two\n")
})
+3 -12
View File
@@ -29,8 +29,6 @@ export interface BasicToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
forceOpen?: boolean
defer?: boolean
locked?: boolean
@@ -85,7 +83,7 @@ export function BasicTool(props: BasicToolProps) {
open: props.defaultOpen ?? false,
ready: !props.defer && (props.defaultOpen ?? false),
})
const open = () => props.open ?? state.open
const open = () => state.open
const ready = () => state.ready
const pending = () => props.status === "pending" || props.status === "running"
const hasChildren = () => (props.defer ? "children" in props : props.children)
@@ -112,15 +110,8 @@ export function BasicTool(props: BasicToolProps) {
if (props.defer && open()) scheduleReady(true)
})
const setOpen = (value: boolean) => {
if (props.open === undefined) setState("open", value)
props.onOpenChange?.(value)
}
createEffect(() => {
if (!props.forceOpen) return
if (open()) return
setOpen(true)
if (props.forceOpen) setState("open", true)
})
createEffect(
@@ -175,7 +166,7 @@ export function BasicTool(props: BasicToolProps) {
const handleOpenChange = (value: boolean) => {
if (pending()) return
if (props.locked && !value) return
setOpen(value)
setState("open", value)
}
const trigger = () => (
+13 -76
View File
@@ -1,7 +1,5 @@
import { sampledChecksum } from "@opencode-ai/core/util/encode"
import {
areFilesEqual,
areOptionsEqual,
DEFAULT_VIRTUAL_FILE_METRICS,
type DiffLineAnnotation,
type FileContents,
@@ -90,7 +88,6 @@ type DiffBaseProps<T> = FileDiffOptions<T> &
mode: "diff"
annotations?: DiffLineAnnotation<T>[]
preloadedDiff?: DiffPreload<T>
virtualize?: boolean
}
type DiffPairProps<T> = DiffBaseProps<T> & {
@@ -126,7 +123,7 @@ const sharedKeys = [
] as const
const textKeys = ["file", ...sharedKeys] as const
const diffKeys = ["fileDiff", "before", "after", "virtualize", ...sharedKeys] as const
const diffKeys = ["fileDiff", "before", "after", ...sharedKeys] as const
// ---------------------------------------------------------------------------
// Shared viewer hook
@@ -485,24 +482,17 @@ function notifyRendered(opts: {
function renderViewer<I extends RenderTarget>(opts: {
viewer: Viewer
current: I | undefined
reset?: boolean
create: () => I
update?: (value: I) => void
assign: (value: I) => void
draw: (value: I) => void
onReady: () => void
}) {
clearReadyWatcher(opts.viewer.ready)
const reset = opts.reset === true && opts.current !== undefined
if (reset) opts.current?.cleanUp()
const next = reset || !opts.current ? opts.create() : opts.current
if (reset || !opts.current) {
opts.viewer.container.innerHTML = ""
opts.assign(next)
} else {
opts.update?.(next)
}
opts.current?.cleanUp()
const next = opts.create()
opts.assign(next)
opts.viewer.container.innerHTML = ""
opts.draw(next)
applyViewerScheme(opts.viewer.getHost())
@@ -576,7 +566,7 @@ function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enab
}
}
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const release = () => {
@@ -586,10 +576,6 @@ function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, ena
return {
get: () => {
if (!enabled()) {
release()
return
}
if (shared) return shared.virtualizer
const container = host()
@@ -872,14 +858,15 @@ function TextViewer<T>(props: TextFileProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = getWorkerPool("unified")
const isVirtual = virtual()
const virtualizer = virtuals.get()
renderViewer({
viewer,
current: instance,
reset: instance !== undefined,
create: () =>
virtualizer
isVirtual && virtualizer
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
: new PierreFile<T>(opts, workerPool),
assign: (value) => {
@@ -920,12 +907,6 @@ function TextViewer<T>(props: TextFileProps<T>) {
function DiffViewer<T>(props: DiffFileProps<T>) {
let instance: FileDiff<T> | undefined
let instanceVirtualizer: Virtualizer | undefined
let instanceWorkerPool: ReturnType<typeof getWorkerPool>
let instanceVirtualHunkSeparators: FileDiffOptions<T>["hunkSeparators"] | undefined
let instanceFileDiff: FileDiffMetadata | undefined
let instanceBefore: FileContents | undefined
let instanceAfter: FileContents | undefined
let dragSide: DiffSelectionSide | undefined
let dragEndSide: DiffSelectionSide | undefined
let viewer!: Viewer
@@ -1010,10 +991,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
adapter,
)
const virtuals = createSharedVirtualStrategy(
() => viewer.container,
() => local.virtualize !== false,
)
const virtuals = createSharedVirtualStrategy(() => viewer.container)
const large = createMemo(() => {
if (local.fileDiff) {
@@ -1089,66 +1067,31 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
return sampledChecksum(contents)
}
const before = local.before
? { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) }
: undefined
const after = local.after
? { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) }
: undefined
const targetChanged =
local.fileDiff !== undefined
? instanceFileDiff !== local.fileDiff
: instanceFileDiff !== undefined ||
before === undefined ||
after === undefined ||
instanceBefore === undefined ||
instanceAfter === undefined ||
!areFilesEqual(instanceBefore, before) ||
!areFilesEqual(instanceAfter, after)
// Pierre beta virtualized instances retain their first diff target and resolve separator metrics at construction.
// Plain timeline diffs can retain the instance as content streams; virtualized viewers reset only when that is unsafe.
const reset =
instance !== undefined &&
(instanceVirtualizer !== virtualizer ||
instanceWorkerPool !== workerPool ||
(virtualizer !== undefined && (instanceVirtualHunkSeparators !== opts.hunkSeparators || targetChanged)))
const forceRender = !reset && instance !== undefined && !areOptionsEqual(instance.options, opts)
renderViewer({
viewer,
current: instance,
reset,
create: () =>
virtualizer
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
: new FileDiff<T>(opts, workerPool),
update: (value) => value.setOptions(opts),
assign: (value) => {
instance = value
instanceVirtualizer = virtualizer
instanceWorkerPool = workerPool
instanceVirtualHunkSeparators = virtualizer ? opts.hunkSeparators : undefined
instanceFileDiff = local.fileDiff
instanceBefore = before
instanceAfter = after
},
draw: (value) => {
if (local.fileDiff) {
value.render({
fileDiff: local.fileDiff,
forceRender,
lineAnnotations: [],
containerWrapper: viewer.container,
})
return
}
if (!before || !after) return
if (!local.before || !local.after) return
value.render({
oldFile: before,
newFile: after,
forceRender,
oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },
lineAnnotations: [],
containerWrapper: viewer.container,
})
@@ -1168,12 +1111,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
onCleanup(() => {
instance?.cleanUp()
instance = undefined
instanceVirtualizer = undefined
instanceWorkerPool = undefined
instanceVirtualHunkSeparators = undefined
instanceFileDiff = undefined
instanceBefore = undefined
instanceAfter = undefined
virtuals.cleanup()
dragSide = undefined
dragEndSide = undefined
+11 -51
View File
@@ -175,10 +175,7 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
toolOpen?: boolean
onToolOpenChange?: (open: boolean) => void
deferToolContent?: boolean
virtualizeDiff?: boolean
showAssistantCopyPartID?: string | null
turnDurationMs?: number
}
@@ -293,7 +290,7 @@ function getDirectory(path: string | undefined) {
}
import type { IconProps } from "./icon"
import { normalize, resolveFileDiff } from "./session-diff"
import { normalize } from "./session-diff"
export type ToolInfo = {
icon: IconProps["name"]
@@ -933,7 +930,7 @@ export function AssistantMessageDisplay(props: {
)
}
export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void }) {
export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
const pending = createMemo(
@@ -941,15 +938,11 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS
!!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
)
const summary = createMemo(() => contextToolSummary(props.parts))
const handleOpenChange = (value: boolean) => {
setOpen(value)
props.onSizeChange?.()
}
return (
<Collapsible
open={open()}
onOpenChange={handleOpenChange}
onOpenChange={setOpen}
variant="ghost"
class="tool-collapsible"
data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}
@@ -1268,10 +1261,7 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
toolOpen={props.toolOpen}
onToolOpenChange={props.onToolOpenChange}
deferToolContent={props.deferToolContent}
virtualizeDiff={props.virtualizeDiff}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
/>
@@ -1288,10 +1278,7 @@ export interface ToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
deferContent?: boolean
virtualizeDiff?: boolean
forceOpen?: boolean
locked?: boolean
}
@@ -1389,8 +1376,6 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
})
const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined)
const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open)
return (
<Show when={!hideQuestion()}>
@@ -1414,8 +1399,6 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
error={error()}
title={part().tool === "websearch" ? webSearchProviderLabel(partMetadata().provider) : undefined}
defaultOpen={props.defaultOpen}
open={controlledOpen()}
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
subtitle={taskSubtitle()}
href={taskHref()}
/>
@@ -1434,10 +1417,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
status={part().state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
open={controlledOpen()}
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
deferContent={props.deferToolContent}
virtualizeDiff={props.virtualizeDiff}
/>
</Match>
</Switch>
@@ -1941,29 +1921,15 @@ ToolRegistry.register({
const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
const filename = () => getFilename(props.input.filePath ?? "")
const pending = () => props.status === "pending" || props.status === "running"
const diffSource = createMemo(
() => {
const filediff = props.metadata?.filediff
if (!filediff) return
return {
file: filediff.file || props.input.filePath || "",
patch: typeof filediff.patch === "string" ? filediff.patch : undefined,
before: typeof filediff.before === "string" ? filediff.before : undefined,
after: typeof filediff.after === "string" ? filediff.after : undefined,
}
},
undefined,
{
equals: (a, b) =>
a?.file === b?.file && a?.patch === b?.patch && a?.before === b?.before && a?.after === b?.after,
},
)
const fileCompProps = createMemo(() => {
try {
const source = diffSource()
if (source) {
const fileDiff = resolveFileDiff(source)
if (props.metadata?.filediff) {
const diff = normalize({
...props.metadata?.filediff,
status: "modified",
})
const fileDiff = diff.fileDiff
if (fileDiff) return { fileDiff, hunkSeparators: fileDiff.isPartial ? "simple" : "line-info-basic" }
}
} catch {}
@@ -2021,7 +1987,7 @@ ToolRegistry.register({
}
>
<div data-component="edit-content">
<Dynamic component={fileComponent} mode="diff" virtualize={props.virtualizeDiff} {...fileCompProps()} />
<Dynamic component={fileComponent} mode="diff" {...fileCompProps()} />
</div>
</ToolFileAccordion>
</Show>
@@ -2205,7 +2171,6 @@ ToolRegistry.register({
<Dynamic
component={fileComponent}
mode="diff"
virtualize={props.virtualizeDiff}
fileDiff={file.view.fileDiff}
hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
/>
@@ -2278,12 +2243,7 @@ ToolRegistry.register({
}
>
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
virtualize={props.virtualizeDiff}
fileDiff={single()!.view.fileDiff}
/>
<Dynamic component={fileComponent} mode="diff" fileDiff={single()!.view.fileDiff} />
</div>
</ToolFileAccordion>
</BasicTool>
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { normalize, resolveFileDiff, text } from "./session-diff"
import { normalize, text } from "./session-diff"
describe("session diff", () => {
test("keeps unified patch content", () => {
@@ -13,8 +13,8 @@ describe("session diff", () => {
}
const view = normalize(diff)
expect(view.patch).toBe(diff.patch)
expect(view.fileDiff.name).toBe("a.ts")
expect(view.fileDiff.isPartial).toBe(true)
expect(text(view, "deletions")).toBe("one\ntwo\n")
expect(text(view, "additions")).toBe("one\nthree\n")
})
@@ -34,53 +34,7 @@ describe("session diff", () => {
expect(text(view, "additions")).toBe("one\nthree")
})
test("keeps separated patch hunks partial without complete file contents", () => {
const fileDiff = resolveFileDiff({
file: "project.ts",
patch:
'Index: project.ts\n===================================================================\n--- project.ts\t\n+++ project.ts\t\n@@ -1,3 +1,2 @@\n import { and } from "drizzle-orm"\n-import { sql } from "drizzle-orm"\n import { ProjectTable } from "./project.sql"\n@@ -346,3 +345,3 @@\n import { Database } from "@/storage/db"\n-import { ProjectTable } from "./project.sql"\n+import { ProjectTable } from "../project/project.sql"\n import { SessionTable } from "../session/session.sql"\n',
})
expect(fileDiff.isPartial).toBe(true)
expect(fileDiff.hunks).toHaveLength(2)
expect(fileDiff.hunks[1]?.collapsedBefore).toBeGreaterThan(0)
})
test("renders headerless persisted patches", () => {
const view = normalize({
file: "a.ts",
patch: "@@ -1 +1 @@\n-old\n+new\n",
additions: 1,
deletions: 1,
status: "modified" as const,
})
expect(view.fileDiff.name).toBe("a.ts")
expect(view.fileDiff.isPartial).toBe(true)
expect(text(view, "deletions")).toBe("old\n")
expect(text(view, "additions")).toBe("new\n")
})
test("does not share headerless patch metadata between files", () => {
const patch = "@@ -1 +1 @@\n-old\n+new\n"
expect(resolveFileDiff({ file: "a.ts", patch }).name).toBe("a.ts")
expect(resolveFileDiff({ file: "b.ts", patch }).name).toBe("b.ts")
})
test("keeps capped header-only patches partial", () => {
const fileDiff = resolveFileDiff({
file: "a.ts",
patch:
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n",
})
expect(fileDiff.name).toBe("a.ts")
expect(fileDiff.isPartial).toBe(true)
expect(fileDiff.hunks).toEqual([])
})
test("keeps full legacy content as a complete diff", () => {
test("converts legacy content into a patch", () => {
const diff = {
file: "a.ts",
before: "one\n",
@@ -91,7 +45,7 @@ describe("session diff", () => {
}
const view = normalize(diff)
expect(view.fileDiff.isPartial).toBe(false)
expect(view.patch).toContain("@@ -1,1 +1,1 @@")
expect(text(view, "deletions")).toBe("one\n")
expect(text(view, "additions")).toBe("two\n")
})
@@ -107,6 +61,7 @@ describe("session diff", () => {
}
const view = normalize(diff)
expect(view.patch).toBe(diff.patch)
expect(text(view, "deletions")).toBe("")
expect(text(view, "additions")).toBe("")
})
+84 -49
View File
@@ -1,5 +1,5 @@
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
import { parsePatch } from "diff"
import { formatPatch, parsePatch, structuredPatch } from "diff"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
type LegacyDiff = {
@@ -14,35 +14,107 @@ type LegacyDiff = {
type SnapshotDiff = SnapshotFileDiff & { file: string }
type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff
export type DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
export type ViewDiff = {
file: string
patch: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
fileDiff: FileDiffMetadata
}
const diffCacheLimit = 16
const patchFileDiffCache = new Map<string, FileDiffMetadata>()
const cache = new Map<string, FileDiffMetadata>()
export function resolveFileDiff(diff: DiffSource) {
if (typeof diff.patch === "string") return fileDiffFromPatch(diff.file, diff.patch)
return fileDiffFromContent(
diff.file,
typeof diff.before === "string" ? diff.before : "",
typeof diff.after === "string" ? diff.after : "",
)
function patch(diff: ReviewDiff) {
if (typeof diff.patch === "string") {
try {
const [patch] = parsePatch(diff.patch)
const beforeLines: Array<{ text: string; newline: boolean }> = []
const afterLines: Array<{ text: string; newline: boolean }> = []
let previous: "-" | "+" | " " | undefined
const patchIsPartial = patch.hunks.every((h) => h.oldStart > 1)
for (const hunk of patch.hunks) {
for (const line of hunk.lines) {
if (line.startsWith("\\")) {
if (previous === "-" || previous === " ") {
const before = beforeLines.at(-1)
if (before) before.newline = false
}
if (previous === "+" || previous === " ") {
const after = afterLines.at(-1)
if (after) after.newline = false
}
continue
}
if (line.startsWith("-")) {
beforeLines.push({ text: line.slice(1), newline: true })
previous = "-"
} else if (line.startsWith("+")) {
afterLines.push({ text: line.slice(1), newline: true })
previous = "+"
} else {
// context line (starts with ' ')
beforeLines.push({ text: line.slice(1), newline: true })
afterLines.push({ text: line.slice(1), newline: true })
previous = " "
}
}
}
return {
before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
patch: diff.patch,
patchIsPartial,
}
} catch {
return { before: "", after: "", patch: diff.patch, patchIsPartial: false }
}
}
return {
before: "before" in diff && typeof diff.before === "string" ? diff.before : "",
after: "after" in diff && typeof diff.after === "string" ? diff.after : "",
patch: formatPatch(
structuredPatch(
diff.file,
diff.file,
"before" in diff && typeof diff.before === "string" ? diff.before : "",
"after" in diff && typeof diff.after === "string" ? diff.after : "",
"",
"",
{ context: Number.MAX_SAFE_INTEGER },
),
),
patchIsPartial: false,
}
}
function file(file: string, patch: string, before: string, after: string, partial = false) {
const hit = cache.get(patch)
if (hit) return hit
let value: FileDiffMetadata | undefined
if (partial) value = parsePatchFiles(patch)[0]?.files[0]
if (value === undefined) value = parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
cache.set(patch, value)
return value
}
export function normalize(diff: ReviewDiff): ViewDiff {
const next = patch(diff)
const fileDiff = file(diff.file, next.patch, next.before, next.after, next.patchIsPartial)
return {
file: diff.file,
patch: next.patch,
additions: diff.additions,
deletions: diff.deletions,
status: diff.status,
fileDiff: resolveFileDiff(diff),
fileDiff,
}
}
@@ -50,40 +122,3 @@ export function text(diff: ViewDiff, side: "deletions" | "additions") {
if (side === "deletions") return diff.fileDiff.deletionLines.join("")
return diff.fileDiff.additionLines.join("")
}
function fileDiffFromPatch(file: string, patch: string) {
const key = `${file}\0${patch}`
const hit = patchFileDiffCache.get(key)
if (hit) {
patchFileDiffCache.delete(key)
patchFileDiffCache.set(key, hit)
return hit
}
const input = patchInput(file, patch)
const value = (input ? parsePatchFiles(input)[0]?.files[0] : undefined) ?? emptyFileDiff(file)
patchFileDiffCache.set(key, value)
while (patchFileDiffCache.size > diffCacheLimit) patchFileDiffCache.delete(patchFileDiffCache.keys().next().value!)
return value
}
function patchInput(file: string, patch: string) {
try {
const parsed = parsePatch(patch)[0]
if (!parsed) return
if (parsed.index || parsed.oldFileName || parsed.newFileName) return patch
if (!parsed.hunks.length) return
return `Index: ${file}\n===================================================================\n--- ${file}\t\n+++ ${file}\t\n${patch}`
} catch {
return
}
}
function fileDiffFromContent(file: string, before: string, after: string) {
if (!before && !after) return emptyFileDiff(file)
return parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
}
function emptyFileDiff(file: string) {
return parseDiffFromFile({ name: file, contents: "" }, { name: file, contents: "" })
}
@@ -2,7 +2,7 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { Todo } from "@opencode-ai/sdk/v2"
import { useServerSync } from "@/context/global-sync"
import { useGlobalSync } from "@/context/global-sync"
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
export default {
@@ -129,7 +129,7 @@ const css = `
export const Playground = {
render: () => {
const global = useServerSync()
const global = useGlobalSync()
const [cfg, setCfg] = createStore({
open: true,
step: 1,
+8 -18
View File
@@ -12,8 +12,6 @@ export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "c
error: string
title?: string
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
subtitle?: string
href?: string
}
@@ -24,22 +22,9 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
open: props.defaultOpen ?? false,
copied: false,
})
const open = () => props.open ?? state.open
const open = () => state.open
const copied = () => state.copied
const [split, rest] = splitProps(props, [
"tool",
"error",
"title",
"defaultOpen",
"open",
"onOpenChange",
"subtitle",
"href",
])
const setOpen = (value: boolean) => {
if (props.open === undefined) setState("open", value)
props.onOpenChange?.(value)
}
const [split, rest] = splitProps(props, ["tool", "error", "title", "defaultOpen", "subtitle", "href"])
const name = createMemo(() => {
if (split.title) return split.title
const map: Record<string, string> = {
@@ -92,7 +77,12 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
return (
<Card {...rest} data-kind="tool-error-card" data-open={open() ? "true" : "false"} variant="error">
<Collapsible class="tool-collapsible" data-open={open() ? "true" : "false"} open={open()} onOpenChange={setOpen}>
<Collapsible
class="tool-collapsible"
data-open={open() ? "true" : "false"}
open={open()}
onOpenChange={(value) => setState("open", value)}
>
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
-93
View File
@@ -1,93 +0,0 @@
diff --git a/lib/solid/Virtualizer.d.ts b/lib/solid/Virtualizer.d.ts
index 144dd7f..819aab9 100644
--- a/lib/solid/Virtualizer.d.ts
+++ b/lib/solid/Virtualizer.d.ts
@@ -38,6 +38,10 @@ export interface VirtualizerHandle {
* @param index index of item
*/
getItemSize(index: number): number;
+ /**
+ * Synchronously measure currently mounted items and update cached item sizes.
+ */
+ measure(): void;
/**
* Scroll to the item specified by index.
* @param index index of item
diff --git a/lib/solid/index.jsx b/lib/solid/index.jsx
index 029201a..3949cd4 100644
--- a/lib/solid/index.jsx
+++ b/lib/solid/index.jsx
@@ -1085,6 +1085,7 @@ const createResizer = (store, isHorizontal) => {
let viewportElement;
const sizeKey = isHorizontal ? "width" : "height";
const mountedIndexes = new WeakMap();
+ const mountedItems = new Map();
const resizeObserver = createResizeObserver((entries) => {
const resizes = [];
for (const { target, contentRect } of entries) {
@@ -1111,12 +1112,27 @@ const createResizer = (store, isHorizontal) => {
},
$observeItem: (el, i) => {
mountedIndexes.set(el, i);
+ mountedItems.set(i, el);
resizeObserver._observe(el);
return () => {
mountedIndexes.delete(el);
+ if (mountedItems.get(i) === el) {
+ mountedItems.delete(i);
+ }
resizeObserver._unobserve(el);
};
},
+ $measureItems: () => {
+ const resizes = [];
+ mountedItems.forEach((el, index) => {
+ if (!el.offsetParent)
+ return;
+ resizes.push([index, el.getBoundingClientRect()[sizeKey]]);
+ });
+ if (resizes.length) {
+ store.$update(ACTION_ITEM_RESIZE, resizes);
+ }
+ },
$dispose: resizeObserver._dispose,
};
};
@@ -1354,6 +1370,8 @@ const Virtualizer = (props) => {
const range = createMemo((prev) => {
stateVersion();
const next = store.$getRange(props.bufferSize);
+ next[0] = Math.max(0, next[0]);
+ next[1] = Math.min(props.data.length - 1, next[1]);
if (prev && isSameRange(prev, next)) {
return prev;
}
@@ -1380,6 +1398,7 @@ const Virtualizer = (props) => {
findItemIndex: store.$findItemIndex,
getItemOffset: store.$getItemOffset,
getItemSize: store.$getItemSize,
+ measure: resizer.$measureItems,
scrollToIndex: scroller.$scrollToIndex,
scrollTo: scroller.$scrollTo,
scrollBy: scroller.$scrollBy,
@@ -1417,6 +1436,11 @@ const Virtualizer = (props) => {
const indexes = [];
if (props.keepMounted) {
const mounted = new Set(props.keepMounted);
+ mounted.forEach((index) => {
+ if (index < 0 || index >= count) {
+ mounted.delete(index);
+ }
+ });
for (let [i, j] = range(); i <= j; i++) {
mounted.add(i);
}
@@ -1528,6 +1552,8 @@ const WindowVirtualizer = (props) => {
const range = createMemo((prev) => {
stateVersion();
const next = store.$getRange(props.bufferSize);
+ next[0] = Math.max(0, next[0]);
+ next[1] = Math.min(props.data.length - 1, next[1]);
if (prev && isSameRange(prev, next)) {
return prev;
}