Compare commits

..

2 Commits

Author SHA1 Message Date
Adam
943d82ce0d chore: simpler splash window 2026-04-08 13:39:17 -05:00
Adam
10a43e0f6a fix(app): tighter startup sequence 2026-04-08 13:29:01 -05:00
20 changed files with 951 additions and 188 deletions

View File

@@ -13,6 +13,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Duration, Effect } from "effect"
import {
type Component,
createEffect,
createMemo,
createResource,
createSignal,
@@ -161,7 +162,7 @@ const effectMinDuration =
<A, E, R>(e: Effect.Effect<A, E, R>) =>
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; onReady?: () => void }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
@@ -189,6 +190,16 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
),
)
let sent = false
createEffect(() => {
if (sent) return
const ready = checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"
if (!ready) return
sent = true
props.onReady?.()
})
return (
<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
@@ -281,6 +292,7 @@ export function AppInterface(props: {
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
onReady?: () => void
}) {
return (
<ServerProvider
@@ -288,7 +300,7 @@ export function AppInterface(props: {
disableHealthCheck={props.disableHealthCheck}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ConnectionGate disableHealthCheck={props.disableHealthCheck} onReady={props.onReady}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>

View File

@@ -33,7 +33,6 @@ export default defineConfig({
rollupOptions: {
input: {
main: "src/renderer/index.html",
loading: "src/renderer/loading.html",
},
},
},

View File

@@ -41,8 +41,11 @@ const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
let splash: BrowserWindow | null = null
let ready = false
let sidecar: CommandChild | null = null
const loadingComplete = defer<void>()
const mainReady = defer<void>()
const pendingDeepLinks: string[] = []
@@ -112,6 +115,11 @@ function emitDeepLinks(urls: string[]) {
}
function focusMainWindow() {
if (!ready) {
splash?.show()
splash?.focus()
return
}
if (!mainWindow) return
mainWindow.show()
mainWindow.focus()
@@ -121,12 +129,14 @@ function setInitStep(step: InitStep) {
initStep = step
logger.log("init step", { step })
initEmitter.emit("step", step)
BrowserWindow.getAllWindows().forEach((win) => {
win.webContents.send("init-step", step)
})
}
async function initialize() {
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
let overlay: BrowserWindow | null = null
const port = await getSidecarPort()
const hostname = "127.0.0.1"
@@ -147,7 +157,7 @@ async function initialize() {
events.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (splash) sendSqliteMigrationProgress(splash, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
})
@@ -173,25 +183,31 @@ async function initialize() {
deepLinks: pendingDeepLinks,
}
if (needsMigration) {
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
if (show) {
overlay = createLoadingWindow(globals)
await delay(1_000)
}
}
const startup = (async () => {
await loadingTask
setInitStep({ phase: "app_waiting" })
mainWindow = createMainWindow(globals, { show: false })
await loadingTask
const ok = await Promise.race([mainReady.promise.then(() => true), delay(15_000).then(() => false)])
if (!ok) logger.warn("main window ready timed out")
})()
splash = createLoadingWindow(globals)
await startup
setInitStep({ phase: "done" })
if (overlay) {
await loadingComplete.promise
if (splash) {
const ok = await Promise.race([loadingComplete.promise.then(() => true), delay(2_000).then(() => false)])
if (!ok) logger.warn("loading window complete timed out")
splash.close()
splash = null
}
mainWindow = createMainWindow(globals)
wireMenu()
overlay?.close()
ready = true
mainWindow?.show()
mainWindow?.focus()
}
function wireMenu() {
@@ -240,6 +256,7 @@ registerIpcHandlers({
wslPath: async (path, mode) => wslPath(path, mode),
resolveAppPath: async (appName) => resolveAppPath(appName),
loadingWindowComplete: () => loadingComplete.resolve(),
mainWindowReady: () => mainReady.resolve(),
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(),

View File

@@ -26,6 +26,7 @@ type Deps = {
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
resolveAppPath: (appName: string) => Promise<string | null>
loadingWindowComplete: () => void
mainWindowReady: () => void
runUpdater: (alertOnFail: boolean) => Promise<void> | void
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void> | void
@@ -56,6 +57,7 @@ export function registerIpcHandlers(deps: Deps) {
)
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
ipcMain.on("main-window-ready", () => deps.mainWindowReady())
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
ipcMain.handle("check-update", () => deps.checkUpdate())
ipcMain.handle("install-update", () => deps.installUpdate())

View File

@@ -21,6 +21,10 @@ export function getBackgroundColor(): string | undefined {
return backgroundColor
}
function back(mode = tone()) {
return backgroundColor ?? (mode === "dark" ? "#101010" : "#f8f8f8")
}
function iconsDir() {
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
}
@@ -54,7 +58,7 @@ export function setDockIcon() {
if (!icon.isEmpty()) app.dock?.setIcon(icon)
}
export function createMainWindow(globals: Globals) {
export function createMainWindow(globals: Globals, opts: { show?: boolean } = {}) {
const state = windowState({
defaultWidth: 1280,
defaultHeight: 800,
@@ -66,10 +70,10 @@ export function createMainWindow(globals: Globals) {
y: state.y,
width: state.width,
height: state.height,
show: true,
show: opts.show ?? true,
title: "OpenCode",
icon: iconPath(),
backgroundColor,
backgroundColor: back(mode),
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
@@ -104,29 +108,235 @@ export function createLoadingWindow(globals: Globals) {
height: 480,
resizable: false,
center: true,
show: true,
show: false,
frame: false,
icon: iconPath(),
backgroundColor,
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
...(process.platform === "win32"
? {
frame: false,
titleBarStyle: "hidden" as const,
titleBarOverlay: overlay({ mode }),
}
: {}),
backgroundColor: back(mode),
webPreferences: {
preload: join(root, "../preload/index.mjs"),
sandbox: false,
},
})
loadWindow(win, "loading.html")
win.once("ready-to-show", () => {
if (!win.isDestroyed()) win.show()
})
loadSplash(win, mode)
injectGlobals(win, globals)
return win
}
function loadSplash(win: BrowserWindow, mode: "dark" | "light") {
void win.loadURL(`data:text/html;charset=UTF-8,${encodeURIComponent(page(mode))}`)
}
function page(mode: "dark" | "light") {
const dark = mode === "dark"
const bg = back(mode)
const base = dark ? "#7e7e7e" : "#8f8f8f"
const weak = dark ? "#343434" : "#dbdbdb"
const strong = dark ? "#ededed" : "#171717"
const track = dark ? "rgba(255,255,255,0.078)" : "rgba(0,0,0,0.051)"
const warn = dark ? "#fbb73c" : "#ebb76e"
const pulse = mark(base, strong)
const splash = mark(weak, strong)
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title>
<style>
:root {
color-scheme: ${mode};
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: ${bg};
}
body {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#root {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
}
#pulse,
#migrate {
display: flex;
align-items: center;
justify-content: center;
}
#pulse[hidden],
#migrate[hidden] {
display: none;
}
#pulse svg {
width: 64px;
height: 80px;
opacity: 0.5;
animation: pulse 1.6s ease-in-out infinite;
transform-origin: center;
}
#migrate {
flex-direction: column;
gap: 44px;
}
#migrate svg {
width: 80px;
height: 100px;
opacity: 0.15;
}
#copy {
display: flex;
width: 240px;
flex-direction: column;
align-items: center;
gap: 16px;
}
#status {
width: 100%;
overflow: hidden;
color: ${strong};
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 20px;
}
#bar {
width: 80px;
height: 4px;
overflow: hidden;
background: ${track};
}
#fill {
width: 25%;
height: 100%;
background: ${warn};
}
@keyframes pulse {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 0.15;
}
}
</style>
</head>
<body>
<div id="root">
<div id="pulse">${pulse}</div>
<div id="migrate" hidden>
${splash}
<div id="copy" aria-live="polite">
<span id="status">Just a moment...</span>
<div id="bar"><div id="fill"></div></div>
</div>
</div>
</div>
<script>
;(() => {
const lines = ["Just a moment...", "Migrating your database", "This may take a couple of minutes"]
const pulse = document.getElementById("pulse")
const migrate = document.getElementById("migrate")
const status = document.getElementById("status")
const fill = document.getElementById("fill")
let step = { phase: "server_waiting" }
let line = 0
let seen = false
let value = 0
let done = false
function render() {
const sql = step.phase === "sqlite_waiting" || (seen && step.phase === "done")
pulse.hidden = sql
migrate.hidden = !sql
if (!sql) return
status.textContent = step.phase === "done" ? "All done" : lines[line]
fill.style.width = String(step.phase === "done" ? 100 : Math.max(25, Math.min(100, value))) + "%"
}
function finish() {
if (done) return
done = true
window.api?.loadingWindowComplete?.()
}
function set(step_) {
step = step_ || step
render()
if (step.phase === "done") finish()
}
const timers = [3000, 9000].map((ms, i) =>
setTimeout(() => {
line = i + 1
render()
}, ms),
)
const off = window.api?.onInitStep?.((step_) => set(step_)) ?? (() => {})
const progress =
window.api?.onSqliteMigrationProgress?.((next) => {
seen = true
if (next.type === "InProgress") {
value = Math.max(0, Math.min(100, next.value))
step = { phase: "sqlite_waiting" }
render()
return
}
value = 100
step = { phase: "done" }
render()
finish()
}) ?? (() => {})
window.api?.awaitInitialization?.((step_) => set(step_))?.catch(() => undefined)
addEventListener("beforeunload", () => {
off()
progress()
timers.forEach(clearTimeout)
})
render()
})()
</script>
</body>
</html>`
}
function mark(base: string, strong: string) {
return `<svg viewBox="0 0 80 100" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M60 80H20V40H60V80Z" fill="${base}" /><path d="M60 20H20V80H60V20ZM80 100H0V0H80V100Z" fill="${strong}" /></svg>`
}
function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {

View File

@@ -11,6 +11,11 @@ const api: ElectronAPI = {
ipcRenderer.removeListener("init-step", handler)
})
},
onInitStep: (cb) => {
const handler = (_: unknown, step: InitStep) => cb(step)
ipcRenderer.on("init-step", handler)
return () => ipcRenderer.removeListener("init-step", handler)
},
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
@@ -60,6 +65,7 @@ const api: ElectronAPI = {
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
mainWindowReady: () => ipcRenderer.send("main-window-ready"),
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
checkUpdate: () => ipcRenderer.invoke("check-update"),
installUpdate: () => ipcRenderer.invoke("install-update"),

View File

@@ -1,4 +1,8 @@
export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
export type InitStep =
| { phase: "server_waiting" }
| { phase: "sqlite_waiting" }
| { phase: "app_waiting" }
| { phase: "done" }
export type ServerReadyData = {
url: string
@@ -19,6 +23,7 @@ export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
onInitStep: (cb: (step: InitStep) => void) => () => void
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
getWslConfig: () => Promise<WslConfig>
@@ -66,6 +71,7 @@ export type ElectronAPI = {
setZoomFactor: (factor: number) => Promise<void>
setTitlebar: (theme: TitlebarTheme) => Promise<void>
loadingWindowComplete: () => void
mainWindowReady: () => void
runUpdater: (alertOnFail: boolean) => Promise<void>
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void>

View File

@@ -16,7 +16,7 @@ const html = async (name: string) => Bun.file(join(dir, name)).text()
* All local resource references must use relative paths (`./`).
*/
describe("electron renderer html", () => {
for (const name of ["index.html", "loading.html"]) {
for (const name of ["index.html"]) {
describe(name, () => {
test("script src attributes use relative paths", async () => {
const content = await html(name)

View File

@@ -332,6 +332,7 @@ render(() => {
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
onReady={() => window.api.mainWindowReady()}
>
<Inner />
</AppInterface>

View File

@@ -1,22 +0,0 @@
<!doctype html>
<html lang="en" style="background-color: var(--background-base)">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title>
<link rel="icon" type="image/png" href="./favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="./favicon-v3.svg" />
<link rel="shortcut icon" href="./favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="./social-share.png" />
<meta property="twitter:image" content="./social-share.png" />
<script id="oc-theme-preload-script" src="./oc-theme-preload.js"></script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh"></div>
<script src="./loading.tsx" type="module"></script>
</body>
</html>

View File

@@ -1,83 +0,0 @@
import { MetaProvider } from "@solidjs/meta"
import { render } from "solid-js/web"
import "@opencode-ai/app/index.css"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { Progress } from "@opencode-ai/ui/progress"
import "./styles.css"
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import type { InitStep, SqliteMigrationProgress } from "../preload/types"
const root = document.getElementById("root")!
const lines = ["Just a moment...", "Migrating your database", "This may take a couple of minutes"]
const delays = [3000, 9000]
render(() => {
const [step, setStep] = createSignal<InitStep | null>(null)
const [line, setLine] = createSignal(0)
const [percent, setPercent] = createSignal(0)
const phase = createMemo(() => step()?.phase)
const value = createMemo(() => {
if (phase() === "done") return 100
return Math.max(25, Math.min(100, percent()))
})
window.api.awaitInitialization((next) => setStep(next as InitStep)).catch(() => undefined)
onMount(() => {
setLine(0)
setPercent(0)
const timers = delays.map((ms, i) => setTimeout(() => setLine(i + 1), ms))
const listener = window.api.onSqliteMigrationProgress((progress: SqliteMigrationProgress) => {
if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value)))
if (progress.type === "Done") {
setPercent(100)
setStep({ phase: "done" })
}
})
onCleanup(() => {
listener()
timers.forEach(clearTimeout)
})
})
createEffect(() => {
if (phase() !== "done") return
const timer = setTimeout(() => window.api.loadingWindowComplete(), 1000)
onCleanup(() => clearTimeout(timer))
})
const status = createMemo(() => {
if (phase() === "done") return "All done"
if (phase() === "sqlite_waiting") return lines[line()]
return "Just a moment..."
})
return (
<MetaProvider>
<div class="w-screen h-screen bg-background-base flex items-center justify-center">
<Font />
<div class="flex flex-col items-center gap-11">
<Splash class="w-20 h-25 opacity-15" />
<div class="w-60 flex flex-col items-center gap-4" aria-live="polite">
<span class="w-full overflow-hidden text-center text-ellipsis whitespace-nowrap text-text-strong text-14-normal">
{status()}
</span>
<Progress
value={value()}
class="w-20 [&_[data-slot='progress-track']]:h-1 [&_[data-slot='progress-track']]:border-0 [&_[data-slot='progress-track']]:rounded-none [&_[data-slot='progress-track']]:bg-surface-weak [&_[data-slot='progress-fill']]:rounded-none [&_[data-slot='progress-fill']]:bg-icon-warning-base"
aria-label="Database migration progress"
getValueLabel={({ value }) => `${Math.round(value)}%`}
/>
</div>
</div>
</div>
</MetaProvider>
)
}, root)

View File

@@ -28,7 +28,7 @@ export const ProviderRoutes = lazy(() =>
"application/json": {
schema: resolver(
z.object({
all: Provider.Info.array(),
all: ModelsDev.Provider.array(),
default: z.record(z.string(), z.string()),
connected: z.array(z.string()),
}),

View File

@@ -906,7 +906,7 @@ export const SessionRoutes = lazy(() =>
description: "Created message",
content: {
"application/json": {
schema: resolver(MessageV2.WithParts),
schema: resolver(MessageV2.Assistant),
},
},
},

View File

@@ -17,25 +17,58 @@ const ctx = {
ask: async () => {},
}
async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
using server = Bun.serve({ port: 0, fetch })
await fn(server.url)
type TimerID = ReturnType<typeof setTimeout>
async function withFetch(
mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
fn: () => Promise<void>,
) {
const originalFetch = globalThis.fetch
globalThis.fetch = mockFetch as unknown as typeof fetch
try {
await fn()
} finally {
globalThis.fetch = originalFetch
}
}
async function withTimers(fn: (state: { ids: TimerID[]; cleared: TimerID[] }) => Promise<void>) {
const set = globalThis.setTimeout
const clear = globalThis.clearTimeout
const ids: TimerID[] = []
const cleared: TimerID[] = []
globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
const id = set(...args)
ids.push(id)
return id
}) as typeof setTimeout
globalThis.clearTimeout = ((id?: TimerID) => {
if (id !== undefined) cleared.push(id)
return clear(id)
}) as typeof clearTimeout
try {
await fn({ ids, cleared })
} finally {
ids.forEach(clear)
globalThis.setTimeout = set
globalThis.clearTimeout = clear
}
}
describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
await withFetch(
() => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
async (url) => {
async () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
const result = await webfetch.execute(
{ url: new URL("/image.png", url).toString(), format: "markdown" },
ctx,
)
const result = await webfetch.execute({ url: "https://example.com/image.png", format: "markdown" }, ctx)
expect(result.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
@@ -54,17 +87,17 @@ describe("tool.webfetch", () => {
test("keeps svg as text output", async () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>'
await withFetch(
() =>
async () =>
new Response(svg, {
status: 200,
headers: { "content-type": "image/svg+xml; charset=UTF-8" },
}),
async (url) => {
async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx)
const result = await webfetch.execute({ url: "https://example.com/image.svg", format: "html" }, ctx)
expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined()
},
@@ -75,17 +108,17 @@ describe("tool.webfetch", () => {
test("keeps text responses as text output", async () => {
await withFetch(
() =>
async () =>
new Response("hello from webfetch", {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" },
}),
async (url) => {
async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx)
const result = await webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx)
expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined()
},
@@ -93,4 +126,28 @@ describe("tool.webfetch", () => {
},
)
})
test("clears timeout when fetch rejects", async () => {
await withTimers(async ({ ids, cleared }) => {
await withFetch(
async () => {
throw new Error("boom")
},
async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
await expect(
webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx),
).rejects.toThrow("boom")
},
})
},
)
expect(ids).toHaveLength(1)
expect(cleared).toContain(ids[0])
})
})
})

View File

@@ -3936,10 +3936,7 @@ export type SessionShellResponses = {
/**
* Created message
*/
200: {
info: Message
parts: Array<Part>
}
200: AssistantMessage
}
export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses]
@@ -4215,7 +4212,68 @@ export type ProviderListResponses = {
* List of providers
*/
200: {
all: Array<Provider>
all: Array<{
api?: string
name: string
env: Array<string>
id: string
npm?: string
models: {
[key: string]: {
id: string
name: string
family?: string
release_date: string
attachment: boolean
reasoning: boolean
temperature: boolean
tool_call: boolean
interleaved?:
| true
| {
field: "reasoning_content" | "reasoning_details"
}
cost?: {
input: number
output: number
cache_read?: number
cache_write?: number
context_over_200k?: {
input: number
output: number
cache_read?: number
cache_write?: number
}
}
limit: {
context: number
input?: number
output: number
}
modalities?: {
input: Array<"text" | "audio" | "image" | "video" | "pdf">
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
options: {
[key: string]: unknown
}
headers?: {
[key: string]: string
}
provider?: {
npm?: string
api?: string
}
variants?: {
[key: string]: {
[key: string]: unknown
}
}
}
}
}>
default: {
[key: string]: string
}

View File

@@ -4098,19 +4098,7 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"info": {
"$ref": "#/components/schemas/Message"
},
"parts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Part"
}
}
},
"required": ["info", "parts"]
"$ref": "#/components/schemas/AssistantMessage"
}
}
}
@@ -4802,7 +4790,211 @@
"all": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Provider"
"type": "object",
"properties": {
"api": {
"type": "string"
},
"name": {
"type": "string"
},
"env": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "string"
},
"npm": {
"type": "string"
},
"models": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"family": {
"type": "string"
},
"release_date": {
"type": "string"
},
"attachment": {
"type": "boolean"
},
"reasoning": {
"type": "boolean"
},
"temperature": {
"type": "boolean"
},
"tool_call": {
"type": "boolean"
},
"interleaved": {
"anyOf": [
{
"type": "boolean",
"const": true
},
{
"type": "object",
"properties": {
"field": {
"type": "string",
"enum": ["reasoning_content", "reasoning_details"]
}
},
"required": ["field"],
"additionalProperties": false
}
]
},
"cost": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"cache_read": {
"type": "number"
},
"cache_write": {
"type": "number"
},
"context_over_200k": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"cache_read": {
"type": "number"
},
"cache_write": {
"type": "number"
}
},
"required": ["input", "output"]
}
},
"required": ["input", "output"]
},
"limit": {
"type": "object",
"properties": {
"context": {
"type": "number"
},
"input": {
"type": "number"
},
"output": {
"type": "number"
}
},
"required": ["context", "output"]
},
"modalities": {
"type": "object",
"properties": {
"input": {
"type": "array",
"items": {
"type": "string",
"enum": ["text", "audio", "image", "video", "pdf"]
}
},
"output": {
"type": "array",
"items": {
"type": "string",
"enum": ["text", "audio", "image", "video", "pdf"]
}
}
},
"required": ["input", "output"]
},
"experimental": {
"type": "boolean"
},
"status": {
"type": "string",
"enum": ["alpha", "beta", "deprecated"]
},
"options": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"headers": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"provider": {
"type": "object",
"properties": {
"npm": {
"type": "string"
},
"api": {
"type": "string"
}
}
},
"variants": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
}
},
"required": [
"id",
"name",
"release_date",
"attachment",
"reasoning",
"temperature",
"tool_call",
"limit",
"options"
]
}
}
},
"required": ["name", "env", "id", "models"]
}
},
"default": {

View File

@@ -1,4 +1,4 @@
import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs"
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web"
@@ -13,6 +13,7 @@ import {
notifyShadowReady,
observeViewerScheme,
} from "../pierre/file-runtime"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { File, type DiffFileProps, type FileProps } from "./file"
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
@@ -25,6 +26,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
let container!: HTMLDivElement
let fileDiffRef!: HTMLElement
let fileDiffInstance: FileDiff<T> | undefined
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const ready = createReadyWatcher()
const workerPool = useWorkerPool(props.diffStyle)
@@ -49,6 +51,14 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
const getVirtualizer = () => {
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
const result = acquireVirtualizer(container)
if (!result) return
sharedVirtualizer = result
return result.virtualizer
}
const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
const diff = fileDiffInstance
if (!diff) return
@@ -82,15 +92,27 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
onCleanup(observeViewerScheme(() => fileDiffRef))
const virtualizer = getVirtualizer()
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
fileDiffInstance = new FileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...(local.preloadedDiff.options ?? {}),
},
workerPool,
)
fileDiffInstance = virtualizer
? new VirtualizedFileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...(local.preloadedDiff.options ?? {}),
},
virtualizer,
virtualMetrics,
workerPool,
)
: new FileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...(local.preloadedDiff.options ?? {}),
},
workerPool,
)
applyViewerScheme(fileDiffRef)
@@ -141,6 +163,8 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
onCleanup(() => {
clearReadyWatcher(ready)
fileDiffInstance?.cleanUp()
sharedVirtualizer?.release()
sharedVirtualizer = undefined
})
return (

View File

@@ -1,5 +1,6 @@
import { sampledChecksum } from "@opencode-ai/util/encode"
import {
DEFAULT_VIRTUAL_FILE_METRICS,
type DiffLineAnnotation,
type FileContents,
type FileDiffMetadata,
@@ -9,6 +10,10 @@ import {
type FileOptions,
type LineAnnotation,
type SelectedLineRange,
type VirtualFileMetrics,
VirtualizedFile,
VirtualizedFileDiff,
Virtualizer,
} from "@pierre/diffs"
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createMediaQuery } from "@solid-primitives/media"
@@ -35,10 +40,19 @@ import {
readShadowLineSelection,
} from "../pierre/file-selection"
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { getWorkerPool } from "../pierre/worker"
import { FileMedia, type FileMediaOptions } from "./file-media"
import { FileSearchBar } from "./file-search"
const VIRTUALIZE_BYTES = 500_000
const codeMetrics = {
...DEFAULT_VIRTUAL_FILE_METRICS,
lineHeight: 24,
fileGap: 0,
} satisfies Partial<VirtualFileMetrics>
type SharedProps<T> = {
annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
@@ -372,6 +386,11 @@ type AnnotationTarget<A> = {
rerender: () => void
}
type VirtualStrategy = {
get: () => Virtualizer | undefined
cleanup: () => void
}
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
return useFileViewer({
enableLineSelection: config.enableLineSelection,
@@ -513,6 +532,64 @@ function scrollParent(el: HTMLElement): HTMLElement | undefined {
}
}
function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
let virtualizer: Virtualizer | undefined
let root: Document | HTMLElement | undefined
const release = () => {
virtualizer?.cleanUp()
virtualizer = undefined
root = undefined
}
return {
get: () => {
if (!enabled()) {
release()
return
}
if (typeof document === "undefined") return
const wrapper = host()
if (!wrapper) return
const next = scrollParent(wrapper) ?? document
if (virtualizer && root === next) return virtualizer
release()
virtualizer = new Virtualizer()
root = next
virtualizer.setup(next, next instanceof Document ? undefined : wrapper)
return virtualizer
},
cleanup: release,
}
}
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const release = () => {
shared?.release()
shared = undefined
}
return {
get: () => {
if (shared) return shared.virtualizer
const container = host()
if (!container) return
const result = acquireVirtualizer(container)
if (!result) return
shared = result
return result.virtualizer
},
cleanup: release,
}
}
function parseLine(node: HTMLElement) {
if (!node.dataset.line) return
const value = parseInt(node.dataset.line, 10)
@@ -611,7 +688,7 @@ function ViewerShell(props: {
// ---------------------------------------------------------------------------
function TextViewer<T>(props: TextFileProps<T>) {
let instance: PierreFile<T> | undefined
let instance: PierreFile<T> | VirtualizedFile<T> | undefined
let viewer!: Viewer
const [local, others] = splitProps(props, textKeys)
@@ -630,12 +707,34 @@ function TextViewer<T>(props: TextFileProps<T>) {
return Math.max(1, total)
}
const bytes = createMemo(() => {
const value = local.file.contents as unknown
if (typeof value === "string") return value.length
if (Array.isArray(value)) {
return value.reduce(
(sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
0,
)
}
if (value == null) return 0
return String(value).length
})
const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual)
const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
const applySelection = (range: SelectedLineRange | null) => {
const current = instance
if (!current) return false
if (virtual()) {
current.setSelectedLines(range)
return true
}
const root = viewer.getRoot()
if (!root) return false
@@ -734,7 +833,10 @@ function TextViewer<T>(props: TextFileProps<T>) {
const notify = () => {
notifyRendered({
viewer,
isReady: (root) => root.querySelectorAll("[data-line]").length >= lineCount(),
isReady: (root) => {
if (virtual()) return root.querySelector("[data-line]") != null
return root.querySelectorAll("[data-line]").length >= lineCount()
},
onReady: () => {
applySelection(viewer.lastSelection)
viewer.find.refresh({ reset: true })
@@ -753,11 +855,17 @@ 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,
create: () => new PierreFile<T>(opts, workerPool),
create: () =>
isVirtual && virtualizer
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
: new PierreFile<T>(opts, workerPool),
assign: (value) => {
instance = value
},
@@ -784,6 +892,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
onCleanup(() => {
instance?.cleanUp()
instance = undefined
virtuals.cleanup()
})
return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} />
@@ -879,6 +988,8 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
adapter,
)
const virtuals = createSharedVirtualStrategy(() => viewer.container)
const large = createMemo(() => {
if (local.fileDiff) {
const before = local.fileDiff.deletionLines.join("")
@@ -941,6 +1052,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = virtuals.get()
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
const done = preserve(viewer)
@@ -955,7 +1067,10 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
renderViewer({
viewer,
current: instance,
create: () => new FileDiff<T>(opts, workerPool),
create: () =>
virtualizer
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
: new FileDiff<T>(opts, workerPool),
assign: (value) => {
instance = value
},
@@ -993,6 +1108,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
onCleanup(() => {
instance?.cleanUp()
instance = undefined
virtuals.cleanup()
dragSide = undefined
dragEndSide = undefined
})

View File

@@ -26,6 +26,7 @@ import type { LineCommentEditorProps } from "./line-comment"
import { normalize, text, type ViewDiff } from "./session-diff"
const MAX_DIFF_CHANGED_LINES = 500
const REVIEW_MOUNT_MARGIN = 300
export type SessionReviewDiffStyle = "unified" | "split"
@@ -138,11 +139,14 @@ type SessionReviewSelection = {
export const SessionReview = (props: SessionReviewProps) => {
let scroll: HTMLDivElement | undefined
let focusToken = 0
let frame: number | undefined
const i18n = useI18n()
const fileComponent = useFileComponent()
const anchors = new Map<string, HTMLElement>()
const nodes = new Map<string, HTMLDivElement>()
const [store, setStore] = createStore({
open: [] as string[],
visible: {} as Record<string, boolean>,
force: {} as Record<string, boolean>,
selection: null as SessionReviewSelection | null,
commenting: null as SessionReviewSelection | null,
@@ -170,7 +174,44 @@ export const SessionReview = (props: SessionReviewProps) => {
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
const hasDiffs = () => files().length > 0
const syncVisible = () => {
frame = undefined
if (!scroll) return
const root = scroll.getBoundingClientRect()
const top = root.top - REVIEW_MOUNT_MARGIN
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
const openSet = new Set(open())
const next: Record<string, boolean> = {}
for (const [file, el] of nodes) {
if (!openSet.has(file)) continue
const rect = el.getBoundingClientRect()
if (rect.bottom < top || rect.top > bottom) continue
next[file] = true
}
const prev = untrack(() => store.visible)
const prevKeys = Object.keys(prev)
const nextKeys = Object.keys(next)
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
setStore("visible", next)
}
const queue = () => {
if (frame !== undefined) return
frame = requestAnimationFrame(syncVisible)
}
const pinned = (file: string) =>
props.focusedComment?.file === file ||
props.focusedFile === file ||
selection()?.file === file ||
commenting()?.file === file ||
opened()?.file === file
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
queue()
const next = props.onScroll
if (!next) return
if (Array.isArray(next)) {
@@ -181,9 +222,21 @@ export const SessionReview = (props: SessionReviewProps) => {
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
}
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
createEffect(() => {
props.open
files()
queue()
})
const handleChange = (next: string[]) => {
props.onOpenChange?.(next)
if (props.open === undefined) setStore("open", next)
queue()
}
const handleExpandOrCollapseAll = () => {
@@ -297,6 +350,7 @@ export const SessionReview = (props: SessionReviewProps) => {
viewportRef={(el) => {
scroll = el
props.scrollRef?.(el)
queue()
}}
onScroll={handleScroll}
classList={{
@@ -309,9 +363,11 @@ export const SessionReview = (props: SessionReviewProps) => {
<Accordion multiple value={open()} onChange={handleChange}>
<For each={items()}>
{(diff) => {
let wrapper: HTMLDivElement | undefined
const file = diff.file
const expanded = createMemo(() => open().includes(file))
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
const force = () => !!store.force[file]
const comments = createMemo(() => grouped().get(file) ?? [])
@@ -402,6 +458,8 @@ export const SessionReview = (props: SessionReviewProps) => {
onCleanup(() => {
anchors.delete(file)
nodes.delete(file)
queue()
})
const handleLineSelected = (range: SelectedLineRange | null) => {
@@ -484,11 +542,21 @@ export const SessionReview = (props: SessionReviewProps) => {
<div
data-slot="session-review-diff-wrapper"
ref={(el) => {
wrapper = el
anchors.set(file, el)
nodes.set(file, el)
queue()
}}
>
<Show when={expanded()}>
<Switch>
<Match when={!mounted() && !tooLarge()}>
<div
data-slot="session-review-diff-placeholder"
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
style={{ height: "160px" }}
/>
</Match>
<Match when={tooLarge()}>
<div data-slot="session-review-large-diff">
<div data-slot="session-review-large-diff-title">

View File

@@ -0,0 +1,100 @@
import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs"
type Target = {
key: Document | HTMLElement
root: Document | HTMLElement
content: HTMLElement | undefined
}
type Entry = {
virtualizer: Virtualizer
refs: number
}
const cache = new WeakMap<Document | HTMLElement, Entry>()
export const virtualMetrics: Partial<VirtualFileMetrics> = {
lineHeight: 24,
hunkSeparatorHeight: 24,
fileGap: 0,
}
function scrollable(value: string) {
return value === "auto" || value === "scroll" || value === "overlay"
}
function scrollRoot(container: HTMLElement) {
let node = container.parentElement
while (node) {
const style = getComputedStyle(node)
if (scrollable(style.overflowY)) return node
node = node.parentElement
}
}
function target(container: HTMLElement): Target | undefined {
if (typeof document === "undefined") return
const review = container.closest("[data-component='session-review']")
if (review instanceof HTMLElement) {
const root = scrollRoot(container) ?? review
const content = review.querySelector("[data-slot='session-review-container']")
return {
key: review,
root,
content: content instanceof HTMLElement ? content : undefined,
}
}
const root = scrollRoot(container)
if (root) {
const content = root.querySelector("[role='log']")
return {
key: root,
root,
content: content instanceof HTMLElement ? content : undefined,
}
}
return {
key: document,
root: document,
content: undefined,
}
}
export function acquireVirtualizer(container: HTMLElement) {
const resolved = target(container)
if (!resolved) return
let entry = cache.get(resolved.key)
if (!entry) {
const virtualizer = new Virtualizer()
virtualizer.setup(resolved.root, resolved.content)
entry = {
virtualizer,
refs: 0,
}
cache.set(resolved.key, entry)
}
entry.refs += 1
let done = false
return {
virtualizer: entry.virtualizer,
release() {
if (done) return
done = true
const current = cache.get(resolved.key)
if (!current) return
current.refs -= 1
if (current.refs > 0) return
current.virtualizer.cleanUp()
cache.delete(resolved.key)
},
}
}