Compare commits

...
32 changed files with 992 additions and 63 deletions
+1
View File
@@ -43,6 +43,7 @@ The suite contains:
- home-session click timing split between content and titlebar-tab paint
- single-session tab close timing through stable home restoration
- cached session repaint and mutation tracing
- large-session search scan, first-result reveal, and highlight stabilization
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
- retained renderer heap with a large model catalog across repeated session navigation
@@ -0,0 +1,69 @@
import { benchmark, expect } from "../benchmark"
import { buildInitialStreamEvent, setupTimelineBenchmark, textPartID } from "./session-timeline-benchmark.fixture"
import {
collectTimelineSearchMetrics,
installTimelineSearchProbe,
waitForStableTimelineSearch,
} from "./session-timeline-search-probe"
benchmark("searches a large virtualized session and reveals the first result", async ({ page, report }) => {
benchmark.setTimeout(180_000)
const historyTurns = Number(process.env.TIMELINE_SEARCH_HISTORY_TURNS ?? 320)
const completionTimeout = Number(process.env.TIMELINE_SEARCH_COMPLETION_TIMEOUT_MS ?? 60_000)
const query = "Historical prompt"
const targetPartID = "msg_0000_0000_a_user:text:0"
const expectedCounter = `1/${historyTurns}`
const fixture = await setupTimelineBenchmark(page, {
historyTurns,
eventBatch: 1,
})
fixture.transport.enqueue(buildInitialStreamEvent(1))
await expect(fixture.text).toContainText("Implementation plan")
await fixture.scrollToBottom()
await fixture.waitForStableGeometry()
// Chromium reserves the physical shortcut for its native find overlay, so request the same controller path directly.
await page.evaluate(() => document.dispatchEvent(new Event("opencode:timeline-search-open")))
const search = page.locator('[data-component="timeline-search-bar"]')
const field = search.getByRole("searchbox", { name: "Find..." })
const count = search.locator('[data-slot="timeline-search-count"]')
const target = page.locator(`[data-timeline-part-id="${targetPartID}"]`)
await expect(field).toBeVisible()
await expect(field).toBeFocused()
await installTimelineSearchProbe(page, { targetPartID })
await field.fill(query)
await expect(count).toHaveText(expectedCounter)
await expect(target).toBeVisible({ timeout: completionTimeout })
await waitForStableTimelineSearch(page, { counter: expectedCounter, targetPartID, timeout: completionTimeout })
const metrics = await collectTimelineSearchMetrics(page, { counter: expectedCounter, targetPartID })
expect(metrics.summary.handlerDurationMs).toBeDefined()
expect(metrics.summary.firstCountObservedMs).toBeDefined()
expect(metrics.summary.firstTargetVisibleMs).toBeDefined()
expect(metrics.summary.firstActiveHighlightObservedMs).toBeDefined()
expect(metrics.summary.stableResultObservedMs).toBeDefined()
expect(metrics.summary.activeHighlightRanges).toBe(1)
report(metrics, { historyTurns, query, expectedMatches: historyTurns })
// Check navigation and the V2 assistant content IDs outside the measured interval.
await field.press("Enter")
await expect(count).toHaveText(`2/${historyTurns}`)
await field.press("Shift+Enter")
await expect(count).toHaveText(expectedCounter)
await field.fill("Implementation plan")
await expect(count).toHaveText("1/1")
await expect(fixture.text).toBeInViewport()
await expect
.poll(() =>
page.evaluate(() => {
const range = [...(CSS.highlights.get("timeline-search-hit-active") ?? [])][0]
return range?.startContainer.parentElement?.closest<HTMLElement>("[data-timeline-part-id]")?.dataset
.timelinePartId
}),
)
.toBe(textPartID)
await field.press("Escape")
await expect(search).toBeHidden()
})
@@ -0,0 +1,176 @@
import type { Page } from "@playwright/test"
export type TimelineSearchSample = {
observedAtMs: number
counter: string
targetMounted: boolean
targetVisible: boolean
targetTopPx?: number
activeRanges: number
activePartID?: string
activeVisible: boolean
scrollTopPx: number
}
type TimelineSearchProbe = {
samples: TimelineSearchSample[]
handlerDurationMs?: number
initialScrollTopPx: number
stop: () => void
}
export async function installTimelineSearchProbe(page: Page, input: { targetPartID: string }) {
await page.evaluate(({ targetPartID }) => {
const search = document.querySelector<HTMLElement>('[data-component="timeline-search-bar"]')
const field = search?.querySelector<HTMLInputElement>('[data-slot="text-input-v2-input"]')
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (!search || !field || !root) throw new Error("missing timeline search benchmark nodes")
const samples: TimelineSearchSample[] = []
const initialScrollTopPx = root.scrollTop
let startedAt: number | undefined
let handlerDurationMs: number | undefined
let frame: number | undefined
let running = true
const visibleInRoot = (rect: DOMRect) => {
const viewport = root.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom
}
const sample = () => {
if (!running || startedAt === undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
setTimeout(() => {
if (!running || startedAt === undefined) return
const target = root.querySelector<HTMLElement>(`[data-timeline-part-id="${targetPartID}"]`)
const targetRect = target?.getBoundingClientRect()
const highlight = CSS.highlights.get("timeline-search-hit-active")
const ranges = highlight ? [...highlight] : []
const active = ranges.find((range): range is Range => range instanceof Range)
const activeRect = active?.getBoundingClientRect()
const activeElement =
active?.startContainer instanceof Element ? active.startContainer : active?.startContainer.parentElement
samples.push({
observedAtMs: performance.now() - startedAt,
counter:
search.querySelector<HTMLElement>('[data-slot="timeline-search-count"]')?.textContent?.trim() ?? "",
targetMounted: !!target,
targetVisible: !!targetRect && visibleInRoot(targetRect),
targetTopPx: targetRect?.top,
activeRanges: ranges.length,
activePartID: activeElement?.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId,
activeVisible: !!activeRect && visibleInRoot(activeRect),
scrollTopPx: root.scrollTop,
})
sample()
}, 0)
})
}
const onInputCapture = (event: Event) => {
if (event.target !== field || startedAt !== undefined) return
startedAt = performance.now()
sample()
}
const onInput = (event: Event) => {
if (event.target !== field || startedAt === undefined || handlerDurationMs !== undefined) return
handlerDurationMs = performance.now() - startedAt
}
document.addEventListener("input", onInputCapture, { capture: true })
document.addEventListener("input", onInput)
;(window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark = {
samples,
initialScrollTopPx,
get handlerDurationMs() {
return handlerDurationMs
},
stop: () => {
running = false
document.removeEventListener("input", onInputCapture, { capture: true })
document.removeEventListener("input", onInput)
if (frame !== undefined) cancelAnimationFrame(frame)
},
}
}, input)
}
export async function waitForStableTimelineSearch(
page: Page,
input: { counter: string; targetPartID: string; timeout: number },
) {
await page.waitForFunction(
({ counter, targetPartID }) => {
const samples = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark
?.samples
if (!samples) return false
return samples.some((_, index) => {
const stable = samples.slice(index, index + 3)
if (stable.length !== 3) return false
return stable.every(
(sample, sampleIndex) =>
sample.counter === counter &&
sample.targetVisible &&
sample.activeRanges === 1 &&
sample.activePartID === targetPartID &&
sample.activeVisible &&
(sampleIndex === 0 ||
(Math.abs(sample.scrollTopPx - stable[sampleIndex - 1]!.scrollTopPx) <= 1 &&
Math.abs((sample.targetTopPx ?? Infinity) - (stable[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)),
)
})
},
{ counter: input.counter, targetPartID: input.targetPartID },
{ timeout: input.timeout },
)
}
export async function collectTimelineSearchMetrics(page: Page, input: { counter: string; targetPartID: string }) {
const result = await page.evaluate(() => {
const probe = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark
if (!probe) throw new Error("missing timeline search benchmark probe")
probe.stop()
return {
samples: probe.samples,
handlerDurationMs: probe.handlerDurationMs,
initialScrollTopPx: probe.initialScrollTopPx,
}
})
const first = (predicate: (sample: TimelineSearchSample) => boolean) => result.samples.find(predicate)?.observedAtMs
const stable = result.samples.findIndex((_, index) => {
const samples = result.samples.slice(index, index + 3)
if (samples.length !== 3) return false
return samples.every(
(sample, sampleIndex) =>
sample.counter === input.counter &&
sample.targetVisible &&
sample.activeRanges === 1 &&
sample.activePartID === input.targetPartID &&
sample.activeVisible &&
(sampleIndex === 0 ||
(Math.abs(sample.scrollTopPx - samples[sampleIndex - 1]!.scrollTopPx) <= 1 &&
Math.abs((sample.targetTopPx ?? Infinity) - (samples[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)),
)
})
const final = result.samples.at(-1)
return {
summary: {
handlerDurationMs: result.handlerDurationMs,
firstCountObservedMs: first((sample) => sample.counter === input.counter),
firstTargetMountedMs: first((sample) => sample.targetMounted),
firstTargetVisibleMs: first((sample) => sample.targetVisible),
firstActiveHighlightObservedMs: first(
(sample) => sample.activeRanges === 1 && sample.activePartID === input.targetPartID && sample.activeVisible,
),
stableResultObservedMs: stable >= 0 ? result.samples[stable + 2]?.observedAtMs : undefined,
initialScrollTopPx: result.initialScrollTopPx,
finalScrollTopPx: final?.scrollTopPx,
scrollDistancePx: final === undefined ? undefined : Math.abs(result.initialScrollTopPx - final.scrollTopPx),
activeHighlightRanges: final?.activeRanges,
},
samples: result.samples,
}
}
+9
View File
@@ -165,6 +165,15 @@
}
}
::highlight(timeline-search-hit) {
background-color: color-mix(in srgb, var(--v2-icon-icon-accent) 28%, transparent);
}
::highlight(timeline-search-hit-active) {
background-color: var(--v2-icon-icon-accent);
color: var(--v2-background-bg-deep);
}
[data-component="getting-started"] {
container-type: inline-size;
container-name: getting-started;
+2
View File
@@ -769,6 +769,8 @@ export const dict = {
"session.header.search.placeholder": "Search {{project}}",
"session.header.searchFiles": "Search files",
"session.search.placeholder": "Find...",
"session.search.noResults": "No matches",
"session.header.openIn": "Open in",
"session.header.open.action": "Open {{app}}",
"session.header.open.ariaLabel": "Open in {{app}}",
@@ -1,12 +1,13 @@
import { describe, expect, test } from "bun:test"
import { createRequestQueue } from "./request-queue"
import { createRequestQueue, isSlowRequest } from "./request-queue"
function setup(input?: { limit?: number; stallMs?: number; headersTimeoutMs?: number }) {
function setup(input?: { limit?: number; slowLimit?: number; stallMs?: number; headersTimeoutMs?: number }) {
const pending: Array<{ url: string; signal: AbortSignal; resolve: () => void }> = []
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
let clock = 0
const queue = createRequestQueue({
limit: input?.limit ?? 2,
slowLimit: input?.slowLimit,
stallMs: input?.stallMs,
headersTimeoutMs: input?.headersTimeoutMs,
now: () => clock,
@@ -40,6 +41,36 @@ describe("createRequestQueue", () => {
expect(input.queue.inflight()).toBe(0)
})
test("slow endpoints hold at most their share of slots so small reads go first", async () => {
const input = setup({ limit: 4, slowLimit: 2 })
const paths = ["/api/vcs?location[directory]=%2Fa", "/api/vcs/diff?location[directory]=%2Fa", "/api/worktree", "/api/session/ses_1"]
const responses = paths.map((path) => input.queue.fetch(`http://server${path}`))
await input.settle()
const started = () => input.pending.map((item) => new URL(item.url).pathname)
// Two slow requests fill the slow share; the worktree read waits while the session read jumps ahead.
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1"])
expect(input.queue.inflight()).toBe(3)
expect(input.queue.queued()).toBe(1)
// A fast request finishing does not free a slow slot.
input.pending[2]!.resolve()
await input.settle()
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1"])
input.pending[0]!.resolve()
await input.settle()
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1", "/api/worktree"])
input.pending.forEach((item) => item.resolve())
await Promise.all(responses)
expect(input.queue.inflight()).toBe(0)
})
test("classifies git and worktree endpoints as slow", () => {
expect(isSlowRequest("/api/vcs")).toBe(true)
expect(isSlowRequest("/api/vcs/branches")).toBe(true)
expect(isSlowRequest("/api/worktree")).toBe(true)
expect(isSlowRequest("/api/vcsx")).toBe(false)
expect(isSlowRequest("/api/session")).toBe(false)
})
test("never counts the event stream against the budget", async () => {
const input = setup({ limit: 1 })
void input.queue.fetch("http://server/api/session")
@@ -1,10 +1,15 @@
type Entry = { method: string; url: string; at: number }
type Entry = { method: string; url: string; at: number; slow: boolean }
// Chromium allows six connections per origin. The event stream holds one for the life of the
// connection and health probes use their own fetch, so the app's API calls stay below that or
// a burst stalls probes and user actions inside the browser where nothing can observe it.
export const requestQueueLimit = 4
// Endpoints that shell out to git or walk the filesystem take seconds on a large repository. They
// may hold at most this many slots, so a session mount's small reads never queue behind them.
export const requestQueueSlowLimit = 2
export const slowRequestPaths = ["/api/vcs", "/api/worktree"]
// A mount legitimately fires a dozen requests at once; only a request that has waited this long
// for a slot indicates the server is not keeping up.
export const requestStallMs = 2_000
@@ -14,15 +19,21 @@ export const requestStallMs = 2_000
// instead of wedging every later API call; the body may still stream for as long as it needs.
export const requestHeadersTimeoutMs = 60_000
export function isSlowRequest(pathname: string) {
return slowRequestPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))
}
export function createRequestQueue(input: {
fetch: typeof globalThis.fetch
limit?: number
slowLimit?: number
stallMs?: number
headersTimeoutMs?: number
log?: (message: string, data: Record<string, unknown>) => void
now?: () => number
}) {
const limit = input.limit ?? requestQueueLimit
const slowLimit = input.slowLimit ?? requestQueueSlowLimit
const stallMs = input.stallMs ?? requestStallMs
const headersTimeoutMs = input.headersTimeoutMs ?? requestHeadersTimeoutMs
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
@@ -50,9 +61,17 @@ export function createRequestQueue(input: {
}
watcher = setTimeout(watch, stallMs)
}
const canStart = (entry: Entry) => {
if (inflight.size >= limit) return false
if (!entry.slow) return true
return [...inflight].filter((item) => item.slow).length < slowLimit
}
// FIFO, except a slow request waits its turn behind faster ones while the slow slots are full.
const release = (entry: Entry) => {
inflight.delete(entry)
waiting.shift()?.start()
const index = waiting.findIndex((item) => canStart(item.entry))
if (index === -1) return
waiting.splice(index, 1)[0]?.start()
}
const acquire = (entry: Entry) =>
new Promise<void>((resolve) => {
@@ -61,7 +80,7 @@ export function createRequestQueue(input: {
inflight.add(entry)
resolve()
}
if (inflight.size < limit) return start()
if (canStart(entry)) return start()
waiting.push({ entry, start })
watcher ??= setTimeout(watch, stallMs)
})
@@ -69,9 +88,10 @@ export function createRequestQueue(input: {
const fetch: typeof globalThis.fetch = Object.assign(
async (resource: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(resource, init)
const pathname = new URL(request.url).pathname
// The event stream is long-lived; never count it against the request budget.
if (new URL(request.url).pathname === "/api/event") return base(request)
const entry = { method: request.method, url: request.url, at: now() }
if (pathname === "/api/event") return base(request)
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
await acquire(entry)
if (request.signal.aborted) {
release(entry)
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createConnectionSync } from "./connection"
import { createConnectionSync, reconnectOrder } from "./connection"
test("invalidates disconnected data and synchronizes after the handshake", () => {
const calls: string[] = []
@@ -19,3 +19,9 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
})
dispose()
})
test("held directories refresh before the rest, otherwise keeping their order", () => {
const held = new Set(["/b", "/d"])
expect(reconnectOrder(["/a", "/b", "/c", "/d"], (directory) => held.has(directory))).toEqual(["/b", "/d", "/a", "/c"])
expect(reconnectOrder(["/a", "/c"], (directory) => held.has(directory))).toEqual(["/a", "/c"])
})
@@ -20,3 +20,8 @@ export function createConnectionSync(input: {
return { handleEvent }
}
// Directories a mounted view holds refresh first; the rest keep their existing order behind them.
export function reconnectOrder(directories: string[], held: (directory: string) => boolean) {
return [...directories.filter(held), ...directories.filter((directory) => !held(directory))]
}
+6 -7
View File
@@ -17,7 +17,7 @@ import type { ServerScope } from "@/runtime/server/scope"
import { persisted } from "@/runtime/persistence/storage"
import type { ServerApi } from "@/runtime/server/api"
import { toggleMcp } from "./global-sync/mcp"
import { createConnectionSync } from "./server-sync/connection"
import { createConnectionSync, reconnectOrder } from "./server-sync/connection"
import { usePlatform } from "@/runtime/platform/platform"
import type { Data } from "@opencode-ai/client/solid"
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
@@ -162,12 +162,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
},
connected: (info) => {
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
Object.keys(children.children)
.filter(children.active)
.forEach((directory) => {
queue.push(directory)
void data.location.sync({ directory }).catch(() => undefined)
})
// The refresh queue re-syncs two directories at a time, held ones first. Syncing every active
// directory here as well sent the whole catalog fan-out for all of them at once.
reconnectOrder(Object.keys(children.children).filter(children.active), children.pinned).forEach(
(directory) => queue.push(directory),
)
},
})
+9
View File
@@ -27,6 +27,8 @@ import { createSessionReview } from "./review/model"
import { SessionDesktopReview, SessionMobileReview, SessionMobileViewTabs } from "./review/view"
import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { createTimelineSearchController } from "./timeline/search-controller"
import { TimelineSearchBar } from "./timeline/search-bar"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
@@ -47,6 +49,12 @@ export function SessionScreen(props: { session: SessionModel }) {
const isDesktop = session.isDesktop
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const timelineSearch = createTimelineSearchController({
sessionID: session.identity.sessionID,
scrollRef: timeline.scroller,
revealMessage: timeline.actions.revealMessage,
pauseAutoScroll: timeline.view.unpin,
})
const messagesReady = timeline.ready
const [store, setStore] = createStore({
deferRender: false,
@@ -262,6 +270,7 @@ export function SessionScreen(props: { session: SessionModel }) {
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
search={<TimelineSearchBar controller={timelineSearch} />}
/>
)}
</Show>
@@ -24,6 +24,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
pinned: true,
},
refs: {
scroller: undefined as HTMLDivElement | undefined,
content: undefined as HTMLDivElement | undefined,
dock: undefined as HTMLDivElement | undefined,
},
@@ -38,7 +39,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
}
let scroller: HTMLDivElement | undefined
let dockHeight = 0
let revealMessage = (_id: string) => {}
let revealMessage = (_id: string, _partID?: string) => {}
let scrollToEnd = () => {}
let scrollMark = 0
let messageMark = 0
@@ -157,6 +158,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
}
const setScrollRef = (element: HTMLDivElement | undefined) => {
scroller = element
setState("refs", "scroller", element)
if (!element) return
scheduleScrollState(element)
fill()
@@ -290,6 +292,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
return {
actions: {
navigateMessage,
revealMessage: (id: string, partID?: string) => revealMessage(id, partID),
resume,
setActiveMessage,
},
@@ -297,7 +300,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
resource: timeline.resource,
ready: timeline.ready,
scroll: state.scroll,
scroller: () => scroller,
scroller: () => state.refs.scroller,
view: {
anchor,
markUserScroll,
@@ -313,7 +316,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
setDockRef: (element: HTMLDivElement | undefined) => {
setState("refs", "dock", element)
},
setRevealMessage: (reveal: (id: string) => void) => {
setRevealMessage: (reveal: (id: string, partID?: string) => void) => {
revealMessage = reveal
},
setScrollRef,
@@ -353,8 +353,9 @@ type MessageTimelineProps = {
workspaceMoveEligible: boolean
onSummaryOpenChange: (open: boolean) => void
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string) => void) => void
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
search?: JSX.Element
}
export function MessageTimeline(props: MessageTimelineProps) {
@@ -789,6 +790,7 @@ function MessageTimelineView(
<Show when={sessionID()} keyed>
{(id) => (
<div class="shrink-0 flex items-center gap-2">
{props.search}
<SessionContextUsage placement="bottom" />
<Show when={!parentID() && project()}>
{(project) => (
@@ -0,0 +1,14 @@
[data-component="timeline-search-bar"] [data-component="text-input-v2"] {
background: var(--v2-background-bg-base);
box-shadow: inset 0 0 0 1px var(--v2-border-border-base);
outline: none;
}
[data-component="timeline-search-bar"] [data-component="text-input-v2"]:hover {
background: var(--v2-background-bg-base);
}
[data-component="timeline-search-bar"] [data-component="text-input-v2"]:focus-within {
box-shadow: inset 0 0 0 1px var(--v2-border-border-focus);
outline: none;
}
@@ -0,0 +1,72 @@
import { Icon } from "@opencode-ai/ui/icon"
import "@opencode-ai/ui/text-input.css"
import { Show } from "solid-js"
import type { TimelineSearchController } from "./search-controller"
import "./search-bar.css"
export function TimelineSearchBar(props: { controller: TimelineSearchController }) {
const c = props.controller
return (
<Show when={c.visible()}>
<div data-component="timeline-search-bar" class="h-7 w-[200px] max-w-[50vw] shrink-0">
<div data-component="text-input-v2" data-appearance="base" data-leading-icon class="!h-7 !w-full max-w-full">
<div data-slot="text-input-v2-value">
<span data-slot="text-input-v2-leading-icon">
<Icon name="magnifying-glass" size="small" />
</span>
<input
ref={c.element.setInput}
data-slot="text-input-v2-input"
type="search"
value={c.query.value()}
placeholder={c.query.placeholder()}
aria-label={c.query.placeholder()}
onInput={(event) => c.query.setValue(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
c.query.close()
return
}
if (event.altKey || event.metaKey || event.ctrlKey) return
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
c.result.move(event.shiftKey ? -1 : 1)
return
}
if (event.key === "ArrowDown" && !event.isComposing) {
event.preventDefault()
c.result.move(1)
return
}
if (event.key === "ArrowUp" && !event.isComposing) {
event.preventDefault()
c.result.move(-1)
return
}
}}
/>
</div>
<Show when={c.query.value()}>
<span
data-slot="timeline-search-count"
class="shrink-0 self-center text-[11px] text-v2-text-text-muted [font-weight:440] tabular-nums"
>
{c.result.count() > 0 ? c.result.activeIndex() + 1 : 0}/{c.result.count()}
</span>
</Show>
<button
type="button"
class="-me-1 flex size-5 shrink-0 self-center items-center justify-center rounded-[2px] border-0 bg-transparent p-0 text-v2-icon-icon-muted outline outline-1 outline-transparent hover:bg-v2-overlay-simple-overlay-hover active:bg-v2-overlay-simple-overlay-pressed focus-visible:outline-v2-border-border-focus"
aria-label={c.query.placeholder()}
onMouseDown={(event) => event.preventDefault()}
onClick={() => c.query.close()}
>
<Icon name="xmark-small" />
</button>
</div>
</div>
</Show>
)
}
@@ -0,0 +1,275 @@
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { Timeline } from "@opencode-ai/session-ui/timeline/projection"
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
export type TimelineSearchMatch = {
messageID: string
role: "user" | "assistant"
revealID: string
partID: string
occurrence: number
text: string
}
const HIGHLIGHT_HIT = "timeline-search-hit"
const HIGHLIGHT_ACTIVE = "timeline-search-hit-active"
const TEXT_SELECTORS = '[data-slot="text-part-body"], [data-slot="user-message-text"]'
function supportsHighlights() {
return typeof CSS !== "undefined" && typeof CSS.highlights === "object" && CSS.highlights !== null
}
function clearHighlights() {
if (!supportsHighlights()) return
CSS.highlights.delete(HIGHLIGHT_HIT)
CSS.highlights.delete(HIGHLIGHT_ACTIVE)
}
function collectRanges(
root: HTMLElement,
query: string,
activePartID: string | undefined,
activeOccurrence: number | undefined,
) {
const hits: Range[] = []
const active: Range[] = []
const lower = query.toLowerCase()
const bodies = root.querySelectorAll<HTMLElement>(TEXT_SELECTORS)
for (const body of bodies) {
const part = body.closest("[data-timeline-part-id]")
const partID = part?.getAttribute("data-timeline-part-id")
const isActivePart = activePartID !== undefined && partID === activePartID
let occurrenceInPart = 0
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT)
let node = walker.nextNode() as Text | null
while (node) {
const value = node.nodeValue ?? ""
const lowerValue = value.toLowerCase()
let from = 0
let at = lowerValue.indexOf(lower, from)
while (at !== -1) {
const range = document.createRange()
range.setStart(node, at)
range.setEnd(node, at + query.length)
if (isActivePart && activeOccurrence === occurrenceInPart) active.push(range)
else hits.push(range)
occurrenceInPart += 1
from = at + query.length
at = lowerValue.indexOf(lower, from)
}
node = walker.nextNode() as Text | null
}
}
return { hits, active }
}
function applyHighlights(
root: HTMLElement,
query: string,
activePartID: string | undefined,
activeOccurrence: number | undefined,
) {
if (!supportsHighlights()) return
const { hits, active } = collectRanges(root, query, activePartID, activeOccurrence)
CSS.highlights.set(HIGHLIGHT_HIT, new Highlight(...hits))
CSS.highlights.set(HIGHLIGHT_ACTIVE, new Highlight(...active))
}
export function createTimelineSearchController(input: {
sessionID: () => string | undefined
scrollRef: () => HTMLDivElement | undefined
revealMessage: (id: string, partID?: string) => void
pauseAutoScroll: () => void
}) {
const command = useCommand()
const language = useLanguage()
const data = useData()
const [state, setState] = createStore({ value: "", active: 0, visible: false })
const [focusTick, setFocusTick] = createSignal(0)
let inputEl: HTMLInputElement | undefined
const query = createMemo(() => state.value.trim().toLowerCase())
const matches = createMemo<TimelineSearchMatch[]>(() => {
const value = query()
if (!value) return []
const sessionID = input.sessionID()
if (!sessionID) return []
const messages = data.session.message.list(sessionID)
const result: TimelineSearchMatch[] = []
let revealID = ""
for (const message of messages) {
if (message.type === "user" || message.type === "shell") revealID = message.id
if (message.type !== "user" && message.type !== "assistant") continue
const visibleParts =
message.type === "user"
? [{ id: `${message.id}:text:0`, content: { type: "text" as const, text: message.text } }]
: Timeline.contentEntries(message)
for (const textPart of visibleParts) {
if (textPart.content.type !== "text") continue
const text = textPart.content.text
if (!text) continue
const lower = text.toLowerCase()
let from = 0
let occurrence = 0
let at = lower.indexOf(value, from)
while (at !== -1) {
result.push({
messageID: message.id,
role: message.type,
revealID,
partID: textPart.id,
occurrence,
text,
})
occurrence += 1
from = at + value.length
at = lower.indexOf(value, from)
}
}
}
return result
})
const activeIndex = createMemo(() => {
const list = matches()
if (list.length === 0) return 0
if (state.active >= list.length) return 0
if (state.active < 0) return 0
return state.active
})
const activePartID = createMemo(() => matches()[activeIndex()]?.partID)
const activeOccurrence = createMemo(() => matches()[activeIndex()]?.occurrence)
createEffect(() => {
const root = input.scrollRef()
const q = query()
if (!root || !state.visible || !q) {
clearHighlights()
return
}
applyHighlights(root, q, activePartID(), activeOccurrence())
let frame: number | undefined
const scheduleApply = () => {
if (frame !== undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
if (!state.visible) return
applyHighlights(root, query(), activePartID(), activeOccurrence())
})
}
const observer = new MutationObserver(scheduleApply)
observer.observe(root, { childList: true, subtree: true, characterData: true })
onCleanup(() => {
observer.disconnect()
if (frame !== undefined) cancelAnimationFrame(frame)
clearHighlights()
})
})
createEffect(
on(focusTick, () => {
if (!state.visible) return
requestAnimationFrame(() => {
inputEl?.focus()
inputEl?.select()
})
}),
)
command.register("session.search", () => [
{
id: "session.search",
title: language.t("session.search.placeholder"),
keybind: "mod+f",
hidden: true,
onSelect: () => open(),
},
])
const onOpenRequest = () => open()
document.addEventListener("opencode:timeline-search-open", onOpenRequest)
onCleanup(() => document.removeEventListener("opencode:timeline-search-open", onOpenRequest))
function open() {
setState("visible", true)
setFocusTick((t) => t + 1)
}
function close() {
setState({ value: "", active: 0, visible: false })
inputEl?.blur()
}
function setValue(value: string) {
setState("value", value)
const list = matches()
const match = list[0]
if (!value.trim() || !match) {
setState("active", 0)
return
}
setState("active", 0)
input.pauseAutoScroll()
input.revealMessage(match.revealID, match.partID)
scrollToMatch(match)
}
function scrollToMatch(match: TimelineSearchMatch) {
let attempts = 0
const seek = () => {
if (!state.visible) return
const root = input.scrollRef()
if (!root) return
const { active } = collectRanges(root, query(), match.partID, match.occurrence)
if (active.length === 0) {
if (attempts++ < 12) requestAnimationFrame(seek)
return
}
const rect = active[0].getBoundingClientRect()
const rootRect = root.getBoundingClientRect()
const sticky = root.querySelector("[data-session-title]")
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
const top = rect.top - rootRect.top + root.scrollTop - inset - (rootRect.height - rect.height) / 2
root.scrollTo({ top: Math.max(0, top), behavior: "auto" })
}
requestAnimationFrame(seek)
}
function move(delta: number) {
const list = matches()
if (list.length === 0) return
const next = (activeIndex() + delta + list.length) % list.length
setState("active", next)
const match = list[next]
if (!match) return
input.pauseAutoScroll()
input.revealMessage(match.revealID, match.partID)
scrollToMatch(match)
}
return {
visible: () => state.visible,
query: {
value: () => state.value,
placeholder: () => language.t("session.search.placeholder"),
noResults: () => language.t("session.search.noResults"),
open,
close,
setValue,
},
result: {
activeIndex,
count: () => matches().length,
move,
},
element: {
setInput: (element: HTMLInputElement) => (inputEl = element),
},
}
}
export type TimelineSearchController = ReturnType<typeof createTimelineSearchController>
@@ -69,7 +69,7 @@ type Input = {
row: TimelineRow.TimelineRow,
disclosure: Readonly<Record<string, boolean | undefined>>,
) => boolean
setRevealMessage?: (fn: (id: string) => void) => void
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
}
@@ -271,8 +271,13 @@ export function createTimelineVirtualizer(input: Input) {
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
createEffect(() => {
input.setRevealMessage?.((id) => {
const index = input.projection.messageRowIndex().get(id)
input.setRevealMessage?.((id, partID) => {
const partIndex = partID
? rows().findIndex(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
)
: -1
const index = partIndex >= 0 ? partIndex : input.projection.messageRowIndex().get(id)
if (index === undefined) return
virtualizer.scrollToIndex(index, { align: "center" })
})
+12 -6
View File
@@ -5,6 +5,8 @@ import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
import { SessionExecution } from "./session/execution.js"
import { SessionStore } from "./session/store.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
const isSessionEvent = Schema.is(SessionEvent.Durable)
@@ -18,6 +20,8 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
const clock = yield* Clock.Clock
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const execution = yield* SessionExecution.Service
const sessions = yield* SessionStore.Service
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
@@ -39,19 +43,21 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
yield* Effect.sleep(options.sweepInterval ?? "1 minute")
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
const cached = new Set(refs.map(key))
yield* Effect.forEach(
refs,
(ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)),
{ discard: true },
)
yield* Effect.forEach(refs, (ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)), { discard: true })
for (const id of entries.keys()) {
if (!cached.has(id)) entries.delete(id)
}
const now = clock.currentTimeMillisUnsafe()
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
if (expired.length === 0) return
const active = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
const occupied = new Set(active.flatMap((session) => (session ? [key(session.location)] : [])))
yield* Effect.forEach(
expired,
(entry) => {
// Waiting for a question or a long-running tool emits no activity.
// Invalidating a borrowed graph would strand it behind a new cache entry.
if (occupied.has(key(entry.ref))) return touch(entry.ref)
entries.delete(key(entry.ref))
return Effect.logInfo("location services evicted", {
directory: entry.ref.directory,
@@ -70,5 +76,5 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
export const node = makeGlobalNode({
service: Service,
layer: layer(),
deps: [Bus.node, LocationServiceMap.node],
deps: [Bus.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node],
})
@@ -0,0 +1,149 @@
import { describe, expect } from "bun:test"
import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, RcMap, Schema } from "effect"
import { TestClock } from "effect/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { Form } from "@opencode-ai/core/form"
import { Location } from "@opencode-ai/core/location"
import { LocationActivity } from "@opencode-ai/core/location-activity"
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
// Keep real execution ownership, location caching, forms, and eviction. The fixture
// runner waits on a form instead of making a model request before asking a question.
const locations = Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
return yield* LayerMap.make(
(ref: Location.Ref) =>
// The fixture only exercises these three Location services.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.merge(
Layer.succeed(
Location.Service,
Location.Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory },
}),
),
Layer.effect(
SessionRunner.Service,
Effect.gen(function* () {
const forms = yield* Form.Service
return SessionRunner.Service.of({
drain: ({ sessionID }) =>
forms
.ask({
sessionID,
title: "Questions",
fields: [{ key: "runtime", type: "string" }],
})
.pipe(Effect.orDie, Effect.as(SessionRunner.DrainResult.Complete())),
})
}),
),
).pipe(
Layer.provideMerge(Form.layer),
Layer.provide(Layer.succeed(Bus.Service, bus)),
Layer.fresh,
) as unknown as Layer.Layer<LocationServices>,
{ idleTimeToLive: Duration.infinity },
)
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, SessionExecution.node, LocationActivity.node]),
[
LocationServiceMap.node.replace(
makeGlobalNode({
service: LocationServiceMap.Service,
layer: locations,
deps: [Bus.node],
}),
),
],
),
)
describe("LocationActivity active execution", () => {
for (const settle of ["answer", "cancel", "interrupt"] as const) {
it.effect(`keeps a waiting question reachable past the deadline until ${settle}`, () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const map = yield* LocationServiceMap.Service
const execution = yield* SessionExecution.Service
const sessionID = Session.ID.make("ses_waiting_question")
const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/project") })
const idle = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_idle") })
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: ref.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "question",
directory: ref.directory,
title: "Waiting question",
version: "test",
})
.run()
.pipe(Effect.orDie)
const created = yield* Deferred.make<Form.Info>()
const unsubscribe = yield* bus.listen((event) =>
event.type === Form.Event.Created.type
? Deferred.succeed(created, Schema.decodeUnknownSync(Form.Event.Created.data)(event.data).form).pipe(
Effect.asVoid,
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const running = yield* execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped)
const form = yield* Deferred.await(created)
yield* Location.Service.pipe(Effect.provide(map.get(idle)), Effect.scoped)
// The first sweep discovers both cached graphs. No more Session events
// are needed while the human is deciding how to answer.
yield* TestClock.adjust("1 minute")
yield* TestClock.adjust("62 minutes")
expect(yield* execution.isActive(sessionID)).toBe(true)
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
const context = yield* map.contextEffect(ref).pipe(Effect.scoped)
const forms = Context.get(context, Form.Service)
expect(yield* forms.list({ sessionID })).toEqual([form])
if (settle === "answer") yield* forms.reply({ id: form.id, answer: { runtime: "Bun" } })
if (settle === "cancel") yield* forms.cancel(form.id)
if (settle === "interrupt") yield* execution.interrupt(sessionID)
yield* Fiber.join(running)
yield* execution.awaitIdle(sessionID)
expect(yield* forms.state(form.id)).toEqual(
settle === "answer" ? { status: "answered", answer: { runtime: "Bun" } } : { status: "cancelled" },
)
yield* TestClock.adjust("62 minutes")
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([])
}),
)
}
})
@@ -14,6 +14,7 @@ import { ApplicationLifecycle } from "../lifecycle"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding"
import { BackgroundService } from "../service/background-service"
import { DesktopCli } from "../service/desktop-cli"
import { SidecarCredentials } from "../service/sidecar-credentials"
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
import { Updater } from "../updater"
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
@@ -29,8 +30,8 @@ export const appHandlers = AppRpcs.toLayer(
const logging = yield* DesktopLogging.Service
const runFork = Effect.runForkWith(yield* Effect.context())
return AppRpcs.of({
AppAwaitInitialization: () => background.connection,
AppReconnectService: () => background.reconnect,
AppAwaitInitialization: () => background.connection.pipe(Effect.map(SidecarCredentials.ready)),
AppReconnectService: () => background.reconnect.pipe(Effect.map(SidecarCredentials.ready)),
AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks),
AppGetDefaultServerUrl: () => Effect.sync(getDefaultServerUrl),
AppSetDefaultServerUrl: ({ url }) => Effect.sync(() => setDefaultServerUrl(url)),
@@ -1,14 +1,14 @@
export * as BackgroundServiceState from "./background-service-state"
import { Effect, Exit, Ref } from "effect"
import type { ServerReadyData } from "../../shared/ipc-contract"
import type { SidecarCredentials } from "./sidecar-credentials"
export const make = Effect.fn("BackgroundServiceState.make")(function* (options: {
readonly initial: Effect.Effect<ServerReadyData, unknown>
readonly reconnect: Effect.Effect<ServerReadyData>
readonly initial: Effect.Effect<SidecarCredentials.Data, unknown>
readonly reconnect: Effect.Effect<SidecarCredentials.Data>
}) {
// Every Exit is an Effect, so the latest resolution replays directly for each consumer.
const current = yield* Ref.make<Exit.Exit<ServerReadyData, unknown>>(yield* options.initial.pipe(Effect.exit))
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown>>(yield* options.initial.pipe(Effect.exit))
return {
connection: Ref.get(current).pipe(Effect.flatten, Effect.orDie),
reconnect: options.reconnect.pipe(Effect.tap((next) => Ref.set(current, Exit.succeed(next)))),
@@ -1,14 +1,14 @@
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import type { ServerReadyData } from "../../shared/ipc-contract"
import { BackgroundServiceState } from "./background-service-state"
import { cleanStages, DesktopCli } from "./desktop-cli"
import { SidecarCredentials } from "./sidecar-credentials"
export * as BackgroundService from "./background-service"
export interface Interface {
readonly connection: Effect.Effect<ServerReadyData>
readonly reconnect: Effect.Effect<ServerReadyData>
readonly connection: Effect.Effect<SidecarCredentials.Data>
readonly reconnect: Effect.Effect<SidecarCredentials.Data>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/BackgroundService") {}
@@ -56,10 +56,9 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
...endpoint(url.origin),
})
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
return {
url: url.origin,
password: service.auth.password,
} satisfies ServerReadyData
const ready = { url: url.origin, password: service.auth.password } satisfies SidecarCredentials.Data
SidecarCredentials.set(ready)
return ready
})
function endpoint(url: string | undefined) {
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { authorization, ready } from "./sidecar-credentials"
const sidecar = { url: "http://127.0.0.1:4096", password: "secret" }
const expected = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
describe("sidecar authorization", () => {
test("adds the Basic credential only for the sidecar origin", () => {
expect(authorization(sidecar, "http://127.0.0.1:4096/api/session?limit=1")).toBe(expected)
expect(authorization(sidecar, "http://127.0.0.1:4097/api/session")).toBeUndefined()
expect(authorization(sidecar, "http://localhost:4096/api/session")).toBeUndefined()
expect(authorization(sidecar, "https://127.0.0.1:4096/api/session")).toBeUndefined()
})
test("hands the renderer the origin only", () => {
expect(ready(sidecar)).toEqual({ url: sidecar.url })
})
test("adds nothing before the sidecar is known or when it has no password", () => {
expect(authorization(undefined, "http://127.0.0.1:4096/api/session")).toBeUndefined()
expect(authorization({ url: sidecar.url, password: null }, "http://127.0.0.1:4096/api/session")).toBeUndefined()
expect(authorization(sidecar, "not a url")).toBeUndefined()
})
})
@@ -0,0 +1,30 @@
export * as SidecarCredentials from "./sidecar-credentials"
import type { ServerReadyData } from "../../shared/ipc-contract"
export type Data = ServerReadyData & { password: string | null }
// The renderer talks to the sidecar without an Authorization header; the main process adds it from
// here so GET requests stay CORS-simple and skip the preflight round trip. Both the initial connection
// and every reconnect publish the current endpoint.
let current: Data | undefined
export function set(data: Data) {
current = data
}
export function get() {
return current
}
/** What the renderer learns about the sidecar: its origin, never its credential. */
export function ready(data: Data): ServerReadyData {
return { url: data.url }
}
/** The Basic credential for a request to the sidecar origin, or undefined for any other URL. */
export function authorization(sidecar: Data | undefined, url: string) {
if (!sidecar?.password || !URL.canParse(url)) return
if (new URL(url).origin !== sidecar.url) return
return `Basic ${Buffer.from(`opencode:${sidecar.password}`).toString("base64")}`
}
+21 -1
View File
@@ -1,5 +1,6 @@
import type { BrowserWindow } from "electron"
import { addRendererHeaders } from "./headers"
import { SidecarCredentials } from "../service/sidecar-credentials"
import { addRendererHeaders, hasHeader, upsertHeader } from "./headers"
import { isRendererUrl } from "./protocol"
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
@@ -31,6 +32,25 @@ export function wireNavigationPolicy(win: BrowserWindow, openExternalURL: (url:
}
export function wireRendererHeaders(win: BrowserWindow) {
// The renderer sends sidecar requests without credentials, so its GETs are CORS-simple and need no
// preflight. Electron applies these listeners in Chromium's extraHeaders mode, after the CORS
// decision, so adding Authorization here does not reintroduce one.
//
// Only the renderer's own top-level frame is credentialed. Other content in this session (web views,
// embedded pages) can reach the same loopback origin and must not inherit its access. Requests with
// no frame, such as from a service worker, are not credentialed either; the renderer registers none.
win.webContents.session.webRequest.onBeforeSendHeaders(
{ urls: ["http://127.0.0.1/*", "http://localhost/*"] },
(details, callback) => {
const frame = details.frame
const renderer = !!frame && frame.parent === null && isRendererUrl(frame.url)
const authorization = renderer && SidecarCredentials.authorization(SidecarCredentials.get(), details.url)
if (authorization && !hasHeader(details.requestHeaders, "Authorization")) {
upsertHeader(details.requestHeaders, "Authorization", authorization)
}
callback({ requestHeaders: details.requestHeaders })
},
)
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
const responseHeaders = details.responseHeaders ?? {}
addRendererHeaders(responseHeaders, { document: isRendererUrl(details.url, true) })
@@ -49,12 +49,8 @@ export function MigrationStatus(props: { server: ServerReadyData }) {
await wait(1_000, abort.signal)
if (abort.signal.aborted) return
const client = OpenCode.make({
baseUrl: props.server.url,
headers: props.server.password
? { Authorization: `Basic ${btoa(`opencode:${props.server.password}`)}` }
: undefined,
})
// The main process credentials sidecar requests; see `wireRendererHeaders`.
const client = OpenCode.make({ baseUrl: props.server.url })
void (async () => {
while (true) {
@@ -27,7 +27,7 @@ describe("desktop renderer initialization", () => {
})
test("returns initialized sidecar data", () => {
const sidecar = { url: "http://127.0.0.1:1234", password: "secret" }
const sidecar = { url: "http://127.0.0.1:1234" }
expect(initializationData(Object.assign(() => sidecar, { error: undefined }))).toBe(sidecar)
})
@@ -47,7 +47,7 @@ describe("desktop renderer initialization", () => {
})
test("refreshes the managed sidecar endpoint", async () => {
const sidecar = { url: "http://127.0.0.1:4321", password: "next" }
const sidecar = { url: "http://127.0.0.1:4321" }
const updates: (typeof sidecar)[] = []
const resolve = createSidecarResolver({
api: { reconnectService: async () => sidecar },
@@ -60,7 +60,7 @@ describe("desktop renderer initialization", () => {
})
test("keeps the current sidecar when reconnection resolves the same endpoint", async () => {
const sidecar = { url: "http://127.0.0.1:4321", password: "same" }
const sidecar = { url: "http://127.0.0.1:4321" }
const updates: (typeof sidecar)[] = []
const resolve = createSidecarResolver({
api: { reconnectService: async () => ({ ...sidecar }) },
@@ -73,7 +73,7 @@ describe("desktop renderer initialization", () => {
})
test("does not publish a sidecar resolved after cancellation", async () => {
const sidecar = { url: "http://127.0.0.1:4321", password: "next" }
const sidecar = { url: "http://127.0.0.1:4321" }
const pending = Promise.withResolvers<typeof sidecar>()
const updates: (typeof sidecar)[] = []
const resolve = createSidecarResolver({
@@ -7,11 +7,10 @@ export function initializationData<A>(state: (() => A | undefined) & { error: un
return state()
}
// The main process adds Authorization to sidecar requests (`wireRendererHeaders`); the renderer never
// holds the password, and its GETs carry only CORS-safelisted headers so they skip the preflight.
export function sidecarHttp(data: SidecarData) {
return {
url: data.url,
password: data.password ?? undefined,
}
return { url: data.url }
}
export function createSidecarResolver(input: {
@@ -29,7 +28,7 @@ export function createSidecarResolver(input: {
}
function sameSidecar(current: SidecarData | undefined, next: SidecarData) {
return current?.url === next.url && current.password === next.password
return current?.url === next.url
}
function markLocalServerStartup(error: unknown) {
+1 -1
View File
@@ -1,6 +1,6 @@
// The sidecar password never crosses into the renderer; the main process adds it to sidecar requests.
export type ServerReadyData = {
url: string
password: string | null
}
export type TitlebarTheme = {
@@ -3,7 +3,6 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
const ServerReadyData = Schema.Struct({
url: Schema.String,
password: Schema.NullOr(Schema.String),
})
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
+11 -1
View File
@@ -10,6 +10,7 @@ import { Spinner } from "./spinner"
export function DialogUpdate(props: {
check?: (signal: AbortSignal) => Promise<string | undefined>
state: () => UpdateState | undefined
skip: () => void
install: () => Promise<void>
restart: () => void
}) {
@@ -47,7 +48,16 @@ export function DialogUpdate(props: {
: type === "installed"
? { label: "Restart", run: props.restart }
: undefined
return [{ label: "Skip", run: () => dialog.clear() }, ...(confirm ? [confirm] : [])]
return [
{
label: "Skip",
run: () => {
props.skip()
dialog.clear()
},
},
...(confirm ? [confirm] : []),
]
})
createEffect(() => setActive(Math.max(0, buttons().length - 1)))
@@ -95,14 +95,12 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider
// The notification can predate an installation through /update.
if (known && active?.type !== "installing" && !(active?.type === "installed" && active.version === known.version))
setState({ type: known.type, version: known.version })
// Manual checks hide the current notice without marking the version as seen.
if (origin === "manual") setNotification(undefined)
if (origin === "notification") dismiss()
const status = state()?.type
dialog.replace(() => (
<DialogUpdate
check={status === undefined || status === "failed" ? check : undefined}
state={state}
skip={dismiss}
install={install}
restart={restart}
/>