Compare commits

..
Author SHA1 Message Date
Kit Langton ff09490bcc fix(core): retain locations while sessions are executing 2026-09-06 10:14:33 -04:00
Luke Parker b2cecc6350 fix(desktop): add sidecar credentials from the main process (#47588) 2026-09-06 07:18:12 +00:00
Luke Parker 63a1074c6c fix(app): keep slow git reads from filling the request queue (#47564) 2026-09-06 17:02:03 +10:00
Luke Parker 31ee07e3ae fix(app): pace directory re-sync after reconnect (#47565) 2026-09-06 17:01:24 +10:00
Aarav Sareen 370b9965d3 feat(app): cmd+F to search session with highlighting 2026-09-06 16:47:20 +10:00
Dax Raad 8ea99ef9ad fix(tui): dismiss update notification only on skip 2026-09-06 02:43:21 -04:00
Aarav Sareen e64b2bc137 feat(app): add horizontal file sidebar scrolling + right click menu 2026-09-06 16:35:14 +10:00
opencode-agent[bot]andrekram1-node cf1923c238 fix(core): disable default GPT and Claude search filtering (#47586)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-06 01:22:48 -05:00
Luke Parker bfcb388dd7 feat(app): pulse the status dot while the event stream reconnects (#47574) 2026-09-06 15:13:38 +10:00
usrnk1 ec46439ef5 feat(desktop): simplify move to background action 2026-09-06 14:45:16 +10:00
Luke Parker 1be3b32a47 fix(client): detect stalled event streams and resync on foreground (#47571) 2026-09-06 04:43:50 +00:00
Aiden Cline 2823b886d7 feat(core): add independent GPT and Claude tool optimization (#47559) 2026-09-05 23:43:23 -05:00
Luke Parker 99651b2d50 fix(app): refresh queued inputs when the connection returns (#47573) 2026-09-06 04:42:27 +00:00
usrnk1 33ef66746b feat(desktop): add third-party web search consent 2026-09-06 14:38:55 +10:00
Luke Parker cf212a4235 fix(app): time out requests the server never answers (#47572) 2026-09-06 14:28:43 +10:00
89 changed files with 3308 additions and 913 deletions
@@ -0,0 +1,79 @@
import { expect, story } from "../../storybook/playwright/story"
story("enables Any only after confirmation and supports resetting the preview", async ({ mount }) => {
const component = await mount("app-current-session-surface--web-search-request")
const card = component.getByRole("region", { name: "Third-party web search" })
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
await expect(card.getByRole("button", { name: "Search provider Any", exact: true })).toBeVisible()
await card.getByRole("button", { name: "Enable", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): random")
await expect(card).toHaveCount(0)
await component.getByRole("button", { name: "Reset", exact: true }).click()
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
})
story("declining search is an explicit disabled selection", async ({ mount }) => {
const component = await mount("app-current-session-surface--web-search-request")
const card = component.getByRole("region", { name: "Third-party web search" })
await card.getByRole("button", { name: "Dont use search", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): false")
await expect(card).toHaveCount(0)
})
story("sizes provider options to their content", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--web-search-request")
await component.getByRole("button", { name: "Search provider Any", exact: true }).click()
const menu = page.getByRole("listbox", { name: "Search provider", exact: true })
await expect(menu).toBeVisible()
const metrics = await menu.evaluate((listbox) => {
const items = Array.from(listbox.querySelectorAll('[data-component="menu-v2-item"]'))
const longest = items
.flatMap((item) => {
const label = item.querySelector('[data-slot="menu-v2-item-content"]')?.getBoundingClientRect()
const check = item.querySelector('[data-slot="menu-v2-item-indicator"]')?.getBoundingClientRect()
return label && check ? [{ label, check }] : []
})
.toSorted((a, b) => b.label.width - a.label.width)[0]
return {
width: listbox.getBoundingClientRect().width,
gap: longest ? longest.check.left - longest.label.right : 0,
}
})
expect(metrics.width).toBeLessThan(160)
expect(metrics.gap).toBe(24)
})
for (const width of [360, 1200]) {
for (const direction of ["ltr", "rtl"]) {
for (const theme of ["light", "dark"]) {
story(`selects and confirms a provider at ${width}px in ${direction} ${theme}`, async ({ mount, page }) => {
await page.setViewportSize({ width, height: 900 })
const component = await mount("app-current-session-surface--web-search-request", {
globals: { theme, direction },
})
const card = component.getByRole("region", { name: "Third-party web search" })
const select = card.getByRole("button", { name: /^Search provider/ })
await expect(select).toBeEnabled()
await expect(card).toHaveCSS("direction", direction)
expect(await card.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true)
await select.focus()
await select.press("Enter")
const list = page.getByRole("listbox", { name: "Search provider", exact: true })
await expect(list).toHaveCSS("direction", direction)
await list.getByRole("option", { name: "Parallel", exact: true }).click()
await expect(select).toHaveText("Parallel")
await expect(card).toBeVisible()
await expect(component.getByRole("status")).toHaveText("Ready")
await expect(select).toBeFocused()
await select.press("Tab")
await expect(card.getByRole("button", { name: "Dont use search", exact: true })).toBeFocused()
await page.keyboard.press("Tab")
const enable = card.getByRole("button", { name: "Enable", exact: true })
await expect(enable).toBeFocused()
await enable.press("Enter")
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): parallel")
await expect(card).toHaveCount(0)
})
}
}
}
+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,
}
}
@@ -7,11 +7,13 @@ const directory = "C:/OpenCode/OpenFileExpand"
const projectID = "proj_open_file_expand"
const sessionID = "ses_open_file_expand"
const title = "Open file expand"
const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts"
const longPath = `frontend/${longFilename}`
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
test("expands Windows paths and horizontally scrolls long filenames", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -44,7 +46,17 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [],
vcsDiff: [
{
file: longPath,
before: "",
after: "export const added = true\n",
additions: 1,
deletions: 0,
status: "added",
patch: "@@ -0,0 +1 @@\n+export const added = true\n",
},
],
fileList: (path) => {
if (path === "frontend\\" || path === "frontend") {
return [
@@ -55,6 +67,13 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
type: "file" as const,
ignored: false,
},
{
name: longFilename,
path: `frontend\\${longFilename}`,
absolute: `${directory}/${longPath}`,
type: "file" as const,
ignored: false,
},
]
}
if (path) return []
@@ -75,6 +94,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
},
]
},
findFiles: ({ query }) => (longPath.includes(query) ? [longPath] : []),
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
@@ -119,6 +139,93 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
const viewport = sidebar.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
const longRow = panel.getByRole("button", { name: longFilename })
await expect(longRow).toBeVisible()
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
expect(
await longRow.evaluate((element) => getComputedStyle(element.querySelector("bdi")!.parentElement!).textOverflow),
).toBe("clip")
expect(await longRow.evaluate((element) => element.getBoundingClientRect().width)).toBeGreaterThanOrEqual(
await viewport.evaluate((element) => element.clientWidth),
)
await expect
.poll(() =>
panel.locator('[data-slot="file-tree-v2-row"]').evaluateAll((rows) => {
const widths = rows.map((row) => row.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
.toBeLessThanOrEqual(0.5)
await expect(longRow.locator('[data-slot="file-tree-v2-label"]')).toHaveCSS("margin-inline-end", "12px")
const status = longRow.locator('[data-slot="file-tree-v2-change"]')
await expect(status).toHaveText("A")
const statusBox = await status.boundingBox()
if (!statusBox) throw new Error("File status has no bounding box")
const viewportBox = await viewport.boundingBox()
if (!viewportBox) throw new Error("File tree viewport has no bounding box")
expect(viewportBox.x + viewportBox.width - statusBox.x - statusBox.width).toBeLessThanOrEqual(24)
await viewport.hover()
const horizontalThumb = sidebar.locator('.scroll-view__thumb[data-orientation="horizontal"]')
await expect(horizontalThumb).toHaveCSS("opacity", "1")
await page.mouse.wheel(1_000, 0)
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
await expect(horizontalThumb).toHaveAttribute("data-visible", "true")
await expect
.poll(() =>
status.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return viewport.right - element.getBoundingClientRect().right
}),
)
.toBeLessThanOrEqual(24)
const beforeDrag = await viewport.evaluate((element) => Math.abs(element.scrollLeft))
const thumbBox = await horizontalThumb.boundingBox()
if (!thumbBox) throw new Error("Horizontal scrollbar thumb has no bounding box")
await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2)
await page.mouse.down()
await page.mouse.move(thumbBox.x + thumbBox.width / 2 - 40, thumbBox.y + thumbBox.height / 2)
await page.mouse.up()
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeLessThan(beforeDrag)
const filter = panel.getByRole("combobox", { name: "Filter files" })
await filter.fill(longFilename)
const filteredRow = panel.getByRole("option", { name: longFilename })
await expect(filteredRow).toBeVisible()
const filteredStatus = filteredRow.locator('[data-slot="file-tree-v2-change"]')
await expect(filteredStatus).toHaveText("A")
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
await viewport.evaluate((element) => {
element.setAttribute("dir", "rtl")
element.scrollLeft = 0
element.dispatchEvent(new Event("scroll"))
})
await expect
.poll(() =>
filteredStatus.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return element.getBoundingClientRect().left - viewport.left
}),
)
.toBeLessThanOrEqual(24)
const rtlThumbBox = await horizontalThumb.boundingBox()
if (!rtlThumbBox) throw new Error("RTL horizontal scrollbar thumb has no bounding box")
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2, rtlThumbBox.y + rtlThumbBox.height / 2)
await page.mouse.down()
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2 - 40, rtlThumbBox.y + rtlThumbBox.height / 2)
await page.mouse.up()
await expect.poll(() => viewport.evaluate((element) => element.scrollLeft)).toBeLessThan(0)
await viewport.evaluate((element) => {
element.removeAttribute("dir")
element.scrollLeft = 0
element.dispatchEvent(new Event("scroll"))
})
await filter.fill("")
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
await expect(appRow).toBeVisible()
await appRow.click()
+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;
+13 -1
View File
@@ -676,7 +676,7 @@ export const dict = {
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.moveRunning": "Move running work to background",
"session.background.moveRunning": "Move to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
@@ -736,6 +736,16 @@ export const dict = {
"session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions",
"session.websearch.title": "Third-party web search",
"session.websearch.description": "Select the search provider agents use to search the web",
"session.websearch.provider": "Search provider",
"session.websearch.any": "Any",
"session.websearch.disable": "Dont use search",
"session.websearch.enable": "Enable",
"session.websearch.loadFailed": "Could not load search providers.",
"session.websearch.empty": "No search providers available.",
"session.websearch.failed": "Could not save your choice. Please try again.",
"session.websearch.retry": "Retry",
"session.question.minimize": "Minimize question",
"session.question.restore": "Restore question",
"session.question.pending.one": "{{count}} pending question",
@@ -759,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,19 +1,23 @@
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 }) {
const pending: Array<{ url: string; resolve: () => void }> = []
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,
log: (message, data) => logs.push({ message, data }),
fetch: Object.assign(
(resource: RequestInfo | URL) =>
new Promise<Response>((resolve) => {
pending.push({ url: new Request(resource).url, resolve: () => resolve(new Response("ok")) })
new Promise<Response>((resolve, reject) => {
const request = new Request(resource)
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
pending.push({ url: request.url, signal: request.signal, resolve: () => resolve(new Response("ok")) })
}),
{ preconnect() {} },
),
@@ -37,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")
@@ -59,6 +93,34 @@ describe("createRequestQueue", () => {
expect(input.queue.inflight()).toBe(0)
})
test("a request the server never answers times out and frees its slot", async () => {
const input = setup({ limit: 1, headersTimeoutMs: 10 })
const dead = input.queue.fetch("http://server/api/dead")
const next = input.queue.fetch("http://server/api/next")
await input.settle()
expect(input.queue.queued()).toBe(1)
const error = await dead.catch((cause: unknown) => cause)
expect(error).toBeInstanceOf(DOMException)
expect((error as DOMException).name).toBe("TimeoutError")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/dead", "/api/next"])
input.pending[1]!.resolve()
await expect(next).resolves.toBeInstanceOf(Response)
expect(input.queue.inflight()).toBe(0)
})
test("caller aborts still reach the underlying request", async () => {
const input = setup({ limit: 1 })
const controller = new AbortController()
const request = input.queue.fetch("http://server/api/slow", { signal: controller.signal })
await input.settle()
expect(input.pending[0]!.signal.aborted).toBe(false)
controller.abort()
expect(input.pending[0]!.signal.aborted).toBe(true)
await expect(request).rejects.toBeInstanceOf(DOMException)
expect(input.queue.inflight()).toBe(0)
})
test("a burst that drains promptly is not thrashing", async () => {
const input = setup({ stallMs: 5 })
const responses = Array.from({ length: 12 }, (_, index) => input.queue.fetch(`http://server/api/${index}`))
@@ -1,23 +1,41 @@
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
// A socket that dies while the device sleeps can leave fetch waiting for response headers until the
// OS gives up on TCP retransmits, which takes minutes. Bound that so a dead request frees its slot
// 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.
const base = input.fetch
const now = input.now ?? Date.now
@@ -43,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) => {
@@ -54,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)
})
@@ -62,15 +88,25 @@ 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)
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
}
return base(request).finally(() => release(entry))
const controller = new AbortController()
request.signal.addEventListener("abort", () => controller.abort(request.signal.reason), { once: true })
const timer = setTimeout(
() => controller.abort(new DOMException("Timed out waiting for the server to respond", "TimeoutError")),
headersTimeoutMs,
)
return base(new Request(request, { signal: controller.signal })).finally(() => {
clearTimeout(timer)
release(entry)
})
},
// Bun's fetch type carries preconnect; the browser never calls it.
{ preconnect: () => {} },
@@ -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),
)
},
})
@@ -2,11 +2,12 @@ import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { SessionPermissionDock } from "@/session/requests/session-permission-dock"
import { SessionQuestionDock } from "@/session/requests/session-question-dock"
import { SessionWebSearchDock } from "@/session/requests/session-websearch-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
type SessionComposerRegionState = Pick<
SessionComposerRegionController["state"],
"questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
"questionRequest" | "websearch" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
>
export type SessionComposerRegionViewController = Pick<
@@ -32,6 +33,9 @@ export function SessionComposerRegion(props: {
"md:max-w-[1000px] md:mx-auto": controller.centered(),
}}
>
<Show when={controller.state.websearch.request()}>
<SessionWebSearchDock model={controller.state.websearch} onSubmit={controller.onResponseSubmit} />
</Show>
<Show when={controller.state.questionRequest()} keyed>
{(request) => (
<div>
+62 -23
View File
@@ -24,6 +24,11 @@ import {
type FileTreeV2Node,
} from "@/session/files/file-tree-v2-model"
import { virtualScrollElement } from "@/session/files/virtual-scroll"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useOpenInApp } from "@/session/files/open-in-app"
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
import { usePlatform } from "@/runtime/platform/platform"
export type { Kind } from "@/session/files/file-tree"
@@ -99,7 +104,7 @@ const FileTreeNodeV2 = (
{...rest}
>
{local.children}
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
<span data-slot="file-tree-v2-label" class="flex-1 shrink-0 text-start text-12-medium whitespace-nowrap">
<bdi dir="auto">
{local.node.type === "directory"
? normalizeFileTreeV2Path(local.node.path).split("/").at(-1)
@@ -136,6 +141,9 @@ export default function FileTreeV2(props: {
onFileDoubleClick?: (file: FileNode) => void
}) {
const file = useFile()
const location = useWorkspaceLocation()
const platform = usePlatform()
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
const live = () => props.allowed === undefined
const draggable = () => props.draggable ?? true
const active = () => normalizeFileTreeV2Path(props.active ?? "")
@@ -217,6 +225,19 @@ export default function FileTreeV2(props: {
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
createEffect(() => {
rows()
const element = root()
if (!element) return
element.style.removeProperty("width")
syncFileTreeV2Width(element)
})
createEffect(() => {
virtualRowKeys()
syncFileTreeV2Width(root())
})
return (
<div
ref={setRoot}
@@ -235,6 +256,7 @@ export default function FileTreeV2(props: {
top: "0",
"inset-inline-start": "0",
width: "100%",
"min-width": "max-content",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
@@ -244,29 +266,36 @@ export default function FileTreeV2(props: {
<Show
when={row().node.type === "directory"}
fallback={
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
<OpenInAppContextMenuV2
state={openIn}
path={() =>
resolveOpenInAppPath(location().directory, row().node.absolute || row().node.originalPath)
}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
</OpenInAppContextMenuV2>
}
>
<FileTreeNodeV2
@@ -303,3 +332,13 @@ export default function FileTreeV2(props: {
</div>
)
}
export function syncFileTreeV2Width(element?: HTMLDivElement) {
if (!element) return
queueMicrotask(() => {
if (!element.isConnected) return
const width = Math.max(element.clientWidth, ...Array.from(element.children, (child) => child.scrollWidth))
if (width <= element.clientWidth) return
element.style.width = `${width}px`
})
}
+56 -34
View File
@@ -2,10 +2,15 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/file-tree.css"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { kindChange, kindLabel, type Kind } from "@/session/files/file-tree-v2"
import { kindChange, kindLabel, syncFileTreeV2Width, type Kind } from "@/session/files/file-tree-v2"
import { normalizePath } from "@/session/review/review-diff-kinds"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { virtualScrollElement } from "@/session/files/virtual-scroll"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useOpenInApp } from "@/session/files/open-in-app"
import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button"
import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
import { usePlatform } from "@/runtime/platform/platform"
// Drives the highlight/selection of the flat search-result list from the filter
// input's keyboard events.
@@ -49,6 +54,9 @@ export function SessionFileList(props: {
onFileClick: (path: string) => void
onFileDoubleClick?: (path: string) => void
}) {
const location = useWorkspaceLocation()
const platform = usePlatform()
const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined
const active = () => normalizePath(props.active ?? "")
const highlighted = () => normalizePath(props.highlighted ?? "")
const normalized = createMemo(() => props.files.map(normalizePath))
@@ -89,6 +97,19 @@ export function SessionFileList(props: {
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
createEffect(() => {
normalized()
const element = root()
if (!element) return
element.style.removeProperty("width")
syncFileTreeV2Width(element)
})
createEffect(() => {
virtualRowKeys()
syncFileTreeV2Width(root())
})
return (
<div
ref={setRoot}
@@ -114,47 +135,48 @@ export function SessionFileList(props: {
style={{
position: "absolute",
top: "0",
left: "0",
"inset-inline-start": "0",
width: "100%",
"min-width": "max-content",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-left: 8px"
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
<Show when={directory()}>
<OpenInAppContextMenuV2 state={openIn} path={() => resolveOpenInAppPath(location().directory, path)}>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-inline-start: 8px"
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span data-slot="file-tree-v2-label" class="flex flex-1 shrink-0 items-center whitespace-nowrap">
<Show when={directory()}>
{(value) => <span class="text-12-medium text-text-muted shrink-0">{value()}</span>}
</Show>
<span class="text-12-medium text-text-base shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
</button>
</button>
</OpenInAppContextMenuV2>
</div>
)}
</Show>
@@ -1,4 +1,4 @@
import { For, Show } from "solid-js"
import { createSignal, For, Show, type ParentProps } from "solid-js"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -10,7 +10,7 @@ import { type OpenApp, useOpenInApp } from "@/session/files/open-in-app"
export function OpenInAppButton(props: { directory: () => string }) {
const language = useLanguage()
const state = useOpenInApp(props)
const state = useOpenInApp({ path: props.directory })
return (
<Show when={props.directory() && state.canOpen()}>
@@ -25,7 +25,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
onClick={(event) => {
event.stopPropagation()
if (state.opening()) return
state.openDir(state.current().id)
state.openPath(state.current().id)
}}
disabled={state.opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
@@ -52,42 +52,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="open-in-app-v2-menu">
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Menu.RadioGroup
value={state.current().id}
onChange={(value) => {
state.selectApp(value as OpenApp)
}}
>
<For each={state.options()}>
{(option) => (
<Menu.RadioItem
value={option.id}
disabled={state.opening()}
onSelect={() => {
state.selectApp(option.id)
state.setMenu("open", false)
state.openDir(option.id)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
state.setMenu("open", false)
state.copyPath()
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</Menu.Item>
<OpenInAppMenuItemsV2 state={state} close={() => state.setMenu("open", false)} />
</Menu.Content>
</Menu.Portal>
</Menu>
@@ -95,3 +60,116 @@ export function OpenInAppButton(props: { directory: () => string }) {
</Show>
)
}
type OpenInAppState = ReturnType<typeof useOpenInApp>
function OpenInAppMenuItemsV2(props: {
state: OpenInAppState
path?: () => string
reveal?: boolean
selection?: boolean
close?: () => void
}) {
const language = useLanguage()
const path = () => props.path?.()
return (
<>
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Show
when={props.selection !== false}
fallback={
<For each={props.state.options()}>
{(option) => (
<Menu.Item
disabled={props.state.opening()}
onSelect={() => {
props.state.selectApp(option.id)
props.close?.()
props.state.openPath(option.id, path(), props.reveal)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.Item>
)}
</For>
}
>
<Menu.RadioGroup
value={props.state.current().id}
onChange={(value) => {
props.state.selectApp(value as OpenApp)
}}
>
<For each={props.state.options()}>
{(option) => (
<Menu.RadioItem
value={option.id}
closeOnSelect
disabled={props.state.opening()}
onSelect={() => {
props.state.selectApp(option.id)
props.close?.()
props.state.openPath(option.id, path(), props.reveal)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Show>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
props.close?.()
props.state.copyPath(path())
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</Menu.Item>
</>
)
}
export function OpenInAppContextMenuV2(
props: ParentProps<{
state?: OpenInAppState
path: () => string
}>,
) {
const state = props.state
if (!state) return props.children
const [open, setOpen] = createSignal(false)
return (
<Show when={state.canOpen() && props.path()} fallback={props.children}>
<Menu.Context modal={false} onOpenChange={setOpen}>
<Menu.Context.Trigger
as="div"
class="h-full w-full min-w-max"
data-slot="file-tree-v2-context-trigger"
data-context-menu-open={open() ? "" : undefined}
>
{props.children}
</Menu.Context.Trigger>
<Menu.Context.Portal>
<Menu.Context.Content class="open-in-app-v2-menu">
<OpenInAppMenuItemsV2
state={state}
path={props.path}
reveal
selection={false}
close={() => setOpen(false)}
/>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
</Show>
)
}
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { openInAppParentPath, resolveOpenInAppPath } from "./open-in-app-path"
describe("resolveOpenInAppPath", () => {
test("joins relative paths using the workspace separator", () => {
expect(resolveOpenInAppPath("/workspace/project", "src/file.ts")).toBe("/workspace/project/src/file.ts")
expect(resolveOpenInAppPath("C:\\workspace\\project", "src/file.ts")).toBe("C:\\workspace\\project\\src\\file.ts")
})
test("does not duplicate root separators", () => {
expect(resolveOpenInAppPath("/workspace/project/", "src/file.ts")).toBe("/workspace/project/src/file.ts")
expect(resolveOpenInAppPath("C:/workspace/project/", "src\\file.ts")).toBe("C:/workspace/project/src/file.ts")
})
test("preserves backslashes in POSIX filenames", () => {
expect(resolveOpenInAppPath("/workspace", "src\\file.ts")).toBe("/workspace/src\\file.ts")
expect(resolveOpenInAppPath("/workspace", "\\file.ts")).toBe("/workspace/\\file.ts")
})
test("preserves absolute POSIX, Windows, and UNC paths", () => {
expect(resolveOpenInAppPath("/workspace", "/tmp/file.ts")).toBe("/tmp/file.ts")
expect(resolveOpenInAppPath("C:/workspace", "D:\\src\\file.ts")).toBe("D:\\src\\file.ts")
expect(resolveOpenInAppPath("C:/workspace", "\\\\server\\share\\file.ts")).toBe("\\\\server\\share\\file.ts")
expect(resolveOpenInAppPath("C:/workspace", "\\src\\file.ts")).toBe("\\src\\file.ts")
})
})
describe("openInAppParentPath", () => {
test("preserves POSIX and Windows roots", () => {
expect(openInAppParentPath("/file.ts")).toBe("/")
expect(openInAppParentPath("/workspace/file.ts")).toBe("/workspace")
expect(openInAppParentPath("C:\\file.ts")).toBe("C:\\")
expect(openInAppParentPath("C:\\workspace\\file.ts")).toBe("C:\\workspace")
expect(openInAppParentPath("\\\\server\\share\\file.ts")).toBe("\\\\server\\share")
})
})
@@ -0,0 +1,19 @@
export function resolveOpenInAppPath(root: string, path: string) {
if (!path) return root
const windowsRoot = root.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(root)
if (path.startsWith("/") || (windowsRoot && path.startsWith("\\")) || /^[A-Za-z]:[\\/]/.test(path)) return path
if (!root) return path
const separator = root.includes("\\") ? "\\" : "/"
const relative = windowsRoot ? path.replace(/^[\\/]+/, "") : path
return `${root.replace(/[\\/]+$/, "")}${separator}${windowsRoot ? relative.replaceAll(separator === "\\" ? "/" : "\\", separator) : relative}`
}
export function openInAppParentPath(path: string) {
const value = path.replace(/[\\/]+$/, "")
const index = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"))
if (index < 0) return path
if (index === 0) return value.slice(0, 1)
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
return value.slice(0, index)
}
+30 -21
View File
@@ -7,6 +7,8 @@ import { showToast } from "@/shell/notifications/toast"
import { useServer } from "@/runtime/server/current"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { fileManagerApp } from "@/home/projects/file-manager"
import { openInAppParentPath } from "@/session/files/open-in-app-path"
export const OPEN_APPS = [
"vscode",
@@ -32,6 +34,8 @@ export const OpenAppPreferences = Persistence.struct({
app: Schema.Literals(OPEN_APPS),
})
const appExistence = new Map<string, Promise<boolean>>()
export const MAC_OPEN_APPS = [
{
id: "vscode",
@@ -108,9 +112,7 @@ export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenA
}
export function openAppFileManager(os: OpenAppOS) {
if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
return fileManagerApp(os)
}
export function openAppsForOS(os: OpenAppOS) {
@@ -127,7 +129,7 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
})
}
export function useOpenInApp(input: { directory: () => string }) {
export function useOpenInApp(input: { path: () => string }) {
const platform = usePlatform()
const server = useServer()
const language = useLanguage()
@@ -149,12 +151,7 @@ export function useOpenInApp(input: { directory: () => string }) {
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
list.map((app) => checkAppExists(platform, app.openWith).then((ok) => [app.id, ok] as const)),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
@@ -189,33 +186,35 @@ export function useOpenInApp(input: { directory: () => string }) {
setPrefs("app", app)
}
const openDir = (app: OpenApp | "finder") => {
const openPath = (app: OpenApp | "finder", target = input.path(), reveal = false) => {
if (opening() || !canOpen() || !platform.openPath) return
const directory = input.directory()
if (!directory) return
if (!target) return
const open = (path: string, openWith?: string) => platform.openPath!(path, openWith)
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
platform
.openPath(directory, openWith)
const request =
app === "finder" && reveal && platform.revealPath
? platform.revealPath(target).then((revealed) => (revealed ? undefined : open(openInAppParentPath(target))))
: open(target, openWith)
request
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = () => {
const directory = input.directory()
if (!directory) return
const copyPath = (target = input.path()) => {
if (!target) return
navigator.clipboard
.writeText(directory)
.writeText(target)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("common.copied"),
description: directory,
description: target,
})
})
.catch((err: unknown) => showRequestError(language, err))
@@ -228,8 +227,18 @@ export function useOpenInApp(input: { directory: () => string }) {
options,
menu,
setMenu,
openDir,
openPath,
selectApp,
copyPath,
}
}
function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
const cached = appExistence.get(app)
if (cached) return cached
const request = Promise.resolve(platform.checkAppExists?.(app))
.then(Boolean)
.catch(() => false)
appExistence.set(app, request)
return request
}
+35 -5
View File
@@ -7,7 +7,8 @@ import { useServerSDK } from "@/runtime/server/client"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
import { createWebSearchRequest } from "./websearch"
import { createSessionBackground } from "@/session/requests/background"
import { useData } from "@/runtime/server/current"
@@ -24,12 +25,40 @@ export function createSessionRequestModel() {
void Promise.all([
data.shell.sync({ directory: sdk().directory }),
data.session.permission.sync(id),
data.session.form.sync(id),
]).catch(() => undefined)
})
createEffect(() => {
const id = params.id
if (!id || serverSDK.connection.status() !== "connected") return
void Promise.all(
sessionTreeIDs(data.session.list(), id).map((sessionID) => data.session.form.sync(sessionID)),
).catch(() => undefined)
})
const questionRequest = createMemo((): FormInfo | undefined => {
return sessionQuestionForm(data.session.list(), data.session.form.list, params.id)
const formRequest = createMemo((): FormInfo | undefined => {
return sessionFormRequest(data.session.list(), data.session.form.list, params.id)
})
const websearch = createWebSearchRequest({
owner: () => params.id,
connected: () => serverSDK.connection.status() === "connected",
request: () => {
const form = formRequest()
return form?.metadata?.kind === "websearch.provider" ? form : undefined
},
providers: async (sessionID) => {
const session = data.session.get(sessionID) ?? (await serverSDK.api.session.get({ sessionID }))
const result = await serverSDK.api.websearch.providers({
location: { directory: session.location.directory, workspace: session.location.workspaceID },
})
return result.data.map((provider) => ({ value: provider.id, label: provider.name }))
},
reply: (input) => data.session.form.reply(input),
events: serverSDK.event,
})
const questionRequest = createMemo(() => {
if (websearch.request()) return
const form = formRequest()
return form?.metadata?.kind === "question" ? form : undefined
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
@@ -40,7 +69,7 @@ export function createSessionRequestModel() {
const blocked = createMemo(() => {
const id = params.id
if (!id) return false
return !!permissionRequest() || !!questionRequest()
return !!permissionRequest() || !!questionRequest() || !!websearch.request()
})
const primary = () => {
@@ -96,6 +125,7 @@ export function createSessionRequestModel() {
return {
blocked,
questionRequest,
websearch,
permissionRequest,
permissionResponding,
background: {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -23,6 +23,22 @@ const question = (id: string, sessionID: string) =>
fields: [{ key: "q0", type: "string" }],
}) as FormInfo
describe("sessionTreeIDs", () => {
test("returns only the current session and its descendants", () => {
const sessions = [
session({ id: "root" }),
session({ id: "child", parentID: "root" }),
session({ id: "grand", parentID: "child" }),
session({ id: "sibling", parentID: "root" }),
session({ id: "other" }),
]
expect(sessionTreeIDs(sessions, "child")).toEqual(["child", "grand"])
expect(sessionTreeIDs(sessions, "root")).toEqual(["root", "child", "sibling", "grand"])
expect(sessionTreeIDs(sessions)).toEqual([])
})
})
describe("sessionPermissionRequest", () => {
test("prefers the current session permission", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
@@ -81,7 +97,7 @@ describe("sessionPermissionRequest", () => {
})
})
describe("sessionQuestionForm", () => {
describe("sessionFormRequest", () => {
test("prefers the current session question", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const questions = {
@@ -89,7 +105,7 @@ describe("sessionQuestionForm", () => {
child: [question("q-child", "child")],
}
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-root")
expect(sessionFormRequest(sessions, questions, "root")?.id).toBe("q-root")
})
test("returns a nested child question", () => {
@@ -102,15 +118,29 @@ describe("sessionQuestionForm", () => {
grand: [question("q-grand", "grand")],
}
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-grand")
expect(sessionFormRequest(sessions, questions, "root")?.id).toBe("q-grand")
})
test("skips forms that are not questions", () => {
test("skips unsupported forms", () => {
const sessions = [session({ id: "root" })]
const forms = {
root: [{ ...question("form", "root"), metadata: { kind: "integration" } }],
}
expect(sessionQuestionForm(sessions, forms, "root")).toBeUndefined()
expect(sessionFormRequest(sessions, forms, "root")).toBeUndefined()
})
test("finds web search consent in a nested child session", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const form = { ...question("search", "child"), metadata: { kind: "websearch.provider" } }
expect(sessionFormRequest(sessions, { child: [form] }, "root")).toBe(form)
})
test("preserves request order across questions and web search", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const form = { ...question("search", "root"), metadata: { kind: "websearch.provider" } }
expect(sessionFormRequest(sessions, { root: [form, question("q", "root")] }, "root")).toBe(form)
expect(sessionFormRequest(sessions, { root: [question("q", "root"), form] }, "root")?.id).toBe("q")
expect(sessionFormRequest(sessions, { root: [form], child: [question("q", "child")] }, "root")).toBe(form)
})
})
@@ -6,8 +6,16 @@ function sessionTreeRequest<T>(
sessionID?: string,
include: (item: T) => boolean = () => true,
) {
if (!sessionID) return
const ids = sessionTreeIDs(session, sessionID)
if (!ids.length) return
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
const id = ids.find((id) => list(id)?.some(include))
if (!id) return
return list(id)?.find(include)
}
export function sessionTreeIDs(session: SessionInfo[], sessionID?: string) {
if (!sessionID) return []
const map = session.reduce((acc, item) => {
if (!item.parentID) return acc
const list = acc.get(item.parentID)
@@ -27,11 +35,7 @@ function sessionTreeRequest<T>(
ids.push(child)
}
}
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
const id = ids.find((id) => list(id)?.some(include))
if (!id) return
return list(id)?.find(include)
return ids
}
export function sessionPermissionRequest(
@@ -43,10 +47,15 @@ export function sessionPermissionRequest(
return sessionTreeRequest(session, request, sessionID, include)
}
export function sessionQuestionForm(
export function sessionFormRequest(
session: SessionInfo[],
request: Record<string, FormInfo[] | undefined> | ((sessionID: string) => FormInfo[] | undefined),
sessionID?: string,
) {
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
return sessionTreeRequest(
session,
request,
sessionID,
(item) => item.metadata?.kind === "question" || item.metadata?.kind === "websearch.provider",
)
}
@@ -0,0 +1,58 @@
[data-component="session-websearch-dock"] {
width: 100%;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 12px;
.websearch-body {
box-shadow: var(--v2-elevation-raised);
}
.websearch-setting {
padding: 16px;
}
.websearch-setting [data-component="settings-row"] {
column-gap: 24px;
padding-block: 0;
border: 0;
}
.websearch-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
padding: 24px 16px 12px;
border: 0;
}
.websearch-status {
display: flex;
align-items: center;
gap: 8px;
margin-inline-end: auto;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
.websearch-status:empty {
display: none;
}
.websearch-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
}
}
[data-component="menu-v2-content"][data-slot="select-v2-content"].websearch-provider-menu {
width: max-content;
min-width: 0;
[data-component="menu-v2-item"] {
gap: 24px;
}
}
@@ -0,0 +1,86 @@
import { createMemo, Show } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface"
import { Select } from "@opencode-ai/ui/select"
import { useLanguage } from "@/runtime/i18n/language"
import { SettingsRow } from "@/settings/row"
import type { WebSearchRequestModel } from "./websearch"
import "./session-websearch-dock.css"
export function SessionWebSearchDock(props: { model: WebSearchRequestModel; onSubmit: () => void }) {
const language = useLanguage()
const options = createMemo(() => [
...(props.model.specific() ? [] : [{ value: "random", label: language.t("session.websearch.any") }]),
...props.model.options(),
])
const current = createMemo(() => options().find((option) => option.value === props.model.selected()))
const busy = () => props.model.sending() || !props.model.connected()
const status = () => {
if (props.model.loading()) return language.t("common.loading")
if (props.model.failed()) return language.t("session.websearch.failed")
if (!props.model.options().length) return language.t("session.websearch.empty")
}
const unavailable = () => props.model.loading() || props.model.loadFailed() || !props.model.options().length
const submit = (selection: string | false) => {
if (busy()) return
props.onSubmit()
void props.model.submit(selection)
}
return (
<section
data-component="session-websearch-dock"
aria-label={language.t("session.websearch.title")}
aria-busy={props.model.sending()}
>
<DockShell class="websearch-body">
<div class="websearch-setting">
<SettingsRow
title={language.t("session.websearch.title")}
description={language.t("session.websearch.description")}
>
<Select
aria-label={language.t("session.websearch.provider")}
options={options()}
current={current()}
value={(option) => option.value}
label={(option) => option.label}
onSelect={(option) => option && props.model.select(option.value)}
disabled={busy() || unavailable()}
placeholder={language.t("session.websearch.provider")}
contentClass="websearch-provider-menu"
/>
</SettingsRow>
</div>
</DockShell>
<DockTray attach="top" class="websearch-footer">
<div class="websearch-status" aria-live="polite">
<Show when={props.model.loadFailed()} fallback={status()}>
<span>{language.t("session.websearch.loadFailed")}</span>
<Button variant="ghost" size="small" onClick={props.model.retry} disabled={busy()}>
{language.t("session.websearch.retry")}
</Button>
</Show>
</div>
<div class="websearch-actions">
<Show when={!props.model.specific()}>
<Button variant="ghost" size="small" onClick={() => submit(false)} disabled={busy()}>
{language.t("session.websearch.disable")}
</Button>
</Show>
<Button
variant="neutral"
size="small"
onClick={() => {
const selected = props.model.selected()
if (selected) submit(selected)
}}
disabled={busy() || unavailable() || !current()}
>
{language.t("session.websearch.enable")}
</Button>
</div>
</DockTray>
</section>
)
}
@@ -0,0 +1,171 @@
import { describe, expect, test } from "bun:test"
import type { FormAnswer, FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode-ai/client/promise"
import { replyWebSearch } from "./websearch"
const consent: FormCreated["data"]["form"] = {
id: "frm_consent",
sessionID: "ses_child",
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [{ key: "choice", type: "string", required: true, custom: false }],
}
const provider: FormCreated["data"]["form"] = {
...consent,
id: "frm_provider",
fields: [
{
key: "provider",
type: "string",
required: true,
custom: false,
options: [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
],
},
],
}
function fixture() {
const listeners = new Set<(event: OpenCodeEvent) => void>()
const replies: FormReplyInput[] = []
const abort = new AbortController()
const emit = (event: OpenCodeEvent) => listeners.forEach((listener) => listener(event))
return {
form: consent,
signal: abort.signal,
abort,
listeners,
replies,
events: {
listen(listener: (event: OpenCodeEvent) => void) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
},
reply: async (input: FormReplyInput) => {
replies.push(input)
},
create: (form = provider) => emit({ id: "evt_create", created: 0, type: "form.created", data: { form } }),
cancel: (id: string) =>
emit({
id: "evt_cancel",
created: 0,
type: "form.cancelled",
data: { id, sessionID: consent.sessionID },
}),
answer: (id: string, answer: FormAnswer) =>
emit({
id: "evt_reply",
created: 0,
type: "form.replied",
data: { id, sessionID: consent.sessionID, answer },
}),
}
}
describe("web search desktop consent", () => {
test.each([
["random", "allow"],
[false, "disable"],
] as const)("submits %s without a second form", async (selection, choice) => {
const input = fixture()
await replyWebSearch({ ...input, selection })
expect(input.replies).toEqual([{ sessionID: consent.sessionID, formID: consent.id, answer: { choice } }])
expect(input.listeners.size).toBe(0)
})
test("subscribes before the first reply and answers the owning session's provider form", async () => {
const input = fixture()
await replyWebSearch({
...input,
selection: "parallel",
reply: async (answer) => {
input.replies.push(answer)
if (answer.answer.choice === "choose") input.create()
},
})
expect(input.replies).toEqual([
{ sessionID: consent.sessionID, formID: consent.id, answer: { choice: "choose" } },
{ sessionID: consent.sessionID, formID: provider.id, answer: { provider: "parallel" } },
])
expect(input.listeners.size).toBe(0)
})
test("ignores questions, other sessions, and repeated consent forms during handoff", async () => {
const input = fixture()
const pending = replyWebSearch({ ...input, selection: "exa" })
input.create({ ...provider, sessionID: "ses_other" })
input.create({ ...provider, metadata: { kind: "question" } })
input.create(consent)
input.create()
await pending
expect(input.replies).toHaveLength(2)
expect(input.replies[1]?.formID).toBe(provider.id)
})
test("leaves a changed provider list for explicit confirmation", async () => {
const input = fixture()
const pending = replyWebSearch({ ...input, selection: "removed" })
input.create()
await pending
expect(input.replies).toHaveLength(1)
expect(input.listeners.size).toBe(0)
})
test("supports direct entry into the provider form", async () => {
const input = fixture()
await replyWebSearch({ ...input, form: provider, selection: "exa" })
expect(input.replies).toEqual([{ sessionID: consent.sessionID, formID: provider.id, answer: { provider: "exa" } }])
expect(input.listeners.size).toBe(0)
})
test("does not misrepresent cancelling the provider form as disabling search", async () => {
const input = fixture()
await replyWebSearch({ ...input, form: provider, selection: false })
expect(input.replies).toEqual([])
})
test.each(["cancel", "other-client", "abort"])("ends a pending handoff on %s", async (action) => {
const input = fixture()
const pending = replyWebSearch({ ...input, selection: "exa" })
if (action === "cancel") input.cancel(consent.id)
if (action === "other-client") input.answer(consent.id, { choice: "disable" })
if (action === "abort") input.abort.abort()
await pending
input.create()
expect(input.replies).toHaveLength(1)
expect(input.listeners.size).toBe(0)
})
test("cleans up after a failed consent submission", async () => {
const input = fixture()
await expect(
replyWebSearch({
...input,
selection: "exa",
reply: async () => {
throw new Error("offline")
},
}),
).rejects.toThrow("offline")
expect(input.listeners.size).toBe(0)
})
test("propagates provider submission failures for retry", async () => {
const input = fixture()
await expect(
replyWebSearch({
...input,
selection: "exa",
reply: async (answer) => {
if (answer.formID === provider.id) throw new Error("offline")
input.create()
},
}),
).rejects.toThrow("offline")
expect(input.listeners.size).toBe(0)
})
})
@@ -0,0 +1,154 @@
import type { FormInfo, FormOption, FormReplyInput, FormStringField } from "@opencode-ai/client/promise"
import { createEffect, createMemo, createResource, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeEventStream } from "@/runtime/server/client"
export function webSearchProviderField(form: FormInfo) {
return form.fields.find(
(field): field is FormStringField => field.type === "string" && field.key === "provider" && !!field.options,
)
}
export function createWebSearchRequest(input: {
owner: () => string | undefined
connected: () => boolean
request: () => FormInfo | undefined
providers: (sessionID: string) => Promise<FormOption[]>
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
const [store, setStore] = createStore({
selected: "random",
sending: undefined as { form: FormInfo; abort: AbortController } | undefined,
error: false,
})
const [providers, resource] = createResource(input.request, async (form) => {
const field = webSearchProviderField(form)
if (field) return field.options ?? []
return input.providers(form.sessionID)
})
const request = createMemo(() => store.sending?.form ?? input.request())
const specific = createMemo(() => {
const form = request()
return !!form && !!webSearchProviderField(form)
})
const options = createMemo(() => (providers.error ? [] : (providers() ?? [])))
const selected = createMemo(() => {
if (!specific()) return store.selected
return options().some((option) => option.value === store.selected) ? store.selected : options()[0]?.value
})
createEffect(
on([input.owner, input.connected], () => {
store.sending?.abort.abort()
setStore({ sending: undefined, selected: "random", error: false })
}),
)
createEffect(
on(
() => input.request()?.id,
() => {
const form = input.request()
if (!form || store.sending || webSearchProviderField(form)) return
setStore({ selected: "random", error: false })
},
),
)
onCleanup(() => store.sending?.abort.abort())
const submit = async (selection: string | false) => {
const form = input.request()
if (!form || store.sending || !input.connected()) return
if (selection === false && webSearchProviderField(form)) return
const sending = { form, abort: new AbortController() }
setStore({ sending, error: false })
await replyWebSearch({ ...input, form, selection, signal: sending.abort.signal })
.catch(() => {
if (!sending.abort.signal.aborted) setStore("error", true)
})
.finally(() => {
if (store.sending?.abort === sending.abort) setStore("sending", undefined)
})
}
return {
request,
options,
selected,
specific,
loading: () => providers.loading,
loadFailed: () => !!providers.error,
failed: () => store.error,
sending: () => !!store.sending,
connected: input.connected,
select: (value: string) => setStore({ selected: value, error: false }),
retry: () => void resource.refetch(),
submit,
}
}
export type WebSearchRequestModel = ReturnType<typeof createWebSearchRequest>
export async function replyWebSearch(input: {
form: FormInfo
selection: string | false
signal: AbortSignal
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
if (input.signal.aborted) return
if (webSearchProviderField(input.form)) {
if (input.selection === false) return
return input.reply({
sessionID: input.form.sessionID,
formID: input.form.id,
answer: { provider: input.selection },
})
}
if (input.selection === false || input.selection === "random") {
return input.reply({
sessionID: input.form.sessionID,
formID: input.form.id,
answer: { choice: input.selection === false ? "disable" : "allow" },
})
}
const next = Promise.withResolvers<FormInfo | undefined>()
const stop = input.events.listen((event) => {
if (event.type === "form.created") {
const form = event.data.form
if (
form.sessionID !== input.form.sessionID ||
form.id === input.form.id ||
form.metadata?.kind !== "websearch.provider" ||
!webSearchProviderField(form)
)
return
next.resolve(form)
}
if (event.type === "form.cancelled" && event.data.id === input.form.id) next.resolve(undefined)
if (event.type === "form.replied" && event.data.id === input.form.id && event.data.answer.choice !== "choose")
next.resolve(undefined)
})
const cancel = () => next.resolve(undefined)
input.signal.addEventListener("abort", cancel, { once: true })
return Promise.all([
input.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: { choice: "choose" } }),
next.promise,
])
.then(([, form]) => {
if (!form || input.signal.aborted) return
const field = webSearchProviderField(form)
if (!field?.options?.some((option) => option.value === input.selection)) return
return input.reply({
sessionID: form.sessionID,
formID: form.id,
answer: { provider: input.selection },
})
})
.finally(() => {
stop()
input.signal.removeEventListener("abort", cancel)
})
}
+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>
@@ -2,30 +2,54 @@ import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createSessionResolution } from "./session-resolution"
function store() {
const syncs = { session: 0, message: 0, pending: 0 }
const sessions = {
get: () => undefined,
sync: () => {
syncs.session++
return Promise.resolve()
},
message: {
sync: () => {
syncs.message++
return Promise.resolve()
},
},
pending: {
sync: () => {
syncs.pending++
return Promise.resolve()
},
},
}
return { syncs, sessions }
}
describe("session resolution", () => {
test("waits for a route session ID", () => {
createRoot((dispose) => {
const syncs = { session: 0, message: 0 }
const sessions = {
get: () => undefined,
sync: () => {
syncs.session++
return Promise.resolve()
},
message: {
sync: () => {
syncs.message++
return Promise.resolve()
},
},
}
const input = store()
const session = createSessionResolution(
() => undefined,
() => sessions,
() => input.sessions,
)
expect(session()).toBeUndefined()
expect(syncs).toEqual({ session: 0, message: 0 })
expect(input.syncs).toEqual({ session: 0, message: 0, pending: 0 })
dispose()
})
})
test("starts the transcript and queued input reads with metadata", () => {
createRoot((dispose) => {
const input = store()
createSessionResolution(
() => "ses_open",
() => input.sessions,
{ children: true },
)
expect(input.syncs).toEqual({ session: 1, message: 1, pending: 1 })
dispose()
})
})
@@ -7,6 +7,9 @@ type SessionStore<T> = {
message: {
sync: (id: string) => Promise<unknown>
}
pending: {
sync: (id: string) => Promise<unknown>
}
}
type Resolution<T> = { id: string; store: SessionStore<T> } & (
@@ -52,8 +55,11 @@ export function createSessionResolution<T>(
onCleanup(() => {
stale = true
})
// The timeline owns message errors; metadata resolution stays independent.
// The timeline owns message errors; metadata resolution stays independent. Queued inputs
// ride along so a reconnect refreshes them with the transcript instead of leaving the
// pre-disconnect queue on screen.
void store.message.sync(id).catch(() => undefined)
void store.pending.sync(id).catch(() => undefined)
if (cached() && !options?.children && !options?.connected) {
setStatus({ id, store, state: "settled" })
return
@@ -138,6 +138,26 @@ export const QuestionRequest = {
),
}
export const WebSearchRequest = {
render: () => (
<SessionPreview
title="Search for current documentation"
description={description}
document={questionPendingDocument}
request={{
type: "websearch",
value: {
id: "frm_websearch_preview",
sessionID: "ses_websearch_preview",
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [{ key: "choice", type: "string", required: true, custom: false }],
},
}}
/>
),
}
export const RetryAndInterruption = {
render: () => (
<SessionPreview title="Recover the interrupted run" description={description} document={retryAfterInterruption} />
+25 -1
View File
@@ -23,6 +23,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ReviewPanelView } from "@/session/review/panel"
import { createReviewPanelState } from "@/session/review/panel-state"
import { TerminalSurface } from "@/session/terminal/surface"
import type { WebSearchRequestModel } from "./requests/websearch"
const modelReady = Object.assign(() => true, { promise: undefined }) satisfies ModelSelection["ready"]
const storyComposerModel = {
@@ -82,7 +83,7 @@ export type SessionPreviewProps = {
description: string
document: SessionDocument
draft?: string
request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
request?: { type: "permission"; value: PermissionRequest } | { type: "question" | "websearch"; value: FormInfo }
reviewOpened?: boolean
child?: { parentID: string }
terminal?: { title: string; lines: string[] }
@@ -173,10 +174,12 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
activity: string
reviewOpened: boolean
request: SessionPreviewProps["request"]
searchProvider: string
}>({
activity: "Ready",
reviewOpened: props.reviewOpened ?? false,
request: props.request,
searchProvider: "random",
})
const prompt = createPromptController({
initial: props.draft ?? "",
@@ -189,6 +192,27 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
const region = {
state: {
questionRequest: () => (state.request?.type === "question" ? state.request.value : undefined),
websearch: {
request: () => (state.request?.type === "websearch" ? state.request.value : undefined),
options: () => [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
{ value: "tavily", label: "Tavily" },
],
selected: () => state.searchProvider,
specific: () => false,
loading: () => false,
loadFailed: () => false,
failed: () => false,
sending: () => false,
connected: () => true,
select: (value) => setState("searchProvider", value),
retry() {},
submit: async (value) => {
setState("request", undefined)
setState("activity", `Web search selection (local only): ${value}`)
},
} satisfies WebSearchRequestModel,
permissionRequest: () => (state.request?.type === "permission" ? state.request.value : undefined),
permissionResponding: () => false,
decide: (response) => {
@@ -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,
@@ -58,7 +58,6 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
type="button"
variant="ghost-faint"
size="small"
icon="outline-arrow-to-corner-top-right"
class="max-w-full"
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
onClick={() => props.onMove?.()}
@@ -354,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) {
@@ -790,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" })
})
@@ -1,6 +1,6 @@
import { createMemo, type Accessor } from "solid-js"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { sessionPermissionRequest, sessionFormRequest } from "@/session/requests/session-request-tree"
import { ServerConnection } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
@@ -29,12 +29,12 @@ export function useSessionTabAvatarState(
if (!ctx) return false
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
})
const hasQuestions = createMemo(() => {
const hasForms = createMemo(() => {
const data = serverCtx()?.data
if (!data) return false
return !!sessionQuestionForm(sessions(), data.session.form.list, sessionId())
return !!sessionFormRequest(sessions(), data.session.form.list, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const needsAttention = createMemo(() => hasPermissions() || hasForms())
const unread = createMemo(
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
)
@@ -21,6 +21,19 @@ describe("serverStatusDotClass", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
})
test("pulses the neutral dot while the event stream is reconnecting", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false, connecting: true })).toBe(
"bg-border-weak-base animate-pulse",
)
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false, connecting: true })).toBe(
"bg-border-weak-base animate-pulse",
)
// A server that is known to be down stays critical rather than looking like a routine reconnect.
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false, connecting: true })).toBe(
"bg-icon-critical-base",
)
})
test("stays neutral before status is ready", () => {
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
@@ -20,8 +20,12 @@ export function serverStatusDotClass(input: {
serverHealth: boolean | undefined
attention?: boolean
issue: boolean
connecting?: boolean
}) {
if (input.serverHealth === false) return "bg-icon-critical-base"
// The event stream is (re)connecting: keep the neutral dot but let it breathe so a stale
// session is visibly waiting on the server rather than silently frozen.
if (input.connecting) return "bg-border-weak-base animate-pulse"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.attention) return "bg-v2-background-bg-accent"
if (input.issue) return "bg-icon-warning-base"
@@ -42,6 +42,7 @@ export function StatusPopover() {
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
connecting: server.ctx.sdk.connection.status() !== "connected",
sidebar: sidebar(),
placement: sidebar() ? "top-start" : "bottom-end",
shift: sidebar() ? 0 : -168,
@@ -63,6 +64,7 @@ type StatusPopoverState = {
serverHealth: boolean | undefined
attention: boolean
issue: boolean
connecting: boolean
sidebar: boolean
placement: "top-start" | "bottom-end"
shift: number
@@ -17,9 +17,11 @@ function createFixture(initial: Record<string, Session> = {}) {
const deferred = new Map<string, PromiseWithResolvers<unknown>>()
const resolves: string[] = []
const messages = { syncs: [] as string[], ...Promise.withResolvers<unknown>() }
const pending = { syncs: [] as string[] }
return {
resolves,
messages,
pending,
sessions: {
get: (id: string) => cache()[id],
sync: (id: string) => {
@@ -34,6 +36,12 @@ function createFixture(initial: Record<string, Session> = {}) {
return messages.promise
},
},
pending: {
sync: (id: string) => {
pending.syncs.push(id)
return Promise.resolve()
},
},
},
settle(id: string, directory = `/dir/${id}`) {
setCache({ ...cache(), [id]: { id, directory } })
@@ -86,6 +94,7 @@ test("refreshes the current session on reconnect while keeping cached content vi
expect(fixture.resolves).toEqual(["ses_a", "ses_a"])
expect(current()).toEqual(sessionOf("ses_a"))
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_a"])
expect(fixture.pending.syncs).toEqual(["ses_a", "ses_a"])
fixture.settle("ses_a", "/worktrees/moved")
await flush()
expect(current()?.directory).toBe("/worktrees/moved")
@@ -0,0 +1,209 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode-ai/client/promise"
import { createEffect, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createWebSearchRequest } from "@/session/requests/websearch"
const consent: FormCreated["data"]["form"] = {
id: "frm_consent",
sessionID: "ses_child",
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [{ key: "choice", type: "string", required: true, custom: false }],
}
const options = [
{ value: "exa", label: "Exa" },
{ value: "parallel", label: "Parallel" },
]
const provider: FormCreated["data"]["form"] = {
...consent,
id: "frm_provider",
fields: [{ key: "provider", type: "string", options, required: true, custom: false }],
}
const cleanups: VoidFunction[] = []
afterEach(() => cleanups.splice(0).forEach((dispose) => dispose()))
function ready(condition: () => boolean) {
return new Promise<void>((resolve) => {
createRoot((dispose) => {
cleanups.push(dispose)
createEffect(() => {
if (!condition()) return
dispose()
resolve()
})
})
})
}
function fixture(form: FormCreated["data"]["form"] | null = consent) {
return createRoot((dispose) => {
cleanups.push(dispose)
const listeners = new Set<(event: OpenCodeEvent) => void>()
const replies: FormReplyInput[] = []
const loads: string[] = []
const [state, setState] = createStore({
request: form ?? undefined,
owner: "ses_root",
connected: true,
loadFails: false,
replyFails: false,
})
const model = createWebSearchRequest({
owner: () => state.owner,
connected: () => state.connected,
request: () => state.request,
providers: async (sessionID) => {
loads.push(sessionID)
if (state.loadFails) throw new Error("offline")
return options
},
reply: async (input) => {
replies.push(input)
if (state.replyFails) throw new Error("offline")
setState("request", undefined)
},
events: {
listen(listener) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
},
})
return {
model,
state,
setState,
replies,
loads,
dispose,
listeners,
create(form = provider) {
setState("request", form)
listeners.forEach((listener) =>
listener({
id: "evt_create",
created: 0,
type: "form.created",
data: { form },
}),
)
},
cancel(id: string) {
setState("request", undefined)
listeners.forEach((listener) =>
listener({
id: "evt_cancel",
created: 0,
type: "form.cancelled",
data: { id, sessionID: consent.sessionID },
}),
)
},
}
})
}
describe("web search request state", () => {
test("loads providers for the form owner, not the viewed parent, and waits for consent", async () => {
const input = fixture()
await ready(() => !input.model.loading())
expect(input.loads).toEqual(["ses_child"])
expect(input.model.selected()).toBe("random")
input.model.select("exa")
expect(input.replies).toEqual([])
await input.model.submit(false)
expect(input.replies[0]?.answer).toEqual({ choice: "disable" })
expect(input.model.request()).toBeUndefined()
expect(input.model.sending()).toBe(false)
})
test("does not load providers when there is no consent request", () => {
const input = fixture(null)
expect(input.model.request()).toBeUndefined()
expect(input.loads).toEqual([])
})
test("holds the card across both forms and prevents duplicate submissions", async () => {
const input = fixture()
await ready(() => !input.model.loading())
input.model.select("parallel")
const pending = input.model.submit("parallel")
await input.model.submit("parallel")
expect(input.state.request).toBeUndefined()
expect(input.model.request()?.id).toBe(consent.id)
expect(input.model.sending()).toBe(true)
input.create()
await pending
expect(input.replies.map((reply) => reply.answer)).toEqual([{ choice: "choose" }, { provider: "parallel" }])
expect(input.model.request()).toBeUndefined()
expect(input.model.sending()).toBe(false)
expect(input.listeners.size).toBe(0)
})
test("direct provider requests need confirmation and do not load a different provider list", async () => {
const input = fixture(provider)
await ready(() => !input.model.loading())
expect(input.loads).toEqual([])
expect(input.model.specific()).toBe(true)
expect(input.model.options()).toEqual(options)
expect(input.replies).toEqual([])
await input.model.submit("exa")
expect(input.replies.map((reply) => reply.answer)).toEqual([{ provider: "exa" }])
})
test("keeps the selected provider after failure and retries only the provider form", async () => {
const input = fixture()
await ready(() => !input.model.loading())
input.model.select("parallel")
const pending = input.model.submit("parallel")
input.setState("replyFails", true)
input.create()
await pending
expect(input.model.failed()).toBe(true)
expect(input.model.sending()).toBe(false)
expect(input.model.request()?.id).toBe(provider.id)
expect(input.model.selected()).toBe("parallel")
input.setState("replyFails", false)
await input.model.submit("parallel")
expect(input.replies.map((reply) => reply.answer)).toEqual([
{ choice: "choose" },
{ provider: "parallel" },
{ provider: "parallel" },
])
expect(input.model.request()).toBeUndefined()
})
test("can retry loading providers without submitting consent", async () => {
const input = fixture()
await ready(() => !input.model.loading())
input.setState("loadFails", true)
input.model.retry()
await ready(() => input.model.loadFailed())
expect(input.model.options()).toEqual([])
input.setState("loadFails", false)
input.model.retry()
await ready(() => !input.model.loading())
expect(input.model.options()).toEqual(options)
expect(input.model.loadFailed()).toBe(false)
expect(input.replies).toEqual([])
})
test.each(["navigate", "disconnect", "dispose", "cancel"])("stops automatic replies on %s", async (action) => {
const input = fixture()
await ready(() => !input.model.loading())
const pending = input.model.submit("exa")
if (action === "navigate") input.setState("owner", "ses_other")
if (action === "disconnect") input.setState("connected", false)
if (action === "dispose") input.dispose()
if (action === "cancel") input.cancel(consent.id)
await pending
expect(input.model.sending()).toBe(false)
expect(input.listeners.size).toBe(0)
input.create()
expect(input.replies.map((reply) => reply.answer)).toEqual([{ choice: "choose" }])
})
})
+1 -1
View File
@@ -9,7 +9,7 @@ export type { ClientOptions, RequestOptions } from "./generated/client.js"
export function make(options: ClientOptions) {
const raw = OpenCode.make(options)
const events = SharedEvents.make((signal) => raw.event.subscribe({ signal }))
const events = SharedEvents.make((signal, onActivity) => raw.event.subscribe({ signal, onActivity }))
return {
...raw,
rpc: Object.assign(makeRpc(raw, events), raw.rpc),
@@ -278,6 +278,8 @@ export interface ClientOptions {
export interface RequestOptions {
readonly signal?: AbortSignal
readonly headers?: RequestInit["headers"]
/** Reports every chunk a streaming response receives, including keepalive comments that yield no event. */
readonly onActivity?: () => void
}
interface RequestDescriptor {
@@ -369,6 +371,7 @@ export function make(options: ClientOptions) {
} catch (cause) {
throw new ClientError("Transport", { cause })
}
if (!next.done) requestOptions?.onActivity?.()
buffer += decoder.decode(next.value, { stream: !next.done })
if (buffer.length > maxSseEventBytes) throw new ClientError("SseEventTooLarge")
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
@@ -2045,7 +2045,6 @@ export type ConfigEntry =
experimental?: {
portable_shell_scanner?: boolean
subagent_depth?: number
subagent_fork?: boolean
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
}
}
+15 -3
View File
@@ -1,10 +1,19 @@
export * as SharedEvents from "./shared-events.js"
export function make<A extends { readonly type: string }>(connect: (signal: AbortSignal) => AsyncIterable<A>) {
export type SubscribeOptions = {
readonly signal?: AbortSignal
/** Reports transport activity on the shared stream, including keepalive frames that carry no event. */
readonly onActivity?: () => void
}
export function make<A extends { readonly type: string }>(
connect: (signal: AbortSignal, onActivity: () => void) => AsyncIterable<A>,
) {
type Completion = { readonly error: unknown } | Record<string, never>
type Subscriber = {
push: (value: A) => void
finish: (completion: Completion) => void
activity?: () => void
}
type Connection = {
controller: AbortController
@@ -26,7 +35,9 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
let completion: Completion = {}
try {
if (connection.controller.signal.aborted) return
iterator = connect(connection.controller.signal)[Symbol.asyncIterator]()
iterator = connect(connection.controller.signal, () => {
connection.subscribers.forEach((subscriber) => subscriber.activity?.())
})[Symbol.asyncIterator]()
while (!connection.controller.signal.aborted) {
const item = await iterator.next()
if (item.done || connection.controller.signal.aborted) break
@@ -47,7 +58,7 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
}
return {
subscribe(options?: { readonly signal?: AbortSignal }): AsyncIterable<A> {
subscribe(options?: SubscribeOptions): AsyncIterable<A> {
return {
[Symbol.asyncIterator]() {
const pending: ReturnType<typeof Promise.withResolvers<IteratorResult<A>>>[] = []
@@ -72,6 +83,7 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
}
const subscriber: Subscriber = {
activity: options?.onActivity,
finish(result) {
finish(result, false)
},
+76 -17
View File
@@ -1,4 +1,4 @@
import { batch, onCleanup, onMount } from "solid-js"
import { batch, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeClient, OpenCodeEvent } from "../promise"
@@ -18,6 +18,11 @@ export type ClientConnectionOptions = {
readonly onEvent: (event: OpenCodeEvent) => void
readonly flushInterval?: number
readonly pageLifecycle?: boolean
/**
* Abort and reconnect a stream that receives no bytes for this long. The server writes a keepalive
* comment every 15 seconds, so a quiet but healthy stream never trips this.
*/
readonly idleTimeout?: number
readonly log?: {
readonly debug?: (message: string, data?: Readonly<Record<string, unknown>>) => void
readonly info?: (message: string, data?: Readonly<Record<string, unknown>>) => void
@@ -27,10 +32,15 @@ export type ClientConnectionOptions = {
const connectTimeout = 2_000
const reconnectDelay = 1_000
const connectionHistoryLimit = 50
export const defaultIdleTimeout = 45_000
// Longer than one server keepalive interval: a stream that is silent this long when the page
// returns to the foreground is probably half-open after the device slept.
export const foregroundIdleThreshold = 20_000
export function createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions) {
const abort = new AbortController()
const history: ClientConnectionEvent[] = []
const idleTimeout = options.idleTimeout ?? defaultIdleTimeout
const [connection, setConnection] = createStore<{
status: ClientConnectionStatus
attempt: number
@@ -40,9 +50,12 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
let pending: OpenCodeEvent[] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
let stream: AbortController | undefined
let current: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
let lastActivity = 0
let forced = false
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
@@ -63,14 +76,25 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
async function connect(signal: AbortSignal, attempt: number) {
let connectedAt: number | undefined
const request = new AbortController()
current = request
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
signal.addEventListener("abort", cancel, { once: true })
// Any received bytes, including keepalive comments, push the stall deadline out. A timer whose
// deadline passed while the page was suspended fires as soon as the page resumes.
let watchdog: ReturnType<typeof setTimeout> | undefined
const touch = () => {
lastActivity = Date.now()
if (connectedAt === undefined) return
clearTimeout(watchdog)
watchdog = setTimeout(() => request.abort(new Error("Event stream stalled")), idleTimeout)
}
try {
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
options.log?.info?.("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const iterator = api.event.subscribe({ signal: request.signal, onActivity: touch })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done)
@@ -85,6 +109,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
clearTimeout(timeout)
record("connected", attempt)
connectedAt = Date.now()
touch()
options.log?.info?.("event stream connected")
publish(first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
@@ -92,7 +117,13 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
if (event.done)
return {
error:
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
connectedAt,
}
touch()
if ("durable" in event.value && event.value.durable)
options.log?.debug?.("event", {
type: event.value.type,
@@ -106,7 +137,9 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
return { error, connectedAt }
} finally {
request.abort()
if (current === request) current = undefined
clearTimeout(timeout)
clearTimeout(watchdog)
signal.removeEventListener("abort", cancel)
}
}
@@ -140,6 +173,11 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
if (attempt === 1) continue
}
}
// A deliberate resync already knows the old socket is gone; reconnect without backing off.
if (forced) {
forced = false
continue
}
await wait(reconnectDelay, controller.signal)
}
}
@@ -147,6 +185,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
function start() {
if (started) return run
started = true
forced = false
const active = ++generation
const previous = run
const current = (async () => {
@@ -161,26 +200,45 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
}
function stop() {
if (!started) return
started = false
generation += 1
stream?.abort()
// Nothing is listening once stopped, so consumers must treat their data as stale until start() reconnects.
setConnection({ status: "connecting", attempt: 0, error: undefined })
}
onMount(() => {
if (options.pageLifecycle) {
const pagehide = () => stop()
const pageshow = (event: PageTransitionEvent) => {
if (event.persisted) void start()
}
window.addEventListener("pagehide", pagehide)
window.addEventListener("pageshow", pageshow)
onCleanup(() => {
window.removeEventListener("pagehide", pagehide)
window.removeEventListener("pageshow", pageshow)
})
// Drop the live request so the reconnect loop replaces it now instead of waiting for the idle watchdog.
function resync(reason: string) {
if (!started || connection.status !== "connected") return
options.log?.info?.("event stream resync", { reason, idle: Date.now() - lastActivity })
forced = true
current?.abort(new Error(reason))
}
if (options.pageLifecycle) {
const pagehide = () => stop()
const pageshow = () => void start()
// Locking a phone or switching apps hides the document without a pagehide; the socket usually
// dies while the page is suspended, and the browser may never report that on the hung read.
const visibility = () => {
if (document.visibilityState !== "visible") return
if (Date.now() - lastActivity < foregroundIdleThreshold) return
resync("Page returned to the foreground after the event stream went quiet")
}
void start()
})
const online = () => resync("Network connection restored")
window.addEventListener("pagehide", pagehide)
window.addEventListener("pageshow", pageshow)
window.addEventListener("online", online)
document.addEventListener("visibilitychange", visibility)
onCleanup(() => {
window.removeEventListener("pagehide", pagehide)
window.removeEventListener("pageshow", pageshow)
window.removeEventListener("online", online)
document.removeEventListener("visibilitychange", visibility)
})
}
void start()
onCleanup(() => {
stop()
@@ -195,6 +253,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
error: () => connection.error,
internal: {
history: () => history.slice(),
resync,
},
}
}
+25
View File
@@ -675,6 +675,31 @@ test("event.subscribe ignores server heartbeat comments", async () => {
expect(received).toEqual([event])
})
test("event.subscribe reports heartbeat comments as stream activity", async () => {
const event = { id: "evt_sentinel", created: 1, type: "server.connected", data: {} }
const encoder = new TextEncoder()
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(": heartbeat\n\n"))
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
controller.enqueue(encoder.encode(": heartbeat\n\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
})
let activity = 0
const received = []
for await (const item of client.event.subscribe({ onActivity: () => activity++ })) received.push(item)
expect(received).toEqual([event])
expect(activity).toBe(3)
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event transport passes through ordinary health requests", async () => {
const requests: string[] = []
@@ -359,3 +359,24 @@ test("synchronous source creation failures reject subscribers without automatic
await expect(shared.subscribe()[Symbol.asyncIterator]().next()).rejects.toBe(failure)
expect(attempts).toHaveLength(2)
})
test("source activity fans out to every subscriber that asked for it", async () => {
const events = source()
let activity: (() => void) | undefined
const shared = SharedEvents.make<Event>((signal, onActivity) => {
activity = onActivity
return events.connect(signal)
})
const counts = { first: 0, second: 0 }
const first = shared.subscribe({ onActivity: () => counts.first++ })[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
const reads = Promise.all([first.next(), second.next()])
activity!()
activity!()
events.connections[0].push({ type: "server.connected" })
await reads
expect(counts).toEqual({ first: 2, second: 0 })
await first.return!()
await second.return!()
await events.connections[0].closed
})
@@ -0,0 +1,153 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createClientConnection } from "../src/solid"
import { OpenCode, type OpenCodeEvent } from "../src/promise"
const connected = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
// One fake server whose event streams stay open until the test writes to them or the client aborts.
function server() {
const encoder = new TextEncoder()
const streams: {
write: (text: string) => void
close: () => void
aborted: boolean
}[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
let controller!: ReadableStreamDefaultController<Uint8Array>
const entry = {
write: (text: string) => controller.enqueue(encoder.encode(text)),
close: () => controller.close(),
aborted: false,
}
const body = new ReadableStream<Uint8Array>({
start(value) {
controller = value
},
cancel() {
entry.aborted = true
},
})
request.signal.addEventListener("abort", () => {
entry.aborted = true
controller.error(request.signal.reason)
})
streams.push(entry)
return new Response(body, { headers: { "content-type": "text/event-stream" } })
},
})
return { api, streams }
}
function setup(input: ReturnType<typeof server>, idleTimeout: number) {
const events: OpenCodeEvent[] = []
return createRoot((dispose) => ({
events,
dispose,
connection: createClientConnection(input.api, {
idleTimeout,
flushInterval: 0,
onEvent: (event) => events.push(event),
}),
}))
}
async function until(check: () => boolean, timeout = 2_000) {
const deadline = Date.now() + timeout
while (!check()) {
if (Date.now() > deadline) throw new Error("Timed out waiting for condition")
await new Promise((resolve) => setTimeout(resolve, 5))
}
}
test("a stream that goes silent past the idle timeout is replaced", async () => {
const fake = server()
const ctx = setup(fake, 60)
try {
await until(() => fake.streams.length === 1)
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
await until(() => fake.streams.length === 2)
expect(fake.streams[0].aborted).toBe(true)
expect(ctx.connection.internal.history().map((item) => item.data)).toContainEqual({
status: "disconnected",
attempt: 1,
error: "Event stream stalled",
})
fake.streams[1].write(`data: ${JSON.stringify({ ...connected, id: "evt_connected_2" })}\n\n`)
await until(() => ctx.connection.status() === "connected" && ctx.events.length === 2)
expect(ctx.connection.error()).toBeUndefined()
} finally {
ctx.dispose()
}
})
test("keepalive comments hold a quiet stream open", async () => {
const fake = server()
const ctx = setup(fake, 60)
try {
await until(() => fake.streams.length === 1)
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
const heartbeat = setInterval(() => fake.streams[0].write(": heartbeat\n\n"), 20)
await new Promise((resolve) => setTimeout(resolve, 250))
clearInterval(heartbeat)
expect(fake.streams).toHaveLength(1)
expect(fake.streams[0].aborted).toBe(false)
expect(ctx.connection.status()).toBe("connected")
expect(ctx.events).toHaveLength(1)
} finally {
ctx.dispose()
}
})
test("a forced resync replaces the stream immediately and only while connected", async () => {
const fake = server()
const ctx = setup(fake, 10_000)
try {
ctx.connection.internal.resync("too early")
await until(() => fake.streams.length === 1)
expect(fake.streams[0].aborted).toBe(false)
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
const started = Date.now()
ctx.connection.internal.resync("Network connection restored")
await until(() => fake.streams.length === 2)
expect(Date.now() - started).toBeLessThan(500)
expect(fake.streams[0].aborted).toBe(true)
expect(ctx.connection.internal.history().map((item) => item.data)).toContainEqual({
status: "disconnected",
attempt: 1,
error: "Network connection restored",
})
fake.streams[1].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
} finally {
ctx.dispose()
}
})
test("a stream the server closes reconnects and reports the disconnect", async () => {
const fake = server()
const ctx = setup(fake, 10_000)
try {
await until(() => fake.streams.length === 1)
fake.streams[0].write(`data: ${JSON.stringify(connected)}\n\n`)
await until(() => ctx.connection.status() === "connected")
fake.streams[0].close()
await until(() => ctx.connection.status() === "reconnecting")
expect(ctx.connection.error()).toBe("Event stream disconnected")
await until(() => fake.streams.length === 2)
} finally {
ctx.dispose()
}
})
-9
View File
@@ -426,15 +426,6 @@ function normalizeExperimental(
)
if (value !== undefined) result.subagent_depth = value
}
if (own(experimental, "subagent_fork")) {
const value = decodeEncoded(
ConfigExperimental.Info.fields.subagent_fork,
experimental.subagent_fork,
["experimental", "subagent_fork"],
diagnostics,
)
if (value !== undefined) result.subagent_fork = value
}
native.push(
...decodeList(
experimental.policies,
+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],
})
+3 -2
View File
@@ -87,7 +87,7 @@ import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { SkillPlugin } from "./skill.js"
import { VcsHgPlugin } from "./vcs/hg.js"
import { SystemPromptPlugin } from "./system-prompt.js"
import { OptimizePlugin } from "./optimize.js"
import { VariantPlugin } from "./variant.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { WarmingPlugin } from "./warming.js"
@@ -201,11 +201,12 @@ const pre = [
CommandPlugin.Plugin,
SkillPlugin.Plugin,
VcsHgPlugin.Plugin,
...SystemPromptPlugin.Plugins,
ModelsDevPlugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
// Render model prompts after the patch plugin selects the available editing tools.
...OptimizePlugin.Plugins,
EditTool.Plugin,
GlobTool.Plugin,
GrepTool.Plugin,
+74
View File
@@ -0,0 +1,74 @@
export * as OptimizePlugin from "./optimize.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import { Model } from "@opencode-ai/schema/model"
import { Effect } from "effect"
import { SessionSystemPrompt } from "../session/system-prompt.js"
import PROMPT_GPT from "./system-prompt/gpt.txt"
import PROMPT_ASTRA from "./system-prompt/gpt-astra.txt"
import PROMPT_KIMI from "./system-prompt/kimi.txt"
import PROMPT_META from "./system-prompt/meta.txt"
import PROMPT_TRINITY from "./system-prompt/trinity.txt"
export const OpenAIPlugin = make("opencode.prompt.openai", (model) => {
const id = model.id.toLowerCase()
if (!id.includes("gpt")) return undefined
return id.includes("gpt-6") ? PROMPT_ASTRA : PROMPT_GPT
})
export const OpenAIToolsPlugin = make("opencode.optimize.openai.tools", (model, tools) => {
const ids = [model.id, model.modelID, model.family].join(" ").toLowerCase()
if (!ids.includes("gpt")) return undefined
delete tools.grep
delete tools.glob
return undefined
})
export const AnthropicToolsPlugin = make("opencode.optimize.anthropic.tools", (model, tools) => {
const ids = [model.id, model.modelID, model.family].join(" ").toLowerCase()
if (!ids.includes("claude")) return undefined
delete tools.grep
delete tools.glob
return undefined
})
export const KimiPlugin = make("opencode.prompt.kimi", (model) =>
model.id.toLowerCase().includes("kimi") ? PROMPT_KIMI : undefined,
)
export const ArceePlugin = make("opencode.prompt.arcee", (model) =>
model.id.toLowerCase().includes("trinity") ? PROMPT_TRINITY : undefined,
)
export const MetaPlugin = make("opencode.prompt.meta", (model) => {
if (!model.id.toLowerCase().includes("muse")) return undefined
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
})
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
function make(
id: string,
optimize: (model: Model.Info, tools: SessionHooks["context"]["tools"]) => string | undefined,
) {
return define({
id,
effect: Effect.fn(`OptimizePlugin.${id}`)(function* (ctx) {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const model =
(yield* ctx.catalog.model.list()).data.find(
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
) ?? Model.Info.default(event.model.providerID, event.model.id)
// Curate tools before rendering their guidance, including for agents with a custom system prompt.
const template = optimize(model, event.tools)
if (!template) return
if ((yield* ctx.agent.get({ agentID: event.agent })).data.system) return
const system = event.system[0]
if (!system) return
event.system[0] = { ...system, text: SessionSystemPrompt.render(template, Object.keys(event.tools)) }
}).pipe(Effect.catch(() => Effect.void)),
)
}),
})
}
-74
View File
@@ -1,74 +0,0 @@
export * as SystemPromptPlugin from "./system-prompt.js"
import { SystemPart } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Model } from "@opencode-ai/schema/model"
import { Effect } from "effect"
import { SessionSystemPrompt } from "../session/system-prompt.js"
import PROMPT_GPT from "./system-prompt/gpt.txt"
import PROMPT_ASTRA from "./system-prompt/gpt-astra.txt"
import PROMPT_KIMI from "./system-prompt/kimi.txt"
import PROMPT_META from "./system-prompt/meta.txt"
import PROMPT_TRINITY from "./system-prompt/trinity.txt"
export const OpenAIPlugin = make(
"openai",
(model) => {
if (!model.id.toLowerCase().includes("gpt")) return
if (model.id.toLowerCase().includes("gpt-6")) return PROMPT_ASTRA
return PROMPT_GPT
},
{ operation: "replace" },
)
export const KimiPlugin = make("kimi", (model) => (model.id.toLowerCase().includes("kimi") ? PROMPT_KIMI : undefined), {
operation: "replace",
})
export const ArceePlugin = make(
"arcee",
(model) => (model.id.toLowerCase().includes("trinity") ? PROMPT_TRINITY : undefined),
{ operation: "replace" },
)
export const MetaPlugin = make(
"meta",
(model) => {
if (!model.id.toLowerCase().includes("muse")) return
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
},
{ operation: "replace" },
)
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
function make(
id: string,
getPrompt: (model: Model.Info) => string | undefined,
options: { operation: "replace" | "append" },
) {
return define({
id: `opencode.prompt.${id}`,
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
if ((yield* ctx.agent.get({ agentID: event.agent })).data.system) return
const system = event.system[0]
if (!system) return
const model = (yield* ctx.catalog.model.list()).data.find(
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
)
const template = getPrompt(model ?? Model.Info.default(event.model.providerID, event.model.id))
if (!template) return
const prompt = SessionSystemPrompt.render(template, Object.keys(event.tools))
if (options.operation === "append") {
event.system.splice(1, 0, SystemPart.make(prompt))
return
}
event.system[0] = { ...system, text: prompt }
}).pipe(Effect.catch(() => Effect.void)),
)
}),
})
}
+1 -4
View File
@@ -93,7 +93,6 @@ type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: Sess
type ForkInput = {
sessionID: SessionSchema.ID
boundary: SessionSchema.ForkRequestBoundary
parentID?: SessionSchema.ID
}
export {
@@ -312,9 +311,7 @@ const layer = Layer.effect(
messageID: input.boundary.messageID,
})
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
const sessionID = input.parentID
? (yield* result.create({ parentID: input.parentID })).id
: SessionSchema.ID.create()
const sessionID = SessionSchema.ID.create()
const inherited = yield* db
.transaction(() =>
Effect.all({
+12 -20
View File
@@ -1,6 +1,6 @@
export * as SessionProjector from "./projector.js"
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm"
import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { Database } from "../database/database.js"
@@ -144,25 +144,22 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.pipe(Effect.orDie)
const copiedSeq = copied?.seq
const inherited = {
fork_session_id: event.data.parentID,
fork_boundary: event.data.boundary,
project_id: parent.project_id,
workspace_id: parent.workspace_id,
directory: parent.directory,
path: parent.path,
title: forkTitle(parent.title ?? undefined),
agent: parent.agent,
model: parent.model,
metadata: parent.metadata,
}
const stored = yield* db
.insert(SessionTable)
.values({
id: event.data.sessionID,
parent_id: null,
...inherited,
fork_session_id: event.data.parentID,
fork_boundary: event.data.boundary,
project_id: parent.project_id,
workspace_id: parent.workspace_id,
slug: Slug.create(),
directory: parent.directory,
path: parent.path,
title: forkTitle(parent.title ?? undefined),
agent: parent.agent,
model: parent.model,
metadata: parent.metadata,
version: parent.version,
cost: 0,
tokens_input: 0,
@@ -173,12 +170,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
time_created: event.created,
time_updated: event.created,
})
// Created records optional ownership; Forked supplies the source's history and defaults.
.onConflictDoUpdate({
target: SessionTable.id,
set: { ...inherited, time_updated: event.created },
setWhere: and(isNotNull(SessionTable.parent_id), isNull(SessionTable.fork_session_id)),
})
.onConflictDoNothing()
.returning({ sessionID: SessionTable.id })
.get()
.pipe(Effect.orDie)
+19 -63
View File
@@ -5,7 +5,6 @@ import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { ConfigEntryObserver } from "../../config/plugin/entry-observer.js"
import { Job } from "../../job.js"
import { Permission } from "../../permission.js"
import { Session } from "../../session.js"
@@ -39,13 +38,6 @@ export const Input = Schema.Struct({
}),
})
const ForkInput = Schema.Struct({
...Input.fields,
fork: Schema.optionalKey(Schema.Boolean).annotate({
description: "Give the subagent your conversation history before this response.",
}),
})
export const Output = Schema.Struct({
sessionID: SessionSchema.ID,
status: Schema.Literals(["completed", "running"]),
@@ -69,26 +61,17 @@ export const Plugin = {
const config = yield* Config.Service
const permission = yield* Permission.Service
const subagents = yield* SubagentJob.make
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.tool.reload())
yield* ctx.tool
.transform((editor) => {
const fork = Config.latest(loaded.entries, "experimental")?.subagent_fork === true
.transform((editor) =>
editor.add({
name,
options: { codemode: false },
description: fork
? description.replace(
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
"New child sessions start with fresh context by default, so include the context needed for the task.",
)
: description,
input: fork ? ForkInput : Input,
description,
input: Input,
output: Output,
execute: (input: typeof ForkInput.Type, context) =>
execute: (input, context) =>
Effect.gen(function* () {
if (fork && input.fork !== undefined && input.sessionID !== undefined)
return yield* new ToolFailure({ message: "Cannot use fork with sessionID. Omit one of them." })
const parent = yield* sessions
.get(context.sessionID)
.pipe(
@@ -167,40 +150,18 @@ export const Plugin = {
const model = agent.model ?? parent.model
const child =
existing ??
(yield* (
fork && input.fork
? sessions.fork({
sessionID: context.sessionID,
parentID: context.sessionID,
boundary: { type: "before", messageID: context.messageID },
})
: sessions.create({
parentID: context.sessionID,
title: input.description,
agent: Agent.ID.make(input.agent),
model,
})
).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message:
fork && input.fork
? `Failed to create subagent: ${error.message}`
: `Parent session not found: ${context.sessionID}`,
error,
}),
),
))
if (fork && input.fork)
yield* sessions.rename({ sessionID: child.id, title: input.description }).pipe(
Effect.andThen(sessions.switchAgent({ sessionID: child.id, agent: agent.id })),
Effect.andThen(model ? sessions.switchModel({ sessionID: child.id, model }) : Effect.void),
Effect.mapError(
(error) => new ToolFailure({ message: `Failed to configure subagent: ${child.id}`, error }),
),
)
(yield* sessions
.create({
parentID: context.sessionID,
title: input.description,
agent: Agent.ID.make(input.agent),
model,
})
.pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
))
const background = input.background === true
yield* context.progress({ sessionID: child.id, status: "running" })
@@ -212,12 +173,7 @@ export const Plugin = {
sessionID: child.id,
text:
existing === undefined
? [
fork && input.fork
? "You are a forked subagent. Use the inherited history as context and perform only the task below."
: "You are a subagent spawned by another session.",
input.prompt,
].join("\n")
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
: input.prompt,
...(background && existing === undefined ? { resume: false } : {}),
})
@@ -274,8 +230,8 @@ export const Plugin = {
metadata: { sessionID: output.sessionID, status: output.status },
})),
),
})
})
}),
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
@@ -409,14 +409,12 @@ describe("ConfigNormalize", () => {
experimental: {
portable_shell_scanner: true,
subagent_depth: 0,
subagent_fork: true,
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
},
}).encoded.experimental,
).toEqual({
portable_shell_scanner: true,
subagent_depth: 0,
subagent_fork: true,
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
@@ -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([])
}),
)
}
})
@@ -5,7 +5,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { OptimizePlugin } from "@opencode-ai/core/plugin/optimize"
import { Session } from "@opencode-ai/core/session"
import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
@@ -26,17 +26,22 @@ const makeHost = Effect.gen(function* () {
})
const context = (id: string, system = fallback): SessionHooks["context"] => ({
sessionID: Session.ID.make("ses_system_prompt"),
sessionID: Session.ID.make("ses_model_optimization"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }),
system: [SystemPart.make(system)],
messages: [],
tools: {},
tools: Object.fromEntries(
["shell", "read", "grep", "glob", "edit", "write", "patch"].map((name) => [
name,
{ description: name, input: { type: "object" } },
]),
),
generation: {},
providerOptions: {},
})
describe("SystemPromptPlugin", () => {
describe("OptimizePlugin", () => {
test("uses current vocabulary in the Meta prompt", () => {
expect(PROMPT_META).toContain("`webfetch` tool")
expect(PROMPT_META).toContain("`subagent` tool")
@@ -51,8 +56,8 @@ describe("SystemPromptPlugin", () => {
)
})
test("uses granular IDs with a common prefix", () => {
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
test("enables prompt plugins without model-specific tool optimization", () => {
expect(OptimizePlugin.Plugins.map((plugin) => plugin.id)).toEqual([
"opencode.prompt.openai",
"opencode.prompt.kimi",
"opencode.prompt.arcee",
@@ -72,7 +77,7 @@ describe("SystemPromptPlugin", () => {
model.name = "Muse Spark"
})
})
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
yield* Effect.forEach(OptimizePlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
discard: true,
})
const cases = [
@@ -106,7 +111,7 @@ describe("SystemPromptPlugin", () => {
}),
)
it.effect("renders the OpenAI prompt and preserves project instructions", () =>
it.effect("renders the OpenAI prompt without changing tools or project instructions", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
@@ -114,7 +119,7 @@ describe("SystemPromptPlugin", () => {
yield* catalog.transform((editor) =>
editor.model.update(Provider.ID.make("test"), Model.ID.make("gpt-5"), () => {}),
)
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
yield* OptimizePlugin.OpenAIPlugin.effect(pluginHost)
const event = context("gpt-5")
event.system.push(SystemPart.make("Project instructions"))
event.tools.shell = { description: "Run a command", input: { type: "object" } }
@@ -128,6 +133,73 @@ describe("SystemPromptPlugin", () => {
expect(event.system[0]?.text).toStartWith("You are an AI agent powered by OpenCode")
expect(event.system[0]?.text).toContain("Prefer dedicated tools over shell commands")
expect(event.system[0]?.text).not.toContain("${OPENCODE_TOOL_GUIDANCE}")
expect(event.system[0]?.text).toContain("Use the write tool")
expect(event.system[0]?.text).toContain("Use the edit tool")
expect(Object.keys(event.tools).sort()).toEqual(["edit", "glob", "grep", "patch", "read", "shell", "write"])
}),
)
it.effect("curates search tools across providers without changing editing tools", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* OptimizePlugin.OpenAIToolsPlugin.effect(pluginHost)
yield* OptimizePlugin.AnthropicToolsPlugin.effect(pluginHost)
const cases = [
["openai", "gpt-5", ["edit", "patch", "read", "shell", "write"]],
["openrouter", "openai/gpt-6-astra", ["edit", "patch", "read", "shell", "write"]],
["azure", "GPT-4.1", ["edit", "patch", "read", "shell", "write"]],
["groq", "openai/gpt-oss-120b", ["edit", "patch", "read", "shell", "write"]],
["anthropic", "claude-opus-4-8", ["edit", "patch", "read", "shell", "write"]],
["amazon-bedrock", "us.anthropic.Claude-sonnet-4-6", ["edit", "patch", "read", "shell", "write"]],
["github-copilot", "claude-sonnet-4.6", ["edit", "patch", "read", "shell", "write"]],
["google", "gemini-2.5-pro", ["edit", "glob", "grep", "patch", "read", "shell", "write"]],
["moonshotai", "kimi-k2", ["edit", "glob", "grep", "patch", "read", "shell", "write"]],
["openai", "o3", ["edit", "glob", "grep", "patch", "read", "shell", "write"]],
["anthropic", "other-model", ["edit", "glob", "grep", "patch", "read", "shell", "write"]],
] as const
yield* Effect.forEach(
cases,
([providerID, id, tools]) =>
Effect.gen(function* () {
const event = {
...context(id),
model: Model.Ref.make({ providerID: Provider.ID.make(providerID), id: Model.ID.make(id) }),
}
yield* hooks.trigger("session", "context", event)
expect(Object.keys(event.tools).sort()).toEqual([...tools])
expect(event.system.map((part) => part.text)).toEqual([fallback])
}),
{ discard: true },
)
}),
)
it.effect("can disable OpenAI tool optimization while retaining its prompt and Anthropic tool optimization", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* OptimizePlugin.OpenAIPlugin.effect(pluginHost)
yield* OptimizePlugin.AnthropicToolsPlugin.effect(pluginHost)
yield* Effect.scoped(
Effect.gen(function* () {
yield* OptimizePlugin.OpenAIToolsPlugin.effect(pluginHost)
const event = context("gpt-5")
yield* hooks.trigger("session", "context", event)
expect(event.system[0]?.text).toContain("# Delegation")
expect(Object.keys(event.tools).sort()).toEqual(["edit", "patch", "read", "shell", "write"])
}),
)
const event = context("gpt-5")
yield* hooks.trigger("session", "context", event)
expect(event.system[0]?.text).toContain("# Delegation")
expect(Object.keys(event.tools).sort()).toEqual(["edit", "glob", "grep", "patch", "read", "shell", "write"])
const claude = context("claude-sonnet-4-6")
yield* hooks.trigger("session", "context", claude)
expect(claude.system.map((part) => part.text)).toEqual([fallback])
expect(Object.keys(claude.tools).sort()).toEqual(["edit", "patch", "read", "shell", "write"])
}),
)
@@ -148,7 +220,7 @@ describe("SystemPromptPlugin", () => {
model.name = name
})
})
yield* SystemPromptPlugin.MetaPlugin.effect(pluginHost)
yield* OptimizePlugin.MetaPlugin.effect(pluginHost)
yield* Effect.forEach(
cases,
@@ -169,7 +241,7 @@ describe("SystemPromptPlugin", () => {
}),
)
it.effect("preserves an explicit agent system prompt", () =>
it.effect("preserves tools and an explicit agent system prompt by default", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
const hooks = yield* PluginHooks.Service
@@ -179,7 +251,7 @@ describe("SystemPromptPlugin", () => {
}),
)
const pluginHost = yield* makeHost
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
yield* Effect.forEach(OptimizePlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
discard: true,
})
const event = context("gpt-5", "Custom agent prompt")
@@ -187,29 +259,32 @@ describe("SystemPromptPlugin", () => {
yield* hooks.trigger("session", "context", event)
expect(event.system.map((part) => part.text)).toEqual(["Custom agent prompt"])
expect(Object.keys(event.tools).sort()).toEqual(["edit", "glob", "grep", "patch", "read", "shell", "write"])
}),
)
it.effect("skips the hook when agent lookup fails", () =>
it.effect("still curates tools when agent lookup fails", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
yield* OptimizePlugin.OpenAIPlugin.effect(pluginHost)
yield* OptimizePlugin.OpenAIToolsPlugin.effect(pluginHost)
yield* agents.transform((editor) => editor.remove(Agent.ID.make("build")))
const event = context("gpt-5")
yield* hooks.trigger("session", "context", event)
expect(event.system.map((part) => part.text)).toEqual([fallback])
expect(Object.keys(event.tools).sort()).toEqual(["edit", "patch", "read", "shell", "write"])
}),
)
it.effect("allows one model-lab prompt plugin to be enabled independently", () =>
it.effect("allows one model-lab optimization plugin to be enabled independently", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* SystemPromptPlugin.KimiPlugin.effect(pluginHost)
yield* OptimizePlugin.KimiPlugin.effect(pluginHost)
const gemini = context("gemini-2.5-pro")
const kimi = context("kimi-k2")
@@ -221,35 +296,41 @@ describe("SystemPromptPlugin", () => {
}),
)
it.effect("selects against the catalog ID rather than the physical model ID or family", () =>
it.effect("preserves tools for model aliases and catalog-ID prompt selection by default", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
const cases = [
["gpt-5-alias", "custom-model", undefined, "# Delegation"],
["gpt-6-alias", "custom-model", undefined, "Do not settle for a partial"],
["openai-alias", "GPT-5", undefined, fallback],
["codex-family-alias", "custom-deployment", "GPT-CODEX", fallback],
["astra-api-alias", "gpt-6-astra", undefined, fallback],
["astra-family-alias", "custom-deployment", "gpt-6", fallback],
["claude-catalog-alias", "custom-model", undefined, fallback],
["anthropic-api-alias", "Claude-Opus-4-8", undefined, fallback],
["anthropic-family-alias", "custom-deployment", "CLAUDE-SONNET", fallback],
] as const
yield* catalog.transform((editor) => {
editor.model.update(Provider.ID.make("test"), Model.ID.make("openai-alias"), (model) => {
model.modelID = Model.ID.make("gpt-5")
})
editor.model.update(Provider.ID.make("test"), Model.ID.make("gpt-5-alias"), (model) => {
model.modelID = Model.ID.make("custom-model")
})
editor.model.update(Provider.ID.make("test"), Model.ID.make("codex-family-alias"), (model) => {
model.modelID = Model.ID.make("custom-deployment")
model.family = Model.Family.make("gpt-codex")
})
for (const [id, modelID, family] of cases)
editor.model.update(Provider.ID.make("test"), Model.ID.make(id), (model) => {
model.modelID = Model.ID.make(modelID)
if (family) model.family = Model.Family.make(family)
})
})
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
const physicalOpenAI = context("openai-alias")
const physicalCustom = context("gpt-5-alias")
const familyOpenAI = context("codex-family-alias")
yield* hooks.trigger("session", "context", physicalOpenAI)
yield* hooks.trigger("session", "context", physicalCustom)
yield* hooks.trigger("session", "context", familyOpenAI)
expect(physicalOpenAI.system.map((part) => part.text)).toEqual([fallback])
expect(physicalCustom.system.map((part) => part.text)).toEqual([expect.stringContaining("# Delegation")])
expect(familyOpenAI.system.map((part) => part.text)).toEqual([fallback])
yield* Effect.forEach(OptimizePlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
yield* Effect.forEach(
cases,
([id, , , prompt]) =>
Effect.gen(function* () {
const event = context(id)
yield* hooks.trigger("session", "context", event)
expect(event.system[0]?.text).toContain(prompt)
expect(Object.keys(event.tools).sort()).toEqual(["edit", "glob", "grep", "patch", "read", "shell", "write"])
}),
{ discard: true },
)
}),
)
})
+32 -65
View File
@@ -578,73 +578,40 @@ describe("Session.create", () => {
}),
)
for (const ownership of ["none", "source", "other"] as const) {
it.effect(`replays a fork with ${ownership} ownership and stable projected identities`, () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const source = yield* session.create({
location,
title: "Source",
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: Model.ID.make("source"), providerID: Provider.ID.make("test") }),
metadata: { source: true },
})
const parentID =
ownership === "none"
? undefined
: ownership === "source"
? source.id
: (yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/owner") }),
title: "Owner",
metadata: { owner: true },
})).id
yield* session.prompt({ sessionID: source.id, text: "First", resume: false })
yield* SessionInbox.promote(db, bus, source.id, "steer")
yield* session.synthetic({ sessionID: source.id, text: "Second", resume: false })
yield* SessionInbox.promote(db, bus, source.id, "steer")
const forked = yield* session.fork({ sessionID: source.id, boundary: { type: "through" }, parentID })
expect(forked.parentID).toBe(parentID)
expect(forked).toMatchObject({
title: "Source (fork #1)",
agent: source.agent,
model: source.model,
metadata: source.metadata,
location: source.location,
fork: { sessionID: source.id },
})
const original = (yield* session.context(forked.id)).map((message) => message.id)
const recorded = yield* db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, forked.id))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
expect(recorded.map((event) => event.type)).toEqual(
parentID ? ["session.created.1", "session.forked.2"] : ["session.forked.2"],
)
it.effect("replays a fork with stable projected identities", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location, title: "Parent" })
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "Second", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const original = (yield* session.context(forked.id)).map((message) => message.id)
const recorded = yield* db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, forked.id))
.get()
.pipe(Effect.orDie)
if (!recorded) return yield* Effect.die(new Error("Fork event not found"))
yield* bus.remove(forked.id)
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run().pipe(Effect.orDie)
yield* Effect.forEach(recorded, (event) =>
bus.replay({
id: event.id,
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}),
)
yield* bus.remove(forked.id)
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run().pipe(Effect.orDie)
yield* bus.replay({
id: recorded.id,
created: recorded.created,
aggregateID: recorded.aggregate_id,
seq: recorded.seq,
type: recorded.type,
data: recorded.data,
})
expect((yield* session.context(forked.id)).map((message) => message.id)).toEqual(original)
expect(yield* session.get(forked.id)).toEqual(forked)
}),
)
}
expect((yield* session.context(forked.id)).map((message) => message.id)).toEqual(original)
}),
)
it.effect("inherits instruction entries when forking", () =>
Effect.gen(function* () {
@@ -35,7 +35,7 @@ import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { OptimizePlugin } from "@opencode-ai/core/plugin/optimize"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
@@ -193,7 +193,7 @@ describe("SessionRunnerLLM recorded", () => {
catalog: catalogHost(catalog),
session: { hook: (name, callback) => hooks.register("session", name, callback) },
})
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
yield* Effect.forEach(OptimizePlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
+2 -2
View File
@@ -51,7 +51,7 @@ import { SessionUsage } from "@opencode-ai/core/session/usage"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { OptimizePlugin } from "@opencode-ai/core/plugin/optimize"
import { QuestionTool } from "@opencode-ai/core/tool/plugin/question"
import { Agent } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
@@ -519,7 +519,7 @@ const setup = Effect.gen(function* () {
catalog: catalogHost(catalog),
session: { hook: (name, callback) => hooks.register("session", name, callback) },
})
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
yield* Effect.forEach(OptimizePlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
discard: true,
})
yield* agents.transform((editor) =>
+1 -287
View File
@@ -30,12 +30,11 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Permission } from "@opencode-ai/core/permission"
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { testEffect } from "./lib/effect"
@@ -178,291 +177,6 @@ const withSubagent = (location: Location.Ref) =>
})
describe("SubagentTool", () => {
for (const enabled of [undefined, false, true]) {
productionIt.live(`gates the fork parameter with experimental.subagent_fork=${enabled}`, () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* Effect.promise(() =>
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_fork: enabled } })),
)
const sessions = yield* Session.Service
const parent = yield* sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
})
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(locations.get(parent.location)))
const snapshot = yield* registry.snapshot()
const definition = snapshot.definitions.find((tool) => tool.name === SubagentTool.name)!
const context = yield* hooks.trigger("session", "context", {
sessionID: parent.id,
agent: toolIdentity.agent,
model: parentModel,
system: [],
messages: [],
tools: { subagent: { description: definition.description, input: { ...definition.inputSchema } } },
generation: {},
providerOptions: {},
})
expect(Object.keys(context.tools.subagent.input.properties ?? {})).toContain("sessionID")
expect(Object.keys(context.tools.subagent.input.properties ?? {}).includes("fork")).toBe(enabled === true)
expect(context.tools.subagent.input).toEqual(definition.inputSchema)
expect(Object.keys(definition.inputSchema.properties ?? {}).includes("fork")).toBe(enabled === true)
if (enabled === true) return
expect(Object.keys(definition.inputSchema.properties ?? {})).toEqual([
"agent",
"description",
"prompt",
"sessionID",
"background",
])
expect(definition.description).toBe(SubagentTool.description)
expect(definition.description).toContain(
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
)
expect(JSON.stringify(context.tools.subagent)).not.toMatch(/fork/i)
// An unknown field keeps the original schema's behavior; it cannot enable forking or advertise it.
const result = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-disabled-fork",
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review", fork: true },
},
})
expect(result.status).toBe("completed")
const childID = outputSessionID(result.metadata)
expect((yield* sessions.get(childID)).fork).toBeUndefined()
expect((yield* sessions.inbox(childID)).find((message) => message.type === "user")?.payload.text).toBe(
"You are a subagent spawned by another session.\nreview",
)
const continued = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-disabled-fork-continuation",
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "continue", fork: true, sessionID: childID },
},
})
expect(continued.status).toBe("completed")
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
}),
)
}
it.live("rejects fork together with sessionID without changing the child", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* Effect.promise(() =>
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_fork: true } })),
)
const sessions = yield* Session.Service
const parent = yield* sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
})
const child = yield* sessions.create({ parentID: parent.id })
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
for (const fork of [false, true]) {
const result = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: `call-conflicting-fork-${fork}`,
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review", fork, sessionID: child.id },
},
})
expect(result).toMatchObject({
status: "error",
error: { message: "Cannot use fork with sessionID. Omit one of them." },
})
}
expect(yield* sessions.get(child.id)).toEqual(child)
expect(yield* sessions.inbox(child.id)).toEqual([])
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
}),
)
completionIt.live("sends inherited history to a forked child and preserves it on continuation", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* Effect.promise(() =>
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_fork: true } })),
)
const sessions = yield* Session.Service
const parent = yield* sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
agent: toolIdentity.agent,
model: parentModel,
metadata: { source: "fork-test" },
})
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(locations.get(parent.location)))
const requests: PluginHooks.Domains["session"]["context"][] = []
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
requests.push(event)
}),
)
const bus = yield* Bus.Service
const { db } = yield* Database.Service
yield* sessions.prompt({ sessionID: parent.id, text: "Remember the project context", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const previous = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: parent.id,
assistantMessageID: previous,
agent: toolIdentity.agent,
model: parentModel,
})
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: parent.id,
assistantMessageID: previous,
finish: "tool-calls",
cost: Money.USD.zero,
tokens,
})
yield* sessions.updateMessage({
sessionID: parent.id,
messageID: previous,
content: [
{
type: "tool",
id: "call-parent-read",
name: "read",
time: { created: parent.time.created },
state: {
status: "completed",
input: { filePath: "README.md" },
content: [{ type: "text", text: "Inherited file contents" }],
},
},
],
})
const spawning = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: parent.id,
assistantMessageID: spawning,
agent: toolIdentity.agent,
model: parentModel,
})
yield* bus.publish(SessionEvent.Text.Started, { sessionID: parent.id, assistantMessageID: spawning, ordinal: 0 })
yield* bus.publish(SessionEvent.Text.Ended, {
sessionID: parent.id,
assistantMessageID: spawning,
ordinal: 0,
text: "Spawning response must not be inherited",
})
yield* sessions.prompt({ sessionID: parent.id, text: "Later parent message", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const result = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
messageID: spawning,
call: {
type: "tool-call",
id: "call-fork",
name: SubagentTool.name,
input: { agent: "reviewer", description: "fork review", prompt: "Review the file", fork: true },
},
})
expect(result.status).toBe("completed")
const child = yield* sessions.get(outputSessionID(result.metadata))
expect(child).toMatchObject({
parentID: parent.id,
title: "fork review",
agent: "reviewer",
model: childModel,
metadata: parent.metadata,
fork: { sessionID: parent.id, boundary: { type: "before", messageID: spawning } },
})
const request = requests.find((request) => request.sessionID === child.id)!
expect(request.agent).toBe(Agent.ID.make("reviewer"))
expect(request.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "user", content: [{ type: "text", text: "Remember the project context" }] }),
expect.objectContaining({
role: "assistant",
content: [expect.objectContaining({ type: "tool-call", id: "call-parent-read" })],
}),
expect.objectContaining({
role: "tool",
content: [
expect.objectContaining({
type: "tool-result",
result: { type: "text", value: "Inherited file contents" },
}),
],
}),
expect.objectContaining({
role: "user",
content: [
{
type: "text",
text: "You are a forked subagent. Use the inherited history as context and perform only the task below.\nReview the file",
},
],
}),
]),
)
expect(JSON.stringify(request.messages)).not.toContain("Spawning response must not be inherited")
expect(JSON.stringify(request.messages)).not.toContain("Later parent message")
expect((yield* sessions.context(child.id)).map((message) => message.id)).not.toContain(previous)
const continued = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-fork-continue",
name: SubagentTool.name,
input: { agent: "reviewer", description: "follow up", prompt: "Continue reviewing", sessionID: child.id },
},
})
expect(outputSessionID(continued.metadata)).toBe(child.id)
const latest = requests.findLast((request) => request.sessionID === child.id)!
expect(JSON.stringify(latest.messages)).toContain("Inherited file contents")
expect(JSON.stringify(latest.messages)).toContain("Continue reviewing")
expect(JSON.stringify(latest.messages)).not.toContain("Later parent message")
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
for (const fork of [undefined, false]) {
const fresh = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: `call-fresh-${fork}`,
name: SubagentTool.name,
input: {
agent: "fallback",
description: "fresh",
prompt: "Start fresh",
...(fork === undefined ? {} : { fork }),
},
},
})
expect(fresh.status).toBe("completed")
const freshChild = yield* sessions.get(outputSessionID(fresh.metadata))
expect(freshChild.fork).toBeUndefined()
expect(freshChild.model).toMatchObject(parentModel)
const freshRequest = requests.find((request) => request.sessionID === freshChild.id)!
expect(JSON.stringify(freshRequest.messages)).not.toContain("Inherited file contents")
expect(JSON.stringify(freshRequest.messages)).toContain("Start fresh")
}
}),
)
completionIt.live("admits one durable completion across live delivery and restart replay", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -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 })
File diff suppressed because one or more lines are too long
@@ -11,9 +11,6 @@ export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(optional).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
subagent_fork: Schema.Boolean.pipe(optional).annotate({
description: "Enable the subagent fork parameter. Defaults to false.",
}),
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
description: "Ordered policies controlling access to configured resources",
}),
-11
View File
@@ -10,17 +10,6 @@ import { AbsolutePath } from "../src/schema.js"
import { WebSearch } from "../src/websearch.js"
describe("Config.Entry", () => {
test("keeps subagent forking opt-in", () => {
const decode = Schema.decodeUnknownSync(Config.Info)
const encode = Schema.encodeSync(Config.Info)
expect(encode(decode({ experimental: {} }))).toEqual({ experimental: {} })
for (const subagent_fork of [false, true]) {
const input = { experimental: { subagent_fork } }
expect(encode(decode(input))).toEqual(input)
}
expect(() => decode({ experimental: { subagent_fork: "true" } })).toThrow()
})
test("accepts directory-only worktree config and omits it when absent", () => {
const decode = Schema.decodeUnknownSync(Config.Info)
const input = { worktree: { directory: "../worktrees" } }
@@ -177,7 +177,9 @@
padding: 4px 8px 12px;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb {
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-tree"]
.scroll-view__thumb[data-orientation="vertical"] {
width: 16px;
}
@@ -188,6 +190,20 @@
background-color: var(--v2-border-border-muted, var(--border-weak-base));
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-tree"]
.scroll-view__thumb[data-orientation="horizontal"] {
height: 16px;
opacity: 1;
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-tree"]
.scroll-view__thumb[data-orientation="horizontal"]::after {
width: auto;
height: 6px;
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-tree"]
.scroll-view__thumb:hover::after,
@@ -124,6 +124,7 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
<ScrollView
data-slot="session-review-v2-sidebar-tree"
class="group/file-tree-v2"
orientation="both"
thumbVisibility="scroll"
viewportRef={props.viewportRef}
>
+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}
/>
+36 -4
View File
@@ -15,15 +15,21 @@
outline: none;
}
.scroll-view[data-orientation="horizontal"] .scroll-view__viewport {
overflow-x: auto;
overflow-y: hidden;
}
.scroll-view[data-orientation="both"] .scroll-view__viewport {
overflow: auto;
}
.scroll-view__viewport::-webkit-scrollbar {
display: none;
}
.scroll-view__thumb {
position: absolute;
inset-inline-end: 0;
top: 0;
width: 12px;
transition: opacity 200ms ease;
cursor: default;
user-select: none;
@@ -31,7 +37,19 @@
opacity: 0;
}
.scroll-view__thumb::after {
.scroll-view__thumb[data-orientation="vertical"] {
inset-inline-end: 0;
top: 0;
width: 12px;
}
.scroll-view__thumb[data-orientation="horizontal"] {
left: 0;
bottom: 0;
height: 12px;
}
.scroll-view__thumb[data-orientation="vertical"]::after {
content: "";
position: absolute;
left: 50%;
@@ -45,6 +63,20 @@
transition: background-color 150ms ease;
}
.scroll-view__thumb[data-orientation="horizontal"]::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 4px;
transform: translateY(-50%);
border-radius: 9999px;
background-color: var(--border-weak-base);
backdrop-filter: blur(4px);
transition: background-color 150ms ease;
}
.scroll-view__thumb:hover::after,
.scroll-view__thumb[data-dragging="true"]::after {
background-color: var(--border-strong-base);
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { canScrollKey, scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
import { canScrollKey, scrollKey, scrollOffsetFromThumbPointer, scrollTopFromThumbPointer } from "./scroll-view"
describe("scrollKey", () => {
test("maps plain navigation keys", () => {
@@ -88,3 +88,24 @@ describe("scrollTopFromThumbPointer", () => {
expect(scrollTopFromThumbPointer(input)).toBeCloseTo((292 / 344) * 7200)
})
})
describe("scrollOffsetFromThumbPointer", () => {
const input = {
viewportStart: 100,
grabOffset: 10,
clientSize: 400,
scrollClientSize: 400,
scrollSize: 1_000,
thumbSize: 100,
}
test("maps horizontal pointer movement to scroll offset", () => {
expect(scrollOffsetFromThumbPointer({ ...input, pointer: 118 })).toBe(0)
expect(scrollOffsetFromThumbPointer({ ...input, pointer: 402 })).toBe(600)
})
test("reverses horizontal pointer movement for RTL", () => {
expect(scrollOffsetFromThumbPointer({ ...input, pointer: 118, reverse: true })).toBe(600)
expect(scrollOffsetFromThumbPointer({ ...input, pointer: 402, reverse: true })).toBe(0)
})
})
+147 -67
View File
@@ -8,7 +8,7 @@ export type ScrollViewThumbVisibility = "hover" | "scroll"
export interface ScrollViewProps extends ComponentProps<"div"> {
viewportRef?: (el: HTMLDivElement) => void
orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
orientation?: "vertical" | "horizontal" | "both"
/**
* `hover`: show while hovered or scrolling. `scroll`: show only while scrolling.
*
@@ -78,12 +78,37 @@ export function scrollTopFromThumbPointer(input: {
thumbHeight: number
/** Viewport height used for max scroll. Defaults to `clientHeight` (track == viewport). */
scrollClientHeight?: number
}) {
return scrollOffsetFromThumbPointer({
pointer: input.pointer,
viewportStart: input.viewportTop,
grabOffset: input.grabOffset,
clientSize: input.clientHeight,
scrollSize: input.scrollHeight,
thumbSize: input.thumbHeight,
scrollClientSize: input.scrollClientHeight,
})
}
export function scrollOffsetFromThumbPointer(input: {
pointer: number
viewportStart: number
grabOffset: number
clientSize: number
scrollSize: number
thumbSize: number
scrollClientSize?: number
reverse?: boolean
}) {
const padding = 8
const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight
if (maxThumbTop <= 0) return 0
const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop))
return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - (input.scrollClientHeight ?? input.clientHeight))
const maxThumbStart = input.clientSize - padding * 2 - input.thumbSize
if (maxThumbStart <= 0) return 0
const thumbStart = Math.max(
0,
Math.min(input.pointer - input.viewportStart - padding - input.grabOffset, maxThumbStart),
)
const progress = input.reverse ? 1 - thumbStart / maxThumbStart : thumbStart / maxThumbStart
return progress * Math.max(0, input.scrollSize - (input.scrollClientSize ?? input.clientSize))
}
export function ScrollView(props: ScrollViewProps) {
@@ -116,7 +141,8 @@ export function ScrollView(props: ScrollViewProps) {
let rootRef!: HTMLDivElement
let viewportRef!: HTMLDivElement
let thumbRef!: HTMLDivElement
let verticalThumbRef!: HTMLDivElement
let horizontalThumbRef!: HTMLDivElement
const thumbMount = () => local.thumbContainer
const thumbHover = () => local.thumbHoverTarget
@@ -124,18 +150,20 @@ export function ScrollView(props: ScrollViewProps) {
const [state, setState] = createStore({
isHovered: false,
isDragging: false,
dragging: undefined as "vertical" | "horizontal" | undefined,
isScrolling: false,
thumbHeight: 0,
thumbTop: 0,
showThumb: false,
verticalThumbSize: 0,
verticalThumbStart: 0,
showVerticalThumb: false,
horizontalThumbSize: 0,
horizontalThumbStart: 0,
showHorizontalThumb: false,
})
const isHovered = () => state.isHovered
const isDragging = () => state.isDragging
const isDragging = () => state.dragging !== undefined
const isScrolling = () => state.isScrolling
const thumbHeight = () => state.thumbHeight
const thumbTop = () => state.thumbTop
const showThumb = () => state.showThumb
const vertical = () => local.orientation === "vertical" || local.orientation === "both"
const horizontal = () => local.orientation === "horizontal" || local.orientation === "both"
let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined
@@ -157,33 +185,42 @@ export function ScrollView(props: ScrollViewProps) {
const updateThumb = () => {
if (!viewportRef) return
const { scrollTop, scrollHeight, clientHeight } = viewportRef
const trackPadding = 8
const minThumbSize = 32
if (scrollHeight <= clientHeight || scrollHeight === 0) {
setState("showThumb", false)
return
if (vertical()) {
const trackSize = Math.max(0, (thumbMount()?.clientHeight || viewportRef.clientHeight) - trackPadding * 2)
const size = trackSize
? Math.min(trackSize, Math.max((viewportRef.clientHeight / viewportRef.scrollHeight) * trackSize, minThumbSize))
: 0
const maxScroll = viewportRef.scrollHeight - viewportRef.clientHeight
const maxStart = trackSize - size
setState("showVerticalThumb", maxScroll > 0)
setState("verticalThumbSize", size)
setState(
"verticalThumbStart",
trackPadding + (maxScroll > 0 ? (viewportRef.scrollTop / maxScroll) * maxStart : 0),
)
} else {
setState("showVerticalThumb", false)
}
setState("showThumb", true)
const trackPadding = 8
const trackClientHeight = thumbMount()?.clientHeight || clientHeight
const trackHeight = trackClientHeight - trackPadding * 2
const minThumbHeight = 32
// Calculate raw thumb height based on ratio
let height = (clientHeight / scrollHeight) * trackHeight
height = Math.max(height, minThumbHeight)
const maxScrollTop = scrollHeight - clientHeight
const maxThumbTop = trackHeight - height
const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
// Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
setState("thumbHeight", height)
setState("thumbTop", boundedTop)
if (horizontal()) {
const trackSize = Math.max(0, (thumbMount()?.clientWidth || viewportRef.clientWidth) - trackPadding * 2)
const size = trackSize
? Math.min(trackSize, Math.max((viewportRef.clientWidth / viewportRef.scrollWidth) * trackSize, minThumbSize))
: 0
const maxScroll = viewportRef.scrollWidth - viewportRef.clientWidth
const maxStart = trackSize - size
const rtl = getComputedStyle(viewportRef).direction === "rtl"
const offset = Math.max(0, Math.min(rtl ? -viewportRef.scrollLeft : viewportRef.scrollLeft, maxScroll))
const start = maxScroll > 0 ? (offset / maxScroll) * maxStart : 0
setState("showHorizontalThumb", maxScroll > 0)
setState("horizontalThumbSize", size)
setState("horizontalThumbStart", trackPadding + (rtl ? maxStart - start : start))
} else {
setState("showHorizontalThumb", false)
}
}
onMount(() => {
@@ -204,6 +241,13 @@ export function ScrollView(props: ScrollViewProps) {
updateThumb()
})
createEffect(() => {
if (!horizontal() || !viewportRef) return
const observer = new MutationObserver(updateThumb)
observer.observe(viewportRef, { childList: true, subtree: true, characterData: true })
onCleanup(() => observer.disconnect())
})
createEffect(() => {
const target = thumbHover()
if (!target) return
@@ -219,58 +263,88 @@ export function ScrollView(props: ScrollViewProps) {
})
})
const onThumbPointerDown = (e: PointerEvent) => {
const onThumbPointerDown = (axis: "vertical" | "horizontal", e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
setState("isDragging", true)
const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top
setState("dragging", axis)
const thumb = axis === "vertical" ? verticalThumbRef : horizontalThumbRef
const grabOffset =
axis === "vertical"
? e.clientY - thumb.getBoundingClientRect().top
: e.clientX - thumb.getBoundingClientRect().left
const track = thumbMount() ?? viewportRef
thumbRef.setPointerCapture(e.pointerId)
thumb.setPointerCapture(e.pointerId)
const onPointerMove = (e: PointerEvent) => {
const { scrollHeight, clientHeight } = viewportRef
viewportRef.scrollTop = scrollTopFromThumbPointer({
pointer: e.clientY,
viewportTop: track.getBoundingClientRect().top,
const vertical = axis === "vertical"
const rtl = !vertical && getComputedStyle(viewportRef).direction === "rtl"
const offset = scrollOffsetFromThumbPointer({
pointer: vertical ? e.clientY : e.clientX,
viewportStart: vertical ? track.getBoundingClientRect().top : track.getBoundingClientRect().left,
grabOffset,
clientHeight: track.clientHeight,
scrollClientHeight: clientHeight,
scrollHeight,
thumbHeight: thumbHeight(),
clientSize: vertical ? track.clientHeight : track.clientWidth,
scrollClientSize: vertical ? viewportRef.clientHeight : viewportRef.clientWidth,
scrollSize: vertical ? viewportRef.scrollHeight : viewportRef.scrollWidth,
thumbSize: vertical ? state.verticalThumbSize : state.horizontalThumbSize,
reverse: rtl,
})
if (vertical) {
viewportRef.scrollTop = offset
return
}
viewportRef.scrollLeft = rtl ? -offset : offset
}
const done = (e: PointerEvent) => {
setState("isDragging", false)
thumbRef.releasePointerCapture(e.pointerId)
thumbRef.removeEventListener("pointermove", onPointerMove)
thumbRef.removeEventListener("pointerup", done)
thumbRef.removeEventListener("pointercancel", done)
setState("dragging", undefined)
thumb.releasePointerCapture(e.pointerId)
thumb.removeEventListener("pointermove", onPointerMove)
thumb.removeEventListener("pointerup", done)
thumb.removeEventListener("pointercancel", done)
}
thumbRef.addEventListener("pointermove", onPointerMove)
thumbRef.addEventListener("pointerup", done)
thumbRef.addEventListener("pointercancel", done)
thumb.addEventListener("pointermove", onPointerMove)
thumb.addEventListener("pointerup", done)
thumb.addEventListener("pointercancel", done)
}
const renderThumb = () => (
const renderVerticalThumb = () => (
<div
ref={(el) => {
thumbRef = el
verticalThumbRef = el
}}
onPointerDown={onThumbPointerDown}
onPointerDown={(event) => onThumbPointerDown("vertical", event)}
class="scroll-view__thumb"
data-orientation="vertical"
data-visible={thumbVisible()}
data-dragging={isDragging()}
data-dragging={state.dragging === "vertical"}
style={{
height: `${thumbHeight()}px`,
transform: `translateY(${thumbTop()}px)`,
height: `${state.verticalThumbSize}px`,
transform: `translateY(${state.verticalThumbStart}px)`,
"z-index": 100, // ensure it displays over content
}}
/>
)
const renderHorizontalThumb = () => (
<div
ref={(el) => {
horizontalThumbRef = el
}}
onPointerDown={(event) => onThumbPointerDown("horizontal", event)}
class="scroll-view__thumb"
data-orientation="horizontal"
data-visible={thumbVisible()}
data-dragging={state.dragging === "horizontal"}
style={{
width: `${state.horizontalThumbSize}px`,
transform: `translateX(${state.horizontalThumbStart}px)`,
"z-index": 100,
}}
/>
)
// Keybinds implementation
// We ensure the viewport has a tabindex so it can receive focus
// We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
@@ -320,6 +394,7 @@ export function ScrollView(props: ScrollViewProps) {
<div
ref={rootRef}
class={`scroll-view ${local.class || ""}`}
data-orientation={local.orientation}
style={local.style}
onPointerEnter={() => {
if (hoverRoot()) setState("isHovered", true)
@@ -363,9 +438,14 @@ export function ScrollView(props: ScrollViewProps) {
</div>
{/* Thumb Overlay — optionally portaled into an external track */}
<Show when={showThumb()}>
<Show when={thumbMount()} fallback={renderThumb()}>
{(mount) => <Portal mount={mount()}>{renderThumb()}</Portal>}
<Show when={state.showVerticalThumb}>
<Show when={thumbMount()} fallback={renderVerticalThumb()}>
{(mount) => <Portal mount={mount()}>{renderVerticalThumb()}</Portal>}
</Show>
</Show>
<Show when={state.showHorizontalThumb}>
<Show when={thumbMount()} fallback={renderHorizontalThumb()}>
{(mount) => <Portal mount={mount()}>{renderHorizontalThumb()}</Portal>}
</Show>
</Show>
</div>
+3 -1
View File
@@ -68,6 +68,7 @@ export type SelectProps<T> = Omit<
numeric?: boolean
children?: (item: T) => JSX.Element
valueClass?: string
contentClass?: string
}
export function Select<T>(props: SelectProps<T>) {
@@ -88,6 +89,7 @@ export function Select<T>(props: SelectProps<T>) {
"numeric",
"disabled",
"valueClass",
"contentClass",
"placement",
"gutter",
"sameWidth",
@@ -208,7 +210,7 @@ export function Select<T>(props: SelectProps<T>) {
</span>
</Trigger>
<Portal>
<Content data-component="menu-v2-content" data-slot="select-v2-content">
<Content class={local.contentClass} data-component="menu-v2-content" data-slot="select-v2-content">
<Listbox data-slot="select-v2-listbox" />
</Content>
</Portal>
+64 -3
View File
@@ -1,20 +1,30 @@
@property --file-tree-v2-row-overlay {
syntax: "<color>";
inherits: true;
initial-value: transparent;
}
[data-component="file-tree-v2"] {
display: flex;
flex-direction: column;
gap: 2px;
width: max-content;
min-width: 100%;
}
[data-component="file-tree-v2"] [data-slot="file-tree-v2-row"] {
--file-tree-v2-row-overlay: transparent;
box-sizing: border-box;
width: 100%;
min-width: 0;
min-width: max-content;
height: 28px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
padding-inline-end: 8px;
overflow: visible;
overflow: clip;
border: none;
border-radius: 6px;
background-color: transparent;
@@ -24,7 +34,12 @@
scroll-margin-block: 8px;
transition:
background-color 120ms ease,
color 120ms ease;
color 120ms ease,
--file-tree-v2-row-overlay 120ms ease;
}
[data-component="file-tree-v2"] [data-slot="file-tree-v2-label"] {
margin-inline-end: 12px;
}
[data-component="file-tree-v2"] [data-slot="file-tree-v2-row"][data-ignored] {
@@ -32,10 +47,14 @@
}
[data-component="file-tree-v2"] [data-slot="file-tree-v2-row"]:hover {
--file-tree-v2-row-overlay: var(--v2-overlay-simple-overlay-hover);
background-color: var(--v2-overlay-simple-overlay-hover);
}
[data-component="file-tree-v2"] [data-slot="file-tree-v2-row"][data-selected] {
--file-tree-v2-row-overlay: var(--v2-overlay-simple-overlay-pressed);
color: var(--v2-text-text-base);
background-color: var(--v2-overlay-simple-overlay-pressed);
}
@@ -44,6 +63,14 @@
background-color: var(--v2-overlay-simple-overlay-pressed);
}
[data-component="file-tree-v2"]
[data-slot="file-tree-v2-context-trigger"][data-context-menu-open]
[data-slot="file-tree-v2-row"]:not([data-selected]) {
--file-tree-v2-row-overlay: var(--v2-overlay-simple-overlay-hover);
background-color: var(--v2-overlay-simple-overlay-hover);
}
[data-component="file-tree-v2"] [data-slot="file-tree-v2-guide"] {
position: absolute;
top: 0;
@@ -110,6 +137,9 @@
[data-component="file-tree-v2"] [data-slot="file-tree-v2-change"] {
box-sizing: border-box;
flex: none;
position: sticky;
inset-inline-end: 8px;
z-index: 1;
display: flex;
flex-direction: row;
align-items: center;
@@ -126,6 +156,37 @@
font-feature-settings:
"tnum" on,
"lnum" on;
background:
linear-gradient(var(--file-tree-v2-row-overlay), var(--file-tree-v2-row-overlay)), var(--v2-background-bg-base);
}
[data-component="file-tree-v2"] [data-slot="file-tree-v2-change"]::before {
content: "";
position: absolute;
inset-block: 0;
inset-inline-end: 100%;
width: 16px;
pointer-events: none;
background:
linear-gradient(to right, transparent, var(--file-tree-v2-row-overlay)),
linear-gradient(to right, transparent, var(--v2-background-bg-base));
}
[data-component="file-tree-v2"]:dir(rtl) [data-slot="file-tree-v2-change"]::before {
background:
linear-gradient(to left, transparent, var(--file-tree-v2-row-overlay)),
linear-gradient(to left, transparent, var(--v2-background-bg-base));
}
[data-component="file-tree-v2"] [data-slot="file-tree-v2-change"]::after {
content: "";
position: absolute;
inset-block: 0;
inset-inline-start: 100%;
width: 16px;
pointer-events: none;
background:
linear-gradient(var(--file-tree-v2-row-overlay), var(--file-tree-v2-row-overlay)), var(--v2-background-bg-base);
}
[data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="modified"] {