Compare commits

...
Author SHA1 Message Date
Kit Langton c9d240704d fix(core): settle abandoned compactions before resuming sessions (#47178) 2026-09-04 01:02:29 +00:00
Kit Langton c9df4ba80d fix(util): skip unused audits during package installs (#47176) 2026-09-04 00:58:29 +00:00
David HillandLukeParkerDev ac71a55294 fix(app): keep right panel controls aligned (#46996)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 00:54:39 +00:00
Aiden Cline f84d927e07 refactor(ai): drop reasoning items when they finish (#47100) 2026-09-03 19:53:22 -05:00
d431fedce5 test(core): disable npm audits in the test preload (#47170)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 19:52:18 -05:00
Kit Langton e536b9627e feat(tui): inspect live shell output (#47134) 2026-09-04 00:47:58 +00:00
David HillandLukeParkerDev 6af46cc8a9 feat(app): add settings to vertical tabs (#47119)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 10:45:09 +10:00
David HillandLukeParkerDev c76e602ba4 fix(app): keep session tab labels stable during creation (#47099)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 10:33:51 +10:00
opencode-agent[bot]andrekram1-node 726107729e test(core): align Code Mode catalog scope assertions (#47169)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 19:30:25 -05:00
David Hill 0808ebc3c5 fix(app): refine build indicators in tab navigation (#47112) 2026-09-04 09:57:32 +10:00
David HillandLukeParkerDev 17e5c5fbf1 feat(app): show vertical sidebar shortcut hints (#47122)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-04 09:42:29 +10:00
David Hill b0a13b810e fix(app): allow new session shortcut from settings (#47123) 2026-09-04 09:42:14 +10:00
Luke Parker 6575215ddf fix(desktop): keep command palette responsive and scoped (#47164) 2026-09-04 09:41:12 +10:00
62 changed files with 2294 additions and 387 deletions
+11 -53
View File
@@ -433,7 +433,6 @@ export interface ParserState {
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly open: boolean
readonly encryptedContent: string | null | undefined
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
@@ -950,7 +949,7 @@ export const normalize = (state: ParserState, input: Event): NormalizedEvent =>
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
if (!item || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const lifecycle = Object.entries(item.summaryParts)
@@ -990,7 +989,7 @@ const startReasoningSummaryPart = (state: ParserState, itemID: string, index: nu
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item?.open) return [state, NO_EVENTS]
if (!event.delta || !item) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
@@ -1015,7 +1014,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
@@ -1074,7 +1073,6 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: true,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
@@ -1112,7 +1110,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item?.open) return [state, NO_EVENTS]
if (!item) return [state, NO_EVENTS]
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
return [
{
@@ -1247,7 +1245,6 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (item.type === "reasoning") {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
const summary: Array<string | undefined> = []
@@ -1274,53 +1271,14 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
}
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
...reasoningItem,
open: false,
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
},
},
},
events,
] satisfies StepResult
const reasoningItems = { ...state.reasoningItems }
delete reasoningItems[item.id]
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(
LLMEvent.reasoningEnd({
id: item.id,
providerMetadata: metadata,
text: itemText,
}),
)
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: false,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "concluded" },
deltaIndexes: new Set(),
},
},
},
events,
] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
events,
] satisfies StepResult
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata, text: itemText }))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
return [state, NO_EVENTS] satisfies StepResult
@@ -71,7 +71,7 @@ function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
}
describe("Open Responses basic-item lifecycles", () => {
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
it.effect("closes implicit summary boundaries", () =>
Effect.gen(function* () {
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
const events = yield* collect(
@@ -90,12 +90,6 @@ describe("Open Responses basic-item lifecycles", () => {
delta: "Third",
},
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
completed,
)
@@ -129,7 +123,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
it.effect("preserves done-only reasoning text and encryption", () =>
Effect.gen(function* () {
const item = {
type: "reasoning",
@@ -139,11 +133,6 @@ describe("Open Responses basic-item lifecycles", () => {
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "late" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "late final" },
completed,
// Route termination must also prevent events after response completion.
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_after" } },
@@ -2861,7 +2861,6 @@ describe("OpenAI Responses route", () => {
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Think" },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
@@ -0,0 +1,18 @@
import { expect, story } from "../../storybook/playwright/story"
for (const theme of ["light", "dark"]) {
story(`keeps the Open in border visible without hovering (${theme})`, async ({ mount, page }, testInfo) => {
const component = await mount("ui-split-button--open-in", { globals: { theme } })
const control = component.locator('[data-component="split-button-v2"]')
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(control).not.toHaveCSS("box-shadow", "none")
const border = await control.evaluate((element) => getComputedStyle(element).boxShadow)
await component.getByRole("button", { name: "Open options" }).hover()
await expect(control).toHaveCSS("box-shadow", border)
await page.mouse.move(0, 0)
await expect(control).toHaveCSS("box-shadow", border)
await control.screenshot({ path: testInfo.outputPath(`open-in-${theme}.png`) })
})
}
@@ -0,0 +1,47 @@
import { benchmark, expect } from "./benchmark"
import { openCommandPalette } from "../utils/command-palette"
benchmark.use({
viewport: { width: 1440, height: 900 },
serviceWorkers: "block",
traceScope: "interaction",
trace: "off",
video: "off",
})
for (const home of [false, true]) {
benchmark(`command lookup from ${home ? "home" : "session"}`, async ({ page, report }) => {
const { dialog, input } = await openCommandPalette(page, home)
const title = home ? "Open settings" : "Copy Session ID"
const query = home ? "open settings" : "copy session"
// Measure input-to-selected-result in the renderer, without assertion polling overhead.
await input.evaluate((element, title) => {
element.addEventListener(
"input",
() => {
performance.mark("palette-input")
const observer = new MutationObserver(() => {
if (document.querySelectorAll('[role="dialog"] [role="option"]').length !== 1) return
const selected = document.querySelector('[role="dialog"] [role="option"][aria-selected="true"]')
if (!selected?.textContent?.includes(title)) return
performance.measure("palette-result", "palette-input")
observer.disconnect()
})
observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true })
},
{ once: true, capture: true },
)
}, title)
await input.fill(query)
await expect(dialog.getByRole("option")).toHaveCount(1)
await expect(dialog.getByRole("option", { name: new RegExp(`^${title}(?:$| )`) })).toHaveAttribute(
"aria-selected",
"true",
)
const result = await page.evaluate(() =>
performance.getEntriesByName("palette-result").map((entry) => entry.duration),
)
expect(result).toHaveLength(1)
report({ inputToResultMs: result[0] }, { home, query, data: "fixture; immediate server responses" })
})
}
@@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test"
import { openCommandPalette, paletteSession } from "../utils/command-palette"
test.use({ serviceWorkers: "block" })
test("failed event-driven reads report an error and recover without an unhandled rejection", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
const palette = await openCommandPalette(page)
const path = `**/api/session/${paletteSession.id}`
await page.route(path, (route) => route.abort("failed"))
const requested = page.waitForRequest(path)
await page.evaluate((sessionID) => {
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([
{
id: "evt_failed_refresh",
created: 2,
type: "session.viewed",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, idle: 2 },
},
])
}, paletteSession.id)
await requested
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await palette.input.fill("copy session")
await expect(palette.dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await palette.input.press("Escape")
await page.unroute(path)
await page.evaluate((sessionID) => {
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([
{
id: "evt_recovered_refresh",
created: 3,
type: "session.renamed",
durable: { aggregateID: sessionID, seq: 2, version: 1 },
data: { sessionID, title: "Recovered session" },
},
])
}, paletteSession.id)
await expect(page.getByRole("heading", { name: "Recovered session", exact: true })).toBeVisible()
expect(errors).toEqual([])
})
@@ -0,0 +1,102 @@
import { expect, test } from "@playwright/test"
import { captureConsoleWarnings, openCommandPalette, paletteSession } from "../utils/command-palette"
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
test("copies the session ID while file and session searches are still pending", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const { dialog, input } = await openCommandPalette(page)
const release = Promise.withResolvers<void>()
await page.route(/\/api\/(session\?|fs\/find\?)/, async (route) => {
await release.promise
await route.fallback()
})
await input.pressSequentially("copy session")
const copy = dialog.getByRole("option", { name: "Copy Session ID", exact: true })
await expect(copy).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(dialog).toHaveCount(0)
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(paletteSession.id)
await expect(page.locator('[data-testid^="toast-v2-"] [data-slot="icon-svg"]')).toBeVisible()
expect(warnings).toEqual([])
release.resolve()
})
test("home commands do not wait for session search", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page, true)
const release = Promise.withResolvers<void>()
await page.route("**/api/session?*", async (route) => {
await release.promise
await route.fallback()
})
await input.fill("open settings")
await expect(dialog.getByRole("option")).toHaveCount(1)
await expect(dialog.getByRole("option", { name: /^Open settings/ })).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(page).toHaveURL("/settings")
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
release.resolve()
})
test("appends search results without resetting the selected command", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const files = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
await page.route("**/api/fs/find?*", async (route) => {
await files.promise
await route.fulfill({ json: { data: [{ path: "copy.txt", type: "file" }] } })
})
await page.route("**/api/session?*", async (route) => {
await sessions.promise
await route.fulfill({
json: {
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "Copy fixture" }],
},
})
})
await input.fill("copy")
const project = dialog.getByRole("option", { name: "Copy Project ID", exact: true })
await expect(project).toBeVisible()
// Select a non-first command with the keyboard before remote results arrive.
await input.press("ArrowDown")
await expect(project).toHaveAttribute("aria-selected", "true")
files.resolve()
await expect(dialog.getByRole("option", { name: "/ copy.txt", exact: true })).toBeVisible()
await expect(project).toHaveAttribute("aria-selected", "true")
// File results are usable even while sessions are still pending.
sessions.resolve()
await expect(dialog.getByRole("option", { name: /Copy fixture/ })).toBeVisible()
await expect(project).toHaveAttribute("aria-selected", "true")
await input.fill("copy session")
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0)
})
test("keeps the automatically selected file when session results arrive later", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const sessions = Promise.withResolvers<void>()
await page.route("**/api/fs/find?*", (route) =>
route.fulfill({ json: { data: [{ path: "README.md", type: "file" }] } }),
)
await page.route("**/api/session?*", async (route) => {
await sessions.promise
await route.fulfill({
json: {
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "README work" }],
},
})
})
await input.fill("README")
const file = dialog.getByRole("option", { name: "/ README.md", exact: true })
await expect(file).toHaveAttribute("aria-selected", "true")
sessions.resolve()
await expect(dialog.getByRole("option", { name: /README work/ })).toBeVisible()
await expect(file).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(dialog).toHaveCount(0)
await expect(page.getByRole("tab", { name: "README.md", exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
})
@@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test"
import { captureConsoleWarnings, openCommandPalette } from "../utils/command-palette"
test.use({ serviceWorkers: "block", video: "off" })
test("opening and closing files does not duplicate tab commands", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const palette = await openCommandPalette(page)
await page.route("**/api/fs/find?*", (route) =>
route.fulfill({
headers: { "access-control-allow-origin": "*" },
json: { data: [{ path: "fixture.txt", type: "file" }] },
}),
)
await palette.input.fill("fixture.txt")
await palette.dialog.getByRole("option", { name: /fixture\.txt/ }).click()
const file = page.getByRole("tab", { name: /fixture\.txt/ })
await expect(file).toBeVisible()
await expect(palette.dialog).toHaveCount(0)
await page
.getByRole("complementary", { name: "Review and files" })
.getByRole("button", { name: "Close tab", exact: true })
.click()
await expect(file).toHaveCount(0)
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
expect(warnings).toEqual([])
})
test("navigation replaces commands without retaining disposed owners", async ({ page }) => {
const warnings = captureConsoleWarnings(page)
const palette = await openCommandPalette(page, true)
await palette.input.press("Escape")
await expect(palette.dialog).toHaveCount(0)
await page
.getByRole("region", { name: "Recent sessions" })
.getByRole("button", { name: /Palette fixture session/ })
.click()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await page.keyboard.press("ControlOrMeta+t")
await expect(page).toHaveURL(/\/new-session\?/)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await page.locator('[data-component="composer-editor"]').blur()
await page.keyboard.press("Control+l")
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
await page.keyboard.press("ControlOrMeta+Shift+P")
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox")).toBeFocused()
await expect(dialog.getByRole("textbox")).toHaveAttribute("placeholder", "Search files, commands, and sessions")
await dialog.getByRole("textbox").fill("copy session")
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveCount(0)
await dialog.getByRole("textbox").press("Escape")
await expect(dialog).toHaveCount(0)
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
for (const count of [3, 4]) {
await page.getByRole("button", { name: "New session", exact: true }).click()
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(count)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
}
await page.setViewportSize({ width: 600, height: 800 })
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-titlebar-tab-link]')).toHaveCount(4)
await page.setViewportSize({ width: 1280, height: 800 })
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toHaveCount(4)
await page.keyboard.press("ControlOrMeta+w")
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(3)
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+Shift+P")
await expect(dialog.getByRole("textbox")).toBeFocused()
await dialog.getByRole("textbox").fill("copy session")
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
expect(warnings).toEqual([])
})
@@ -26,6 +26,53 @@ for (const viewport of [
{ name: "desktop", width: 1280, height: 900 },
{ name: "mobile", width: 390, height: 844 },
]) {
test(`keeps Session in the tab until the generated title arrives on ${viewport.name}`, async ({ page }, testInfo) => {
await page.setViewportSize(viewport)
const mock = await openDraft(page, { untitled: true })
const label = page.locator(
viewport.name === "mobile"
? '[data-slot="mobile-tab-title"]'
: '[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]',
)
await expect(label).toHaveText("Session")
const pending = await submitPending(page, mock)
const spinner = page.locator(
viewport.name === "mobile"
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
)
await expect(spinner).toBeVisible()
await testInfo.attach("pending-tab-title", {
body: await page.screenshot(),
contentType: "image/png",
})
await expect(label).toHaveText("Session")
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect(pending.shimmer).toHaveCount(0)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(label).toHaveText("Session")
if (viewport.name === "mobile") {
await label.click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
await expect(tab.locator("[data-titlebar-tab-title]")).toHaveText("Session")
await tab.click()
await expect(drawer).toBeHidden()
}
mock.events.push({
id: "evt_generated_title",
type: "session.renamed",
created: Date.now(),
location: { directory: workspace },
durable: { aggregateID: pending.sessionID, seq: 1, version: 1 },
data: { sessionID: pending.sessionID, title: "Generated session title" },
})
await expect(label).toHaveText("Generated session title")
})
test(`shows a pending workspace session immediately on ${viewport.name}`, async ({ page }, testInfo) => {
await page.setViewportSize(viewport)
const mock = await openDraft(page)
@@ -404,13 +451,14 @@ async function draftFollowUp(page: Page) {
async function openDraft(
page: Page,
options?: { failSessionCreate?: boolean; command?: boolean; events?: () => OpenCodeEvent[] },
options?: { failSessionCreate?: boolean; untitled?: boolean; command?: boolean; events?: () => OpenCodeEvent[] },
) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
const worktreeRequests: Record<string, unknown>[] = []
const creates: Record<string, unknown>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const events: OpenCodeEvent[] = []
const project = {
id: projectID,
worktree: directory,
@@ -437,7 +485,7 @@ async function openDraft(
sessions,
pageMessages: () => ({ items: [] }),
onPrompt: (input) => prompts.push(input),
events: options?.events,
events: options?.events ?? (() => events.splice(0)),
})
page.on("request", (request) => {
if (request.method() !== "POST") return
@@ -464,7 +512,10 @@ async function openDraft(
return route.fulfill({ status: 500, json: { message: "Session creation failed in the fixture" }, headers })
}
if (typeof body.id !== "string") throw new Error("Session creation must use the client-reserved ID")
const session = currentSession({ ...body, id: body.id, projectID, title: "Created workspace session" }, workspace)
const session = currentSession(
{ ...body, id: body.id, projectID, title: options?.untitled ? "" : "Created workspace session" },
workspace,
)
sessions.push(session)
return route.fulfill({ json: { data: session }, headers })
})
@@ -532,7 +583,7 @@ async function openDraft(
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
return { worktree, worktreeRequests, calls, creates, prompts }
return { worktree, worktreeRequests, calls, creates, prompts, events }
}
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>, prompt = text) {
@@ -1,6 +1,7 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import { createMockServerHandler } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097"
@@ -8,6 +9,8 @@ const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
const childB = { ...session("ses_server_b_child", sessionB.directory, "Server B subagent"), parentID: sessionB.id }
test.use({ serviceWorkers: "block" })
test("tab busy indicator reflects activity in the tab session family", async ({ page }, info) => {
await mockServers(page)
await page.addInitScript(
@@ -27,7 +30,7 @@ test("tab busy indicator reflects activity in the tab session family", async ({
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(hrefB)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
// The parent is idle, but its tab remains active while the background child runs.
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
@@ -52,65 +55,61 @@ function session(id: string, directory: string, title: string) {
}
async function mockServers(page: Page) {
// Both servers stay connected while the client hydrates their active-session snapshots.
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
const servers = new Map(
[sessionA, sessionB].map(
(current) =>
[
current === sessionA ? serverA : serverB,
createMockServerHandler({
directory: current.directory,
project: {
id: current.projectID,
worktree: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
},
sessions: current === sessionB ? [current, childB] : [current],
sessionStatus: current === sessionB ? { [childB.id]: { type: "running" } } : {},
provider: { all: [], connected: [], default: {} },
pageMessages: () => ({ items: [] }),
}),
] as const,
),
)
page.on("close", () => servers.forEach((server) => void server.dispose()))
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const server = servers.get(url.origin)
if (!server) return route.fallback()
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [childB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session")
return json(route, {
data: url.origin === serverB ? [currentSession(current), currentSession(childB)] : [currentSession(current)],
cursor: {},
if (directory && directory !== current.directory)
return route.fulfill({
status: 500,
json: { name: "InvalidDirectory" },
headers: { "access-control-allow-origin": "*" },
})
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: current.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
const project = {
id: current.projectID,
canonical: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
}
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
if (route.request().method() === "OPTIONS")
return route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
})
return json(route, {})
})
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
const body = route.request().postDataBuffer()
const response = await server.handler(
new Request(url, {
method: route.request().method(),
headers: route.request().headers(),
body: body ? Uint8Array.from(body) : undefined,
}),
)
return route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
body: Buffer.from(await response.arrayBuffer()),
})
})
}
@@ -0,0 +1,189 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test, type Locator } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewTogglePosition"
const sessionID = "ses_review_toggle_position"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_review_toggle_position",
worktree: directory,
vcs: "git",
name: "review-toggle-position",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "review-toggle-position",
projectID: "proj_review_toggle_position",
directory,
title: "Review toggle position",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
})
for (const width of [1000, 1440]) {
for (const direction of ["ltr", "rtl"] as const) {
test(`keeps the review toggle at the outer header edge (${width}px, ${direction})`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Review toggle position")
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
const header = page.locator("[data-session-title]")
const panel = page.locator("#review-panel")
await expect(toggle).toHaveAttribute("aria-expanded", "false")
const closed = await toggle.boundingBox()
if (!closed) throw new Error("Review toggle bounds are unavailable")
const headerBox = await header.boundingBox()
if (!headerBox) throw new Error("Session header bounds are unavailable")
expect(closed.y).toBeGreaterThanOrEqual(headerBox.y)
expect(closed.y + closed.height).toBeLessThanOrEqual(headerBox.y + headerBox.height)
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(panel).toHaveAttribute("aria-hidden", "false")
await expect(toggle).toHaveCount(1)
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
await expect
.poll(async () => {
const box = await panel.boundingBox()
if (!box) return false
return (
closed.x >= box.x &&
closed.x + closed.width <= box.x + box.width &&
closed.y >= box.y &&
closed.y + closed.height <= box.y + 52
)
})
.toBe(true)
await expect
.poll(async () => {
const box = await panel.locator('[data-slot="session-side-panel-actions"]').boundingBox()
return box ? box.y + box.height / 2 : undefined
})
.toBe(closed.y + closed.height / 2)
await toggle.press("Enter")
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toggle).toBeFocused()
await expect(toggle).toHaveCount(1)
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
})
test(`keeps terminal controls clear of the review toggle (${width}px, ${direction})`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const ptys: { id: string; title: string }[] = []
const removed: string[] = []
await page.route("**/api/pty**", async (route) => {
const path = new URL(route.request().url()).pathname
const location = { directory, project: { id: "proj_review_toggle_position", directory } }
if (route.request().method() === "DELETE") {
removed.push(path.split("/").at(-1)!)
return route.fulfill({ status: 204 })
}
if (path.endsWith("/connect-token")) {
return route.fulfill({ json: { location, data: { ticket: "e2e-ticket", expires_in: 60 } } })
}
if (path === "/api/pty" && route.request().method() === "POST") {
const pty = { id: `pty_review_${ptys.length + 1}`, title: `Terminal ${ptys.length + 1}` }
ptys.push(pty)
return route.fulfill({ json: { location, data: pty } })
}
return route.fulfill({ json: { location, data: ptys.find((pty) => path.endsWith(pty.id)) ?? ptys } })
})
await page.routeWebSocket(/\/api\/pty\/pty_review_\d+\/connect/, () => undefined)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Review toggle position")
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await page.keyboard.press("Control+Backquote")
const terminal = page.getByRole("region", { name: "Terminal", exact: true })
await expect(terminal.getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
for (const number of [2, 3, 4]) {
await terminal.getByRole("button", { name: "New terminal", exact: true }).click()
await expect(terminal.getByRole("tab", { name: `Terminal ${number}`, exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
}
await expect
.poll(async () => {
const tabs = await terminal.getByRole("tablist").boundingBox()
const button = await toggle.boundingBox()
if (!tabs || !button) return false
return direction === "rtl" ? tabs.x >= button.x + button.width : tabs.x + tabs.width <= button.x
})
.toBe(true)
await expectTerminalControlsAligned(terminal, toggle)
const fourth = terminal.locator('[data-slot="tabs-trigger-wrapper"][data-value="pty_review_4"]')
await fourth.getByRole("button", { name: "Close terminal", exact: true }).click()
await expect(terminal.getByRole("tab")).toHaveText(["Terminal 1", "Terminal 2", "Terminal 3"])
expect(removed).toEqual(["pty_review_4"])
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await terminal.getByRole("button", { name: "New terminal", exact: true }).click()
await expect(terminal.getByRole("tab", { name: "Terminal 5", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await expect(toggle).toHaveAttribute("aria-expanded", "false")
const position = await toggle.boundingBox()
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "false")
await expect.poll(() => toggle.boundingBox()).toEqual(position)
await expect
.poll(async () => {
const actions = await page.locator('[data-slot="session-side-panel-actions"]').boundingBox()
const button = await toggle.boundingBox()
if (!actions || !button) return undefined
return actions.y + actions.height / 2 - (button.y + button.height / 2)
})
.toBe(0)
await toggle.press("Enter")
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toggle).toBeFocused()
await expect.poll(() => toggle.boundingBox()).toEqual(position)
await expectTerminalControlsAligned(terminal, toggle)
})
}
}
async function expectTerminalControlsAligned(terminal: Locator, toggle: Locator) {
await expect
.poll(async () => {
const centers = await Promise.all(
[terminal.getByRole("button", { name: "New terminal", exact: true }), toggle].map((button) =>
button.locator("svg").evaluate((element) => {
const svg = element as SVGSVGElement
const path = svg.getBBox()
return new DOMPoint(path.x + path.width / 2, path.y + path.height / 2).matrixTransform(svg.getScreenCTM()!)
.y
}),
),
)
return centers[0]! - centers[1]!
})
.toBeCloseTo(0, 1)
}
@@ -24,7 +24,7 @@ for (const direction of ["ltr", "rtl"] as const) {
const header = page.locator("[data-session-title]")
const more = header.getByRole("button", { name: "More options", exact: true })
const project = header.getByRole("button", { name: fixture.project.name, exact: true })
const review = header.getByRole("button", { name: "Toggle review", exact: true })
const review = page.getByRole("button", { name: "Toggle review", exact: true })
const details = header.getByRole("button", { name: "Session details", exact: true })
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
@@ -27,6 +27,12 @@ test.beforeEach(async ({ page }) => {
})),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
@@ -50,6 +56,34 @@ test("settings has its own route and returns through app history", async ({ page
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeFocused()
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(1)
})
test("recording a new session shortcut stays in settings until recording finishes", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
const binding = settings.locator('[data-keybind-id="tab.new"]')
await binding.click()
await expect(binding).toHaveText("Press keys")
await page.keyboard.press("Control+t")
await expect(binding).toHaveText("Ctrl+T")
await expect(page).toHaveURL("/settings")
await expect(page.locator("[data-titlebar-tab]")).toHaveCount(0)
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
@@ -184,7 +184,9 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabA).toContainText(sessionA.title)
await expect(tabB).toContainText(sessionB.title)
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
await expect(sidebar.getByRole("button", { name: "Home", exact: true })).toHaveText("Home")
await expect(
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
).toBeVisible()
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
const status = sidebar.getByRole("button", { name: "Status", exact: true })
@@ -230,6 +232,162 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabB).toBeVisible()
})
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
...Array.from({ length: 24 }, (_, index) => ({
type: "draft",
server,
directory,
draftID: `draft_scroll_${index}`,
})),
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
)
await page.goto("/")
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const settings = sidebar.getByRole("button", { name: "Settings", exact: true })
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
await expect(settings).toHaveText("Settings")
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 360 })
await expect(settings).toBeInViewport({ ratio: 1 })
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
await expect(settings).toHaveCSS("margin-top", "8px")
await expect
.poll(() =>
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
const content = Math.max(
0,
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
)
return element.getBoundingClientRect().height - content
}),
)
.toBe(0)
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
await scroll.evaluate((element) => element.scrollTo(0, 0))
await expect(scroll).toHaveJSProperty("scrollTop", 0)
const pinned = await settings.boundingBox()
await scroll.hover()
await page.mouse.wheel(0, 200)
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
await testInfo.attach(`vertical-tabs-settings-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
await scroll.evaluate((element) => element.scrollTo(0, element.scrollHeight))
await expect(tabB).toBeInViewport({ ratio: 1 })
await expect
.poll(async () => {
const tab = await tabB.boundingBox()
const viewport = await scroll.boundingBox()
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect.poll(() => settings.boundingBox()).toEqual(pinned)
}
await settings.click()
await expect(page.getByTestId("settings-screen")).toBeVisible()
await expect(settings).toHaveAttribute("aria-pressed", "true")
await sidebar.getByRole("button", { name: "Home", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeHidden()
await settings.focus()
await settings.press("Enter")
await expect(page.getByTestId("settings-screen")).toBeVisible()
})
}
for (const profile of [
{ locale: "en", direction: "ltr" },
{ locale: "en", direction: "rtl" },
{ locale: "ar", direction: "rtl" },
]) {
test(`vertical shortcut hints align at the row end: ${profile.locale} ${profile.direction}`, async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionID, locale }) => {
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
localStorage.setItem(
"settings.v3",
JSON.stringify({
appearance: { tabLayout: "vertical" },
keybinds: { "home.toggle": "ctrl+alt+h", "tab.new": "ctrl+shift+n" },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: sessionA.id, locale: profile.locale },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar).toHaveCSS("width", "260px")
await page
.locator("html")
.evaluate((element, direction) => element.setAttribute("dir", direction), profile.direction)
await expect(sidebar).toHaveCSS("direction", profile.direction)
for (const row of [
{ action: "home", shortcut: "Ctrl+Alt+H" },
{ action: "new-session", shortcut: "Ctrl+Shift+N" },
]) {
const button = sidebar.locator(`[data-action="vertical-tabs-${row.action}"]`)
const hint = button.locator('span[aria-hidden="true"]')
await expect(hint).toHaveText(row.shortcut)
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
await expect(hint).toHaveCSS("opacity", "0")
await button.hover()
await expect(hint).toHaveCSS("opacity", "1")
await expect
.poll(() =>
hint.evaluate((element) => {
const button = element.closest("button")!
const row = button.getBoundingClientRect()
const hint = element.getBoundingClientRect()
return getComputedStyle(button).direction === "rtl" ? hint.left - row.left : row.right - hint.right
}),
)
.toBeCloseTo(8, 1)
await page.getByRole("main").hover()
await expect(hint).toHaveCSS("opacity", "0")
}
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
const newSession = sidebar.locator('[data-action="vertical-tabs-new-session"]')
await home.focus()
await page.keyboard.press("Tab")
await expect(newSession).toBeFocused()
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await page.keyboard.press("Shift+Tab")
await expect(home).toBeFocused()
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
})
}
test("dedicated experimental settings control vertical tab details", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
+57
View File
@@ -0,0 +1,57 @@
import { expect, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "./mock-server"
import { APP_READY_TIMEOUT } from "./waits"
export const paletteSession = {
id: "ses_command_palette",
projectID: "proj_command_palette",
directory: "C:/OpenCode/CommandPalette",
title: "Palette fixture session",
time: { created: 1700000000000, updated: 1700000000000 },
}
export function captureConsoleWarnings(page: Page) {
const warnings: string[] = []
page.on("console", (message) => {
if (message.type() !== "warning" && message.type() !== "error") return
// This message comes from test isolation, not application code.
if (message.text() === "Service Worker registration blocked by Playwright") return
warnings.push(message.text())
})
return warnings
}
export async function openCommandPalette(page: Page, home = false) {
await mockOpenCodeServer(page, {
directory: paletteSession.directory,
project: {
id: paletteSession.projectID,
worktree: paletteSession.directory,
vcs: "git",
name: "command-palette",
time: paletteSession.time,
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [paletteSession],
pageMessages: () => ({ items: [] }),
findFiles: () => [],
})
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(home ? "/" : `/server/${base64Encode(server)}/session/${paletteSession.id}`)
if (home) {
await expect(
page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }),
).toBeEnabled({ timeout: APP_READY_TIMEOUT })
}
if (!home) {
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable({ timeout: APP_READY_TIMEOUT })
}
await page.keyboard.press("ControlOrMeta+Shift+P")
const dialog = page.getByRole("dialog")
const input = dialog.getByRole("textbox")
await expect(input).toBeFocused()
await expect(dialog.getByRole("option")).not.toHaveCount(0)
return { dialog, input }
}
@@ -52,10 +52,9 @@ export function HomeCommandPalette(props: {
}
if (item.type === "session") props.onSelectSession(item)
}
const loadItems = async (text: string) => {
const query = text.trim()
const items = (query: string) => {
if (!query) return commandEntries().slice(0, 5)
return [...commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query)), ...(await sessions(query))]
return commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, query))
}
onCleanup(() => {
@@ -66,7 +65,8 @@ export function HomeCommandPalette(props: {
return (
<CommandPaletteView
placeholder={language.t("palette.search.placeholder.home")}
loadItems={loadItems}
items={items}
sources={[sessions]}
highlight={highlight}
select={select}
close={() => dialog.close()}
+4
View File
@@ -812,6 +812,10 @@ export const dict = {
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
"titlebar.channel.local": "Local",
"titlebar.channel.dev": "Dev",
"titlebar.channel.beta": "Beta",
"titlebar.toggleDebugTools": "Toggle debug tools",
"titlebar.updateVersion": "Update {{version}}",
"common.closeTab": "Close tab",
@@ -13,6 +13,9 @@ import { createServerNotificationState } from "@/shell/notifications/notificatio
import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
import { ModelState } from "./persistence"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "./errors"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -127,6 +130,7 @@ function createServerController(
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
) {
const language = useLanguage()
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const source = createData({
@@ -137,6 +141,13 @@ function createServerController(
},
connection: sdk.connection,
directory: "",
onError(error) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(error, language.t),
})
},
})
const data = createDesktopData({
data: source,
@@ -321,9 +321,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}),
tab &&
fileCommand({
id: "tab.close",
id: "file.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
keybind: settings.keybinds.get("tab.close") ?? "mod+w",
when: (event) => !(event.target instanceof Element && event.target.closest('[data-component="terminal"]')),
onSelect: closeTab,
}),
].filter((v) => !!v)
@@ -219,7 +219,7 @@ export function SessionSidePanel(props: {
return active !== "review" && active !== "context" && active !== "empty"
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
createEffect(() => {
if (!file.ready()) return
@@ -427,11 +427,16 @@ export function SessionSidePanel(props: {
</div>
</Tabs.List>
<div
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
data-slot="session-side-panel-actions"
class="session-review-v2-open-in-app-slot self-start shrink-0 flex items-center gap-2 pe-3"
classList={{ "h-[51px]": props.stacked, "h-12": !props.stacked }}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<OpenInAppButton directory={projectDirectory} />
<Show when={reviewOpen()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</div>
</div>
+1 -1
View File
@@ -19,7 +19,7 @@ export function SortableTab(props: {
const file = useFile()
const language = useLanguage()
const command = useCommand()
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
const sortable = useSortable({
get id() {
return props.tab
@@ -3,6 +3,28 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useCommand } from "@/shell/commands/command"
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { useLanguage } from "@/runtime/i18n/language"
import { useSessionLayout } from "@/session/session-layout"
export function SessionReviewToggle() {
const command = useCommand()
const language = useLanguage()
const { view } = useSessionLayout()
return (
<SessionHeaderActions
state={{
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: true,
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}}
/>
)
}
export type SessionHeaderActionsState = {
reviewLabel: string
@@ -1,31 +1,19 @@
import { createMemo, Show } from "solid-js"
import { Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useSessionLayout } from "@/session/session-layout"
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
export function SessionHeader() {
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const { view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const actions = createMemo<SessionHeaderActionsState>(() => ({
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: isDesktop(),
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}))
return (
<>
<TitlebarRight>
@@ -35,7 +23,9 @@ export function SessionHeader() {
</Tooltip>
</Show>
</TitlebarRight>
<SessionHeaderActions state={actions()} />
<Show when={isDesktop() && !view().reviewPanel.opened()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</>
)
}
+13 -2
View File
@@ -28,6 +28,7 @@ import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
const SessionMobileFiles = lazy(async () => {
@@ -274,10 +275,19 @@ export function SessionScreen(props: { session: SessionModel }) {
<>
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
{/* Keep the control outside panel animations; the terminal's 52px header includes a 1px divider. */}
<Show when={isDesktop() && messagesReady() && session.identity.params.id}>
<div
class="absolute end-3 top-0 z-30 flex items-center"
classList={{ "h-[51px]": sideTerminalVisible(), "h-12": !sideTerminalVisible() }}
data-slot="session-review-toggle"
>
<SessionReviewToggle />
</div>
</Show>
<div
classList={{
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]":
true,
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!screen.size.active() && sidePresence.animate(),
"transition-none": screen.size.active() || !sidePresence.animate(),
@@ -408,6 +418,7 @@ export function SessionScreen(props: { session: SessionModel }) {
present={store.sideTerminalPresent}
animate={sidePresence.animate() || sideMotion().animateTerminal}
contentHeight={screen.side.terminal.contentHeight()}
reserveReviewToggle={!screen.side.region.open()}
/>
</div>
</div>
+51 -40
View File
@@ -45,6 +45,7 @@ export function TerminalPanel(
contentHeight?: string
embedded?: boolean
animate?: boolean
reserveReviewToggle?: boolean
} = {},
) {
const terminal = useTerminal()
@@ -249,7 +250,10 @@ export function TerminalPanel(
when={terminal.ready() || store.surfaces.length > 0}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
<div
class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden"
classList={{ "pe-12": props.reserveReviewToggle }}
>
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
@@ -291,46 +295,53 @@ export function TerminalPanel(
}}
>
<div class="flex flex-col h-full">
<Tabs
variant="panel"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-[52px] !flex-none"
>
<Tabs.List
ref={tabList}
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
const active = document.activeElement
if (event.target === active) return
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
}}
<div class="h-[52px] shrink-0 flex border-b border-border-weaker-base">
<Tabs
variant="panel"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-full min-w-0 !flex-1"
>
<For each={all()}>
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
</For>
<div class="h-full flex items-center justify-center">
<Tooltip
value={
<>
{language.t("command.terminal.new")}
<Show when={newTerminalKeybind().length > 0}>
<Keybind keys={newTerminalKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButton
icon={<Icon name="plus-small" size="large" />}
variant="ghost"
onClick={() => terminal.new({ focus: true })}
aria-label={language.t("command.terminal.new")}
/>
</Tooltip>
</div>
</Tabs.List>
</Tabs>
<Tabs.List
ref={tabList}
class="!border-b-0"
onPointerDown={(event: PointerEvent & { currentTarget: HTMLDivElement }) => {
const active = document.activeElement
if (event.target === active) return
if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur()
}}
>
<For each={all()}>
{(pty, index) => <SortableTerminalTab terminal={pty} index={index()} onClose={close} />}
</For>
<div class="h-full flex items-center justify-center">
<Tooltip
value={
<>
{language.t("command.terminal.new")}
<Show when={newTerminalKeybind().length > 0}>
<Keybind keys={newTerminalKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButton
icon={<Icon name="plus-small" size="large" />}
variant="ghost"
onClick={() => terminal.new({ focus: true })}
aria-label={language.t("command.terminal.new")}
/>
</Tooltip>
</div>
</Tabs.List>
</Tabs>
{/* Reserve outside the scroll viewport so overflowing tabs cannot cover the toggle. */}
<Show when={props.reserveReviewToggle}>
<div class="w-12 shrink-0" aria-hidden />
</Show>
</div>
<div class="flex-1 min-h-0 relative">
<For each={store.surfaces}>
{(surface) => (
+1 -5
View File
@@ -1,4 +1,4 @@
import { Component, createEffect, createMemo, For, Show, onCleanup, onMount, startTransition } from "solid-js"
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
@@ -22,7 +22,6 @@ import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useCommand } from "@/shell/commands/command"
import { useSettingsSurface } from "./surface"
import "@/settings/settings.css"
@@ -50,7 +49,6 @@ const sections = [
export const SettingsScreen: Component = () => {
const language = useLanguage()
const dialog = useDialog()
const command = useCommand()
const surface = useSettingsSurface()
const layout = useLayout()
const servers = useServers()
@@ -59,10 +57,8 @@ export const SettingsScreen: Component = () => {
let root: HTMLDivElement | undefined
onMount(() => {
command.keybinds(false)
root?.focus({ preventScroll: true })
})
onCleanup(() => command.keybinds(true))
const server = createMemo(() => {
const route = surface.route()
+3 -1
View File
@@ -430,7 +430,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
key: id,
options,
}
setStore("registrations", (arr) => addCommandRegistration(arr, entry))
// Register only committed owners. Updating the registry during a transition
// can restore its pending snapshot after the outgoing owner's cleanup.
onMount(() => setStore("registrations", (arr) => addCommandRegistration(arr, entry)))
onCleanup(() => {
setStore("registrations", (arr) => arr.filter((x) => x !== entry))
})
+34 -36
View File
@@ -5,21 +5,15 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { TextInput } from "@opencode-ai/ui/text-input"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { formatKeybindParts } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useTabs } from "@/shell/tabs/tabs"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { getRelativeTime } from "@/shell/time"
import {
createCommandPaletteCommandEntry,
createCommandPaletteFileEntry,
createCommandPaletteModel,
createServerSessionEntries,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./palette"
import { createCommandPaletteFileEntry, createCommandPaletteModel, type CommandPaletteEntry } from "./palette"
import { createCommandPaletteSearch } from "./search"
import "./dialog.css"
function groups(entries: CommandPaletteEntry[]) {
@@ -35,23 +29,24 @@ export function matchesCommandPaletteEntry(entry: CommandPaletteEntry, query: st
export function DialogCommandPalette(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const loadItems = async (text: string) => {
const q = text.trim()
const items = (q: string) => {
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
const category = palette.language.t("palette.group.files")
return [
...palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q)),
...nextSessions,
...files.map((path) => createCommandPaletteFileEntry(path, category)),
]
return palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q))
}
return (
<CommandPaletteView
placeholder={palette.language.t("palette.search.placeholder")}
loadItems={loadItems}
items={items}
sources={[
palette.sessions,
async (query, signal) => {
if (!query) return []
const files = await palette.file.searchFiles(query, { signal })
const category = palette.language.t("palette.group.files")
return files.map((path) => createCommandPaletteFileEntry(path, category))
},
]}
highlight={palette.highlight}
select={palette.select}
close={palette.close}
@@ -61,29 +56,31 @@ export function DialogCommandPalette(props: { onOpenFile?: (path: string) => voi
export function CommandPaletteView(props: {
placeholder: string
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
items: (query: string) => CommandPaletteEntry[]
sources: ((query: string, signal: AbortSignal) => Promise<CommandPaletteEntry[]>)[]
highlight: (item: CommandPaletteEntry | undefined) => void
select: (item: CommandPaletteEntry | undefined) => void
close: () => void
}) {
const language = useLanguage()
const tabs = useTabs()
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const [store, setStore] = createStore({ query: "", active: undefined as string | undefined })
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const search = createCommandPaletteSearch({ query: () => store.query, items: props.items, sources: props.sources })
const visibleEntries = search.items
const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()])
// Keep keyboard selection stable when another search source adds results.
const activeEntry = createMemo(
() => visibleEntries().find((entry) => entry.id === store.active) ?? visibleEntries()[0],
)
const openSessions = createMemo(
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
)
createEffect(() => {
query()
visibleEntries()
setActive(0)
// Pin automatic selection too: a later source can insert rows before it.
const id = activeEntry()?.id
if (store.active !== id) setStore("active", id)
})
createEffect(() => {
@@ -95,7 +92,8 @@ export function CommandPaletteView(props: {
const move = (delta: -1 | 1) => {
const count = visibleEntries().length
if (count === 0) return
setActive((index) => (index + delta + count) % count)
const index = visibleEntries().findIndex((entry) => entry.id === activeEntry()?.id)
setStore("active", visibleEntries()[(index + delta + count) % count].id)
requestAnimationFrame(() => {
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
})
@@ -128,14 +126,14 @@ export function CommandPaletteView(props: {
<DialogBody class="command-palette-body">
<div class="command-palette-search">
<TextInput
value={query()}
value={store.query}
autofocus
autocomplete="off"
spellcheck={false}
appearance="large"
placeholder={props.placeholder}
leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)}
onInput={(event) => setStore({ query: event.currentTarget.value, active: undefined })}
onKeyDown={handleKeyDown}
/>
</div>
@@ -145,7 +143,7 @@ export function CommandPaletteView(props: {
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-state">
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
{search.loading() ? language.t("common.loading") : language.t("palette.empty")}
</div>
}
>
@@ -166,7 +164,7 @@ export function CommandPaletteView(props: {
? openSessions().has(`${item.server}\0${item.sessionID}`)
: false
}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onActive={() => setStore("active", item.id)}
onSelect={() => props.select(item)}
/>
)}
+41
View File
@@ -0,0 +1,41 @@
import { createMemo, createResource, onCleanup } from "solid-js"
import { uniqueCommandPaletteEntries, type CommandPaletteEntry } from "./palette"
export function createCommandPaletteSearch(props: {
query: () => string
items: (query: string) => CommandPaletteEntry[]
sources: ((query: string, signal: AbortSignal) => Promise<CommandPaletteEntry[]>)[]
}) {
const query = createMemo(() => props.query().trim())
const local = createMemo(() => props.items(query()))
const sources = props.sources.map((load) => {
let abort: AbortController | undefined
onCleanup(() => abort?.abort())
const [result] = createResource(
query,
async (query) => {
abort?.abort()
const current = new AbortController()
abort = current
return { query, items: await load(query, current.signal).catch(() => []) }
},
// Remote searches must not suspend the dialog's local results on first render.
{ initialValue: { query: "", items: [] as CommandPaletteEntry[] } },
)
return result
})
return {
items: createMemo(() =>
uniqueCommandPaletteEntries([
...local(),
...sources.flatMap((source) => {
// Never keep results for an older query selectable while the next one loads.
const result = source.latest
return result.query === query() ? result.items : []
}),
]),
),
loading: () => sources.some((source) => source.loading),
}
}
@@ -1,5 +1,6 @@
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { Toast, showToast, toaster, type ToastOptions } from "@opencode-ai/ui/toast"
import type { JSX } from "solid-js"
type AppToastOptions = Omit<ToastOptions, "icon"> & {
icon?: IconProps["name"]
@@ -30,6 +31,7 @@ export function dismissToast(toastId: number) {
function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastOptions["variant"]) {
const name = icon ?? (variant === "success" ? "check" : undefined)
if (!name) return
return <Icon name={name} />
if (!name) return undefined
// Solid resolves JSX accessors under the toast's render owner, not this imperative call site.
return (() => <Icon name={name} />) as unknown as JSX.Element
}
+1 -1
View File
@@ -67,7 +67,7 @@ export default function Layout(props: ParentProps) {
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(8px, env(safe-area-inset-bottom, 0px))",
"padding-bottom": "max(10px, env(safe-area-inset-bottom, 0px))",
}}
>
<ResizeHandle
+2 -2
View File
@@ -13,7 +13,7 @@ import { displayName, projectForSession } from "@/shell/layout/helpers"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { sessionLabel } from "@/session/title"
import { sessionTabTitle } from "./tab-title"
import { useSettings } from "@/settings/model"
import { canOpenTabRename, forwardTabRef } from "./tab-gesture"
import { TabPreviewPopover } from "./tab-popover"
@@ -63,7 +63,7 @@ export function TabNavItem(props: {
})
const title = createMemo(() => {
const session = props.session
return session ? sessionLabel(session) : props.fallbackTitle
return sessionTabTitle(session ? session.title : props.fallbackTitle, language.t("session.tab.session"))
})
const projectName = createMemo(() => {
@@ -171,7 +171,7 @@ function SessionTabEntry(props: {
preparing={!!pending()}
fallbackTitle={
pending()
? language.t("command.session.new")
? language.t("session.tab.session")
: (persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined))
}
onRename={rename}
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test"
import { sessionTabTitle } from "./tab-title"
describe("session tab titles", () => {
test("uses the same localized label before a title arrives", () => {
for (const title of [
undefined,
"",
"New session - 2026-07-30T18:45:03.662Z",
"Child session - 2026-07-30T18:45:03.662Z",
]) {
expect(sessionTabTitle(title, "Session")).toBe("Session")
expect(sessionTabTitle(title, "Sitzung")).toBe("Sitzung")
}
})
test("preserves generated and user-supplied titles", () => {
for (const title of ["Generated title", "New session", "New session - custom"]) {
expect(sessionTabTitle(title, "Session")).toBe(title)
}
})
})
@@ -0,0 +1,6 @@
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
// Draft, preparing, and untitled session tabs share one localized label.
export function sessionTabTitle(title: string | undefined, fallback: string) {
return !title || isFallbackTitle(title) ? fallback : title
}
@@ -2,6 +2,21 @@
user-select: none;
}
[data-slot="vertical-tabs-sidebar"] [data-slot="vertical-tabs"] {
display: flex;
flex: 1;
flex-direction: column;
}
[data-slot="vertical-tabs-sidebar"] [data-slot="vertical-tabs-scroll"] {
min-height: 0;
/* Leave enough space to scroll the final tab clear of the fade above Settings. */
padding-block-end: 16px;
scroll-padding-block-end: 16px;
-webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
}
[data-slot="mobile-tabs-trigger"][aria-expanded="true"] {
background:
linear-gradient(var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
+85 -71
View File
@@ -1,6 +1,6 @@
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { Portal } from "solid-js/web"
import { Dynamic, Portal } from "solid-js/web"
import { useLocation, useNavigate } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -26,17 +26,20 @@ import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import { sessionLabel } from "@/session/title"
import { sessionTabTitle } from "./tab-title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import { projectForSession } from "@/shell/layout/helpers"
import { useSettingsDialog } from "@/settings/command"
import devIcon from "../../../../desktop/icons/dev/64x64.png"
import betaIcon from "../../../../desktop/icons/beta/64x64.png"
const titlebarHeight = 36
const windowsTitlebarHeight = 44 // Includes the content inset; matches the native Windows overlay.
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const macTrafficLightsBaseWidth = 84
// Native controls: 14px left inset, two 20px button pitches, and a 14px button.
const macTrafficLightsBaseWidth = 68
const macTrafficLightsTopClearance = 28
export type TitlebarUpdate = {
@@ -348,13 +351,19 @@ export function Titlebar(props: {
type="button"
data-action="vertical-tabs-home"
data-state={layout.route().type === "home" ? "pressed" : undefined}
class="mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
>
<Icon name="grid-plus" />
{language.t("home.title")}
<span class="min-w-0 truncate">{language.t("home.title")}</span>
<span
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
aria-hidden="true"
>
<bdi dir="ltr">{command.keybind("home.toggle")}</bdi>
</span>
</button>
</Show>
)
@@ -413,9 +422,12 @@ export function Titlebar(props: {
const currentTitle = () => {
const tab = currentTab()
if (!tab) return language.t("home.title")
if (tab.type === "draft") return language.t("command.session.new")
if (tab.type === "draft") return language.t("session.tab.session")
const value = session()
return value ? sessionLabel(value) : (tabs.info[tabKey(tab)]?.title ?? language.t("command.session.new"))
return sessionTabTitle(
value ? value.title : tabs.info[tabKey(tab)]?.title,
language.t("session.tab.session"),
)
}
createEffect(() => {
path()
@@ -425,16 +437,15 @@ export function Titlebar(props: {
return (
<div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pe-3"
classList={{
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
"pb-[max(0px,calc(8px-env(safe-area-inset-bottom,0px)))]": bottom(),
"md:pl-2": macTrafficLights(),
"md:pl-4": !macTrafficLights(),
"pl-4": macTrafficLights(),
}}
>
<Show when={!mobile() && (!props.verticalTabs || windows())}>
<ChannelIndicator debugTools={props.debugTools} />
<Show when={!mobile() && !props.verticalTabs}>
<ChannelIndicator horizontal debugTools={props.debugTools} />
</Show>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
@@ -497,7 +508,7 @@ export function Titlebar(props: {
</span>
)}
</Show>
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
<span data-slot="mobile-tab-title" dir="auto" class="min-w-0 flex-1 truncate text-start">
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
@@ -625,33 +636,28 @@ export function Titlebar(props: {
>
<Show when={macVerticalTabs()}>
<div
class="relative mb-2 w-full shrink-0"
class="mb-4 min-h-7 w-full shrink-0"
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
data-tauri-drag-region
>
<div
class="absolute -top-0.5 bottom-0.5 flex items-center"
style={{
// Native traffic lights stay on the physical left; subtract the sidebar padding.
left: macTrafficLights()
? `calc(${macTrafficLightsBaseWidth / zoom()}px - 0.625rem)`
: "0px",
}}
>
<ChannelIndicator debugTools={props.debugTools} />
</div>
</div>
/>
</Show>
<ChannelIndicator sidebar debugTools={props.debugTools} />
{homeButton(true)}
<button
type="button"
data-action="vertical-tabs-new-session"
class="flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="edit" />
{language.t("command.session.new")}
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
<span
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
aria-hidden="true"
>
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
</span>
</button>
<div class="h-4 w-full shrink-0" aria-hidden="true" />
<div class="flex min-h-0 flex-1 flex-col gap-1">
@@ -670,14 +676,20 @@ export function Titlebar(props: {
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div
data-slot="vertical-tabs-footer"
class="mt-auto flex h-9 w-full shrink-0 items-center gap-1.5"
<button
type="button"
data-action="vertical-tabs-settings"
data-state={layout.route().type === "settings" ? "pressed" : undefined}
class="mt-2 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]"
onClick={openSettings}
aria-label={language.t("sidebar.settings")}
aria-pressed={layout.route().type === "settings"}
>
<Icon name="settings-gear" />
{language.t("sidebar.settings")}
</button>
<div data-slot="vertical-tabs-footer" class="flex w-full shrink-0 items-center gap-1.5">
<TitlebarRightMount />
<Show when={!macVerticalTabs() && !windows()}>
<ChannelIndicator debugTools={props.debugTools} />
</Show>
</div>
</Portal>
)}
@@ -753,45 +765,47 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
)
}
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
function ChannelIndicator(props: {
horizontal?: boolean
sidebar?: boolean
debugTools?: { visible: boolean; toggle: () => void }
}) {
const language = useLanguage()
const platform = usePlatform()
const windows = () => platform.platform === "desktop" && platform.os === "windows"
const classes = () => ({
"px-2 rounded-sm": windows(),
"inline-flex h-4 shrink-0 items-center leading-4 px-1.5 rounded-full": !windows(),
})
const style = () => ({
"font-size": windows() ? undefined : platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
})
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono cursor-pointer [app-region:no-drag]"
classList={classes()}
style={style()}
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
aria-pressed={props.debugTools.visible}
>
DEV
</button>
)
}
if (!channel || channel === "prod") return null
const label = channel && ["local", "beta", "dev"].includes(channel) ? channel.toUpperCase() : undefined
const label = () => language.t(`titlebar.channel.${channel}`)
const debug = () => (channel === "dev" ? props.debugTools : undefined)
return (
<Show when={label}>
{(value) => (
<div
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono"
classList={classes()}
style={style()}
>
{value()}
</div>
)}
</Show>
<Tooltip
placement={props.sidebar ? "right" : "bottom"}
value={label()}
class={`shrink-0 [app-region:no-drag] ${props.sidebar ? "mb-4 ms-0.5 self-start" : ""} ${props.horizontal ? "me-1.5" : ""} ${props.horizontal && platform.platform === "web" ? "ps-2.5" : ""}`}
>
<Dynamic
component={debug() ? "button" : "div"}
type={debug() ? "button" : undefined}
data-slot="channel-indicator"
class="flex h-7 shrink-0 items-center rounded-[6px] [app-region:no-drag]"
classList={{
"w-6": props.sidebar,
"w-5": !props.sidebar,
"cursor-pointer hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02":
!!debug(),
}}
onClick={() => debug()?.toggle()}
aria-label={debug() ? language.t("titlebar.toggleDebugTools") : undefined}
aria-pressed={debug()?.visible}
>
<img
src={channel === "beta" ? betaIcon : devIcon}
alt={debug() ? "" : label()}
class="shrink-0 rounded-[4px] shadow-[var(--v2-elevation-raised)]"
classList={{ "size-6": props.sidebar, "size-5": !props.sidebar }}
draggable={false}
/>
</Dynamic>
</Tooltip>
)
}
@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test"
import { createComputed, createRoot, createSignal } from "solid-js"
import type { CommandPaletteEntry } from "@/shell/commands/palette"
import { createCommandPaletteSearch } from "@/shell/commands/search"
const copy: CommandPaletteEntry = {
id: "command:session.copyID",
type: "command",
title: "Copy Session ID",
category: "Commands",
}
const file: CommandPaletteEntry = { id: "file:copy.txt", type: "file", title: "copy.txt", category: "Files" }
const session: CommandPaletteEntry = {
id: "session:copy",
type: "session",
title: "Copy session",
category: "Sessions",
}
describe("command palette search", () => {
test("matches commands synchronously and cancels obsolete requests", () => {
const signals: AbortSignal[] = []
const root = createRoot((dispose) => {
const [query, setQuery] = createSignal("")
const search = createCommandPaletteSearch({
query,
items: (text) => (text === "copy session" ? [copy] : []),
sources: [
(_text, signal) => {
signals.push(signal)
return new Promise<CommandPaletteEntry[]>(() => {})
},
],
})
return { search, setQuery, dispose }
})
root.setQuery(" copy session ")
expect(root.search.items()).toEqual([copy])
expect(root.search.loading()).toBe(true)
expect(signals[0].aborted).toBe(true)
root.setQuery("no match")
expect(root.search.items()).toEqual([])
expect(signals[1].aborted).toBe(true)
root.dispose()
expect(signals[2].aborted).toBe(true)
})
test("publishes each source independently and drops stale results on a new query", async () => {
const files = Promise.withResolvers<CommandPaletteEntry[]>()
const sessions = Promise.withResolvers<CommandPaletteEntry[]>()
const fileVisible = Promise.withResolvers<void>()
const sessionVisible = Promise.withResolvers<void>()
const root = createRoot((dispose) => {
const [query, setQuery] = createSignal("copy")
const search = createCommandPaletteSearch({
query,
items: () => [copy],
sources: [() => sessions.promise, () => files.promise],
})
createComputed(() => {
if (search.items().some((entry) => entry.id === file.id)) fileVisible.resolve()
if (search.items().some((entry) => entry.id === session.id)) sessionVisible.resolve()
})
return { search, setQuery, dispose }
})
expect(root.search.items()).toEqual([copy])
files.resolve([file])
await fileVisible.promise
expect(root.search.items()).toEqual([copy, file])
expect(root.search.loading()).toBe(true)
sessions.resolve([session])
await sessionVisible.promise
expect(root.search.items()).toEqual([copy, session, file])
expect(root.search.loading()).toBe(false)
root.setQuery("new query")
expect(root.search.items()).toEqual([copy])
root.dispose()
})
test("failed searches do not hide commands or successful sources", async () => {
const settled = Promise.withResolvers<void>()
const root = createRoot((dispose) => {
const search = createCommandPaletteSearch({
query: () => "copy",
items: () => [copy],
sources: [() => Promise.reject(new Error("offline")), () => Promise.resolve([file])],
})
createComputed(() => {
if (!search.loading()) settled.resolve()
})
return { search, dispose }
})
await settled.promise
expect(root.search.items()).toEqual([copy, file])
root.dispose()
})
})
+81 -60
View File
@@ -66,6 +66,8 @@ export type CreateDataInput = {
readonly connection?: {
readonly status: () => "connected" | "connecting" | "reconnecting"
}
/** Receives failed event-driven reads. Explicit reads still reject to their caller. */
readonly onError?: (error: unknown) => void
}
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
@@ -187,6 +189,17 @@ function createSync() {
export function createData(config: CreateDataInput) {
const api = config.api
let disposed = false
onCleanup(() => (disposed = true))
function refresh(load: () => Promise<unknown>) {
if (disposed || (config.connection && config.connection.status() !== "connected")) return
void load().catch((error) => {
if (disposed || (config.connection && config.connection.status() !== "connected")) return
if (config.onError) return config.onError(error)
console.error("Failed to refresh client data", error)
})
}
const [store, setStore] = createStore<Store>({
session: {
@@ -554,31 +567,34 @@ export function createData(config: CreateDataInput) {
case "server.connected": {
const updates = new Map<string, DataSessionStatus | undefined>()
activeUpdates = updates
void api()
.session.active()
.then((active) => {
if (activeUpdates !== updates) return
// Lifecycle events received during hydration supersede the snapshot.
const snapshot = new Map<string, DataSessionStatus>(Object.keys(active).map((id) => [id, "running"]))
updates.forEach((status, id) => {
if (status === undefined) return snapshot.delete(id)
snapshot.set(id, status)
refresh(() =>
api()
.session.active()
.then((active) => {
if (activeUpdates !== updates) return
// Lifecycle events received during hydration supersede the snapshot.
const snapshot = new Map<string, DataSessionStatus>(Object.keys(active).map((id) => [id, "running"]))
updates.forEach((status, id) => {
if (status === undefined) return snapshot.delete(id)
snapshot.set(id, status)
})
activeUpdates = undefined
setStore("session", "active", reconcile(Object.fromEntries(snapshot)))
})
activeUpdates = undefined
setStore("session", "active", reconcile(Object.fromEntries(snapshot)))
})
.catch(() => {
if (activeUpdates === updates) activeUpdates = undefined
})
void api()
.location.get({ location: locationQuery(defaultLocation()) })
.then((location) => {
const key = locationKey(location)
setStore("location", key, { info: location })
})
.catch((error) => console.error("Failed to preload location", error))
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
.catch(() => {
if (activeUpdates === updates) activeUpdates = undefined
}),
)
refresh(() =>
api()
.location.get({ location: locationQuery(defaultLocation()) })
.then((location) => {
const key = locationKey(location)
setStore("location", key, { info: location })
}),
)
refresh(() => result.location.vcs.sync())
refresh(() => result.project.sync())
return
}
case "project.updated":
@@ -587,7 +603,7 @@ export function createData(config: CreateDataInput) {
case "session.created":
sessionOutbox.delete(event.data.sessionID)
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
refresh(() => result.session.sync(event.data.sessionID))
// Band-aid: a newly created session starts empty, so live events can be its source of truth.
// Fetching pending inputs and projected messages separately lets promotion move an input between snapshots,
// causing both requests to miss it and overwrite event-built state. Skip those racy initial reads until
@@ -628,25 +644,28 @@ export function createData(config: CreateDataInput) {
model: event.data.model,
time: { created: event.created },
})
void api()
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
.then((item) => {
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(item.id)
if (position === undefined) return message.append(draft, index, item)
draft[position] = item
})
})
.catch((error) => console.error("Failed to load projected model switch message", error))
refresh(() =>
api()
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
.then((item) => {
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(item.id)
if (position === undefined) return message.append(draft, index, item)
draft[position] = item
})
}),
)
return
case "session.renamed": {
// Preserve the live title when it races the session's initial read.
const family = sync.pending(`session.family:${event.data.sessionID}`)
? result.session.sync(event.data.sessionID, { children: true })
: Promise.resolve()
void Promise.all([result.session.sync(event.data.sessionID), family]).then(() => {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
refresh(() => {
const family = sync.pending(`session.family:${event.data.sessionID}`)
? result.session.sync(event.data.sessionID, { children: true })
: Promise.resolve()
return Promise.all([result.session.sync(event.data.sessionID), family]).then(() => {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
})
})
return
}
@@ -680,7 +699,7 @@ export function createData(config: CreateDataInput) {
if (!directory) {
if (info.location.workspaceID) continue
result.session.invalidate(sessionID)
void result.session.sync(sessionID)
refresh(() => result.session.sync(sessionID))
continue
}
const adopted = Worktree.adopt(
@@ -791,7 +810,7 @@ export function createData(config: CreateDataInput) {
})
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
result.session.message.invalidate(event.data.sessionID)
void result.session.message.sync(event.data.sessionID)
refresh(() => result.session.message.sync(event.data.sessionID))
return
}
case "session.step.started":
@@ -992,12 +1011,12 @@ export function createData(config: CreateDataInput) {
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
refresh(() => result.session.sync(event.data.sessionID))
return
case "session.viewed":
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
refresh(() => result.session.sync(event.data.sessionID))
return
case "session.revert.staged":
if (store.session.info[event.data.sessionID])
@@ -1105,7 +1124,7 @@ export function createData(config: CreateDataInput) {
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
if (event.type === "credential.updated") {
result.location.integration.invalidate(location)
void result.location.integration.sync(location)
refresh(() => result.location.integration.sync(location))
return
}
setStore("location", key, (data) => ({
@@ -1123,7 +1142,7 @@ export function createData(config: CreateDataInput) {
}))
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]))
})
return
}
@@ -1134,19 +1153,19 @@ export function createData(config: CreateDataInput) {
case "catalog.updated":
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]))
break
case "agent.updated":
result.location.agent.invalidate(location)
void result.location.agent.sync(location)
refresh(() => result.location.agent.sync(location))
break
case "command.updated":
result.location.command.invalidate(location)
void result.location.command.sync(location)
refresh(() => result.location.command.sync(location))
break
case "skill.updated":
result.location.skill.invalidate(location)
void result.location.skill.sync(location)
refresh(() => result.location.skill.sync(location))
break
case "vcs.branch.updated":
setStore("location", locationKey(location), (data) => ({
@@ -1181,31 +1200,33 @@ export function createData(config: CreateDataInput) {
break
case "reference.updated":
result.location.reference.invalidate(location)
void result.location.reference.sync(location)
refresh(() => result.location.reference.sync(location))
break
case "integration.updated":
result.location.integration.invalidate(location)
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
void Promise.all([
result.location.integration.sync(location),
result.location.model.sync(location),
result.location.provider.sync(location),
])
refresh(() =>
Promise.all([
result.location.integration.sync(location),
result.location.model.sync(location),
result.location.provider.sync(location),
]),
)
break
case "config.updated":
case "websearch.updated":
void result.location.websearch.refresh(location)
refresh(() => result.location.websearch.refresh(location))
break
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
// so the mcp list syncs here rather than off integration.updated.
case "mcp.status.changed":
result.location.mcp.server.invalidate(location)
void result.location.mcp.server.sync(location)
refresh(() => result.location.mcp.server.sync(location))
break
case "mcp.resources.changed":
result.location.mcp.resource.invalidate(location)
void result.location.mcp.resource.sync(location)
refresh(() => result.location.mcp.resource.sync(location))
break
}
}
+117
View File
@@ -0,0 +1,117 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const reported = Promise.withResolvers<unknown>()
const errors: unknown[] = []
const state = { offline: true, requests: 0 }
const session: SessionInfo = {
id: "ses_refresh_failure",
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0, idle: 2 },
location: { directory: "/project" },
}
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () => {
state.requests++
if (state.offline) throw new TypeError("Failed to fetch")
return Response.json({ data: { ...session, title: "Recovered" } })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
onError(error) {
errors.push(error)
reported.resolve(error)
},
}),
dispose,
}))
const event: OpenCodeEvent = {
id: "evt_refresh_failure",
created: 2,
type: "session.viewed",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: { sessionID: session.id, idle: 2 },
}
try {
setup.data.session.remember(session)
setup.data.session.invalidate(session.id)
await expect(setup.data.session.sync(session.id)).rejects.toThrow("Transport")
expect(errors).toEqual([])
listeners.forEach((listener) => listener({ name: event.type, details: event }))
expect(String(await reported.promise)).toContain("Transport")
expect(errors).toHaveLength(1)
expect(setup.data.session.get(session.id)?.title).toBeUndefined()
state.offline = false
listeners.forEach((listener) => listener({ name: event.type, details: event }))
await setup.data.session.sync(session.id)
expect(setup.data.session.get(session.id)?.title).toBe("Recovered")
expect(state.requests).toBe(3)
} finally {
setup.dispose()
}
})
test.each(["reconnecting", "disposed"] as const)("background reads respect %s owners", async (mode) => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const pending = Promise.withResolvers<Response>()
const errors: unknown[] = []
const state = { requests: 0 }
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: () => {
state.requests++
return pending.promise
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => (mode === "reconnecting" ? "reconnecting" : "connected") },
onError: (error) => errors.push(error),
}),
dispose,
}))
const event: OpenCodeEvent = {
id: "evt_refresh_owner",
type: "command.updated",
location: { directory: "/project" },
data: {},
}
listeners.forEach((listener) => listener({ name: event.type, details: event }))
if (mode === "reconnecting") {
expect(state.requests).toBe(0)
setup.dispose()
return
}
const joined = setup.data.location.command.sync()
setup.dispose()
pending.reject(new TypeError("Failed to fetch"))
await expect(joined).rejects.toThrow("Transport")
expect(state.requests).toBe(1)
expect(errors).toEqual([])
})
+33
View File
@@ -1,6 +1,7 @@
export * as SessionRunnerLLM from "./llm.js"
import { Message } from "@opencode-ai/ai"
import { and, desc, eq, sql } from "drizzle-orm"
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
@@ -15,6 +16,7 @@ import { SessionModelTransport } from "../model-transport.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionMessageTable } from "../sql.js"
import { SessionTitle } from "../title.js"
import { DrainResult, Service, type Interface } from "./index.js"
import { Snapshot } from "../../snapshot.js"
@@ -59,6 +61,7 @@ const layer = Layer.effect(
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
}
yield* plugins.awaitActivation
yield* settleStaleCompactions(sessionID)
yield* settleStaleToolCalls(sessionID)
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
@@ -276,6 +279,36 @@ const layer = Layer.effect(
}
})
const settleStaleCompactions = Effect.fn("SessionRunner.settleStaleCompactions")(function* (
sessionID: SessionSchema.ID,
) {
// A process death skips compaction finalizers. Include orphans behind a
// completed checkpoint, and settle newest first to match event projection.
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
),
)
.orderBy(desc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
for (const row of rows) {
const message = yield* SessionHistory.decodeMessageRow(row)
if (message.type !== "compaction") continue
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: message.reason,
inputID: message.id,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
})
}
})
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
sessionID: SessionSchema.ID,
) {
+4 -4
View File
@@ -122,8 +122,8 @@ describe("CodeModeInstructions.render", () => {
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
expect(instructions).not.toContain("## Search")
expect(instructions).toContain("The Code Mode tool catalog below is complete.")
expect(instructions).toContain("This catalog is the complete set of tools available within Code Mode.")
expect(instructions).not.toContain("surrounding top-level agent tools")
expect(instructions).toContain("This catalog is the complete set of tools callable inside `execute`.")
expect(instructions).toContain("It does not affect tools exposed directly outside Code Mode.")
})
test("adds search guidance when the catalog exceeds the budget", () => {
@@ -133,9 +133,9 @@ describe("CodeModeInstructions.render", () => {
expect(partial).toContain("## Search")
expect(partial).toContain("The Code Mode tool catalog below is partial.")
expect(partial).toContain(
"The Code Mode catalog and `search` results are the complete set of tools available within Code Mode.",
"The Code Mode catalog and `search` results are the complete set of tools callable inside `execute`.",
)
expect(partial).not.toContain("surrounding top-level agent tools")
expect(partial).toContain("It does not affect tools exposed directly outside Code Mode.")
expect(partial).toContain("- search(input: {")
expect(partial).toContain(" /** @integer @exclusiveMinimum 0 */\n limit?: number,")
expect(partial).toContain(" /** @integer @minimum 0 */\n offset?: number,")
+25 -2
View File
@@ -69,12 +69,16 @@ async function createRegistryFixture(directory: string) {
await Bun.$`tar -czf package.tgz package`.cwd(root)
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
}
const state = { latest: "1.0.0" }
const state = { latest: "1.0.0", audits: 0 }
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname.startsWith("/-/npm/v1/security/")) {
state.audits++
return Response.json({})
}
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
return Response.json({
name: "@fixture/registry-plugin",
@@ -97,7 +101,7 @@ async function createRegistryFixture(directory: string) {
await fs.mkdir(root, { recursive: true })
await Bun.write(
path.join(root, ".npmrc"),
`@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
`registry=${server.url}\n@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=true\n`,
)
return root
},
@@ -359,6 +363,25 @@ describe("Npm.resolve", () => {
})
describe("Npm.check and Npm.update", () => {
test("installs and updates without requesting registry audits", async () => {
await using tmp = await tmpdir()
await using registry = await createRegistryFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
await registry.configure(cache, spec)
await Effect.gen(function* () {
const npm = yield* Npm.Service
expect((yield* npm.add(spec)).version).toBe("1.0.0")
expect(registry.state.audits).toBe(0)
registry.state.latest = "1.1.0"
expect((yield* npm.update(spec)).version).toBe("1.1.0")
expect(registry.state.audits).toBe(0)
expect((yield* npm.resolve(spec)).version).toBe("1.1.0")
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
})
test("checks a mutable registry target without mutation and explicitly updates it", async () => {
await using tmp = await tmpdir()
await using registry = await createRegistryFixture(tmp.path)
+4
View File
@@ -4,6 +4,10 @@ import path from "path"
import { Global } from "@opencode-ai/util/global"
describe("Core test environment", () => {
test("disables public npm security audits", () => {
expect(process.env.NPM_CONFIG_AUDIT).toBe("false")
})
test("isolates global home and XDG roots", () => {
const home = process.env.OPENCODE_TEST_HOME
expect(home).toBeDefined()
+1
View File
@@ -1 +1,2 @@
process.env.OPENCODE_DB = ":memory:"
process.env.NPM_CONFIG_AUDIT = "false"
+77
View File
@@ -3472,6 +3472,83 @@ describe("SessionRunnerLLM", () => {
expect(userTexts(s.requests[1])).toEqual(["Start working", "Recover with this"])
})
scenario("settles abandoned compactions before continuing after a process crash", function* (s) {
yield* s.runPrompt("History before the crash")
const first = SessionMessage.ID.create()
const completed = SessionMessage.ID.create()
const last = SessionMessage.ID.create()
yield* s.bus.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
inputID: first,
recent: "",
})
yield* s.bus.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
inputID: completed,
recent: "",
})
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
text: "## Objective\n- Earlier completed checkpoint",
recent: "",
})
yield* s.bus.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "auto",
inputID: last,
recent: "",
})
// These starts have no terminal events, as after SIGKILL. The older orphan
// is outside model-visible history; recovery must settle it as well.
expect(
(yield* s.messages).filter((message) => message.type === "compaction" && message.status === "running"),
).toHaveLength(2)
yield* s.llm.push(TestLLM.text("Recovered response", "recovered"))
const run = yield* s.resumePaused
expect((yield* s.messages).filter((message) => message.type === "compaction").toReversed()).toMatchObject([
{ id: first, status: "failed", reason: "manual", error: { type: "compaction.interrupted" } },
{ id: completed, status: "completed", summary: "## Objective\n- Earlier completed checkpoint" },
{ id: last, status: "failed", reason: "auto", error: { type: "compaction.interrupted" } },
])
yield* run.finish
yield* s.llm.push(TestLLM.text("## Objective\n- New checkpoint", "new-summary"))
const next = yield* s.session.compact({ sessionID })
yield* s.session.wait(sessionID)
expect((yield* s.messages).find((message) => message.id === next.id)).toMatchObject({
status: "completed",
summary: "## Objective\n- New checkpoint",
})
expect(
(yield* s.messages).filter((message) => message.type === "compaction" && message.status === "running"),
).toHaveLength(0)
})
scenario("settles an abandoned compaction before delivering another manual compaction", function* (s) {
yield* s.runPrompt("History before the crash")
const previous = SessionMessage.ID.create()
yield* s.bus.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
inputID: previous,
recent: "",
})
yield* s.llm.push(TestLLM.text("## Objective\n- New checkpoint", "new-summary"))
const gate = yield* s.llm.gate
const next = yield* s.session.compact({ sessionID })
yield* gate.started
expect((yield* s.messages).filter((message) => message.type === "compaction").toReversed()).toMatchObject([
{ id: previous, status: "failed", error: { type: "compaction.interrupted" } },
{ id: next.id, status: "running" },
])
yield* gate.release
yield* s.session.wait(sessionID)
})
scenario("durably fails local tools left running by a prior process before continuing", function* (s) {
yield* s.admit("Recover interrupted tool")
yield* SessionInbox.promote(s.db, s.bus, sessionID, "steer")
+2 -2
View File
@@ -1,4 +1,5 @@
import { defineConfig } from "electron-vite"
import { pickerPlugin } from "./scripts/picker"
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
@@ -10,7 +11,6 @@ const channel = (() => {
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
const appPlugin = (await import("@opencode-ai/app/vite")).default
const picker = (await import("@brendonovich/vite-plugin-opencode")).default()
const sentry =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
? (await import("@sentry/vite-plugin")).sentryVitePlugin({
@@ -91,7 +91,7 @@ const require = __cjs_mod__.createRequire(import.meta.url);
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},
plugins: [picker, appPlugin, sentry],
plugins: [pickerPlugin(), appPlugin, sentry],
publicDir: "../../../app/public",
root: "src/renderer",
build: {
@@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<title>Picker fixture</title>
</head>
<body>
<button>Pick this element</button>
<script type="module" src="./main.ts"></script>
</body>
</html>
@@ -0,0 +1 @@
document.body.dataset.ready = "true"
+76
View File
@@ -0,0 +1,76 @@
import { expect, test } from "bun:test"
import { loadConfigFromFile, RendererConfigFactory } from "electron-vite"
import { createServer } from "vite"
import { fileURLToPath } from "node:url"
import { pickerPlugin } from "./picker"
test("injects a browser-loadable URL instead of a bare virtual module", () => {
const plugin = pickerPlugin()
const tag = plugin.transformIndexHtml.handler()[0]
expect(tag.attrs.src).toBe("/__vite_opencode_picker_client.js")
const id = plugin.resolveId(tag.attrs.src)
expect(id).toBeDefined()
expect(plugin.load(id!)).toContain("opencodePickerUi")
})
test.each([true, false])(
"serves browser-loadable picker scripts with bundled dev = %s",
async (bundledDev) => {
const loaded = await loadConfigFromFile(
{ command: "serve", mode: "development" },
fileURLToPath(new URL("../electron.vite.config.ts", import.meta.url)),
)
if (!loaded.config.renderer) throw new Error("Missing renderer configuration")
const config = await new RendererConfigFactory(
loaded.config.renderer,
{ configFile: false, mode: "development" },
{ root: fileURLToPath(new URL("..", import.meta.url)) },
).build()
const server = await createServer({
...config,
configFile: false,
root: fileURLToPath(new URL("./fixtures/picker", import.meta.url)),
build: {
...config.build,
rolldownOptions: { input: { main: fileURLToPath(new URL("./fixtures/picker/index.html", import.meta.url)) } },
},
experimental: { bundledDev },
server: { host: "127.0.0.1", port: 0 },
logLevel: "silent",
})
await server.listen()
const url = server.resolvedUrls?.local[0]
if (!url) throw new Error("Missing fixture server URL")
const socket = bundledDev
? new WebSocket(`${url.replace("http:", "ws:")}?token=${server.config.webSocketToken}`, "vite-hmr")
: undefined
try {
if (socket && !server.environments.client.bundledDev?.hasBuildOutput) {
await new Promise<void>((resolve, reject) => {
socket.addEventListener("error", reject, { once: true })
socket.addEventListener("message", (event) => {
const message: { type: string } = JSON.parse(String(event.data))
if (message.type === "full-reload") resolve()
})
})
}
for (const path of ["/", "/index.html", "/server/example/session/example", "/new-session?draftId=example"]) {
const html = await fetch(new URL(path, url)).then((response) => response.text())
const sources = [...html.matchAll(/<script[^>]*\bsrc="([^"]+)"/g)].map((match) => match[1])
const scripts = await Promise.all(
sources.map((source) => fetch(new URL(source, url)).then((response) => response.text())),
)
expect(scripts.length).toBeGreaterThan(0)
if (bundledDev) expect(scripts.join("\n")).toContain("opencodePickerUi")
expect([html, ...scripts].join("\n")).not.toMatch(/\bimport(?:\s*\(\s*|\s*)["']virtual:/)
}
const direct = await fetch(new URL("/__vite_opencode_picker_client.js", url))
expect(direct.ok).toBe(true)
expect(await direct.text()).toContain("opencodePickerUi")
} finally {
socket?.close()
await server.close()
}
},
30_000,
)
+26
View File
@@ -0,0 +1,26 @@
import picker from "@brendonovich/vite-plugin-opencode"
export function pickerPlugin() {
const plugin = picker()
const client = "/__vite_opencode_picker_client.js"
return {
...plugin,
resolveId(id: string) {
return plugin.resolveId(id === client ? "virtual:vite-opencode-picker/client" : id)
},
configureServer(server: Parameters<typeof plugin.configureServer>[0]) {
server.middlewares.use(client, (_request, response) => {
response.setHeader("content-type", "text/javascript")
response.end(plugin.load(plugin.resolveId("virtual:vite-opencode-picker/client")!))
})
plugin.configureServer(server)
},
transformIndexHtml: {
order: "pre" as const,
handler() {
// A real URL stays loadable if bundled dev leaves the HTML import unbundled.
return [{ tag: "script", attrs: { type: "module", src: client }, injectTo: "body" as const }]
},
},
}
}
@@ -0,0 +1,144 @@
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { isShellNotFoundError, type LocationRef, type ShellInfo } from "@opencode-ai/client"
import { createEffect, createMemo, createSignal, onCleanup, Show, untrack } from "solid-js"
import stripAnsi from "strip-ansi"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
const PAGE_BYTES = 64 * 1024
export function DialogShellOutput(props: { shell: ShellInfo; location: LocationRef }) {
const client = useClient()
const dialog = useDialog()
const theme = useTheme("elevated")
const dimensions = useTerminalDimensions()
const [info, setInfo] = createSignal(props.shell)
const [output, setOutput] = createSignal<string>()
const [omitted, setOmitted] = createSignal(false)
const [error, setError] = createSignal("")
const text = createMemo(() => stripAnsi(output() ?? "").replace(/\r\n?/g, "\n"))
const height = () => Math.max(3, Math.floor(dimensions().height * 0.6) - 6)
let scroll: ScrollBoxRenderable | undefined
dialog.setSize("xlarge")
dialog.setCentered(true)
createEffect(() => {
// The running-shell inventory drops exited commands. Keep this view tied to
// the opened ID and its original Location, not the list's current selection.
const id = props.shell.id
const location = { directory: props.location.directory, workspace: props.location.workspaceID }
let cursor: number | undefined
let disposed = false
let missing = false
let timer: ReturnType<typeof setTimeout> | undefined
const load = async () => {
if (untrack(info).status === "running") {
const current = await client.api.shell.get({ id, location })
if (disposed) return false
setInfo(current.data)
}
if (cursor === undefined) {
const head = await client.api.shell.output({ id, location, cursor: Number.MAX_SAFE_INTEGER })
if (disposed) return false
cursor = Math.max(0, head.data.size - PAGE_BYTES)
setOmitted(cursor > 0)
}
const page = await client.api.shell.output({ id, location, cursor, limit: PAGE_BYTES })
if (disposed) return false
cursor = page.data.cursor
setOutput((previous) => {
const next = (previous ?? "") + page.data.output
if (next.length > PAGE_BYTES) setOmitted(true)
return next.slice(-PAGE_BYTES)
})
setError("")
return cursor < page.data.size
}
const poll = () => {
void load()
.catch((cause: unknown) => {
if (disposed) return
missing = isShellNotFoundError(cause)
setError(missing ? "Shell output is no longer available." : "Unable to read shell output. Retrying…")
})
.then((more) => {
// Poll only while the viewer is open, including after exit so the final
// file flush is observed. Never overlap reads or reload earlier pages.
if (!disposed && !missing) timer = setTimeout(poll, more ? 0 : 1_000)
})
}
poll()
onCleanup(() => {
disposed = true
clearTimeout(timer)
})
})
const status = () => {
if (info().status === "running") return "Running"
if (info().status === "timeout") return "Timed out"
if (info().status === "killed") return "Killed"
return info().exit === undefined ? "Exited" : `Exited · code ${info().exit}`
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "up", title: "Scroll output up", group: "Shell", run: () => scroll?.scrollBy(-1) },
{ bind: "down", title: "Scroll output down", group: "Shell", run: () => scroll?.scrollBy(1) },
{ bind: "pageup", title: "Previous output page", group: "Shell", run: () => scroll?.scrollBy(-height()) },
{ bind: "pagedown", title: "Next output page", group: "Shell", run: () => scroll?.scrollBy(height()) },
{ bind: "home", title: "First loaded output", group: "Shell", run: () => scroll?.scrollTo(0) },
{ bind: "end", title: "Follow shell output", group: "Shell", run: () => scroll?.scrollTo(Infinity) },
],
}))
return (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" gap={2}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD} flexGrow={1}>
Shell output
</text>
<text fg={theme.text.subdued}>{status()}</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.text.subdued} maxHeight={3} wrapMode="word">
{props.shell.command}
</text>
<Show when={omitted()}>
<text fg={theme.text.subdued}>Earlier output omitted · showing recent output</text>
</Show>
<scrollbox
id="shell-output-scroll"
ref={(value: ScrollBoxRenderable) => (scroll = value)}
height={height()}
stickyScroll
stickyStart="bottom"
scrollbarOptions={{ visible: false }}
>
<text fg={theme.text.default} wrapMode="word">
{text() ||
(output() === undefined
? "Loading output…"
: "No captured output. Output redirected to files is not shown here.")}
</text>
</scrollbox>
<Show when={error()}>
<text fg={theme.text.feedback.error.default}>{error()}</text>
</Show>
<box flexDirection="row" gap={2} flexWrap="wrap">
<text fg={theme.text.subdued}>/ scroll</text>
<text fg={theme.text.subdued}>end follow</text>
<text fg={theme.text.subdued}>esc back</text>
</box>
</box>
)
}
+1
View File
@@ -244,6 +244,7 @@ export const Definitions = {
"composer.subagent.interrupt": keybind("ctrl+d", "Interrupt subagent"),
"composer.shell.up": keybind("up", "Previous shell"),
"composer.shell.down": keybind("down", "Next shell"),
"composer.shell.select": keybind("return", "View shell output"),
"composer.shell.kill": keybind("ctrl+d", "Kill shell command"),
"composer.terminal.up": keybind("up,k", "Previous terminal"),
"composer.terminal.down": keybind("down,j", "Next terminal"),
@@ -6,6 +6,8 @@ import { useClient } from "../../../context/client"
import { useTheme } from "../../../context/theme"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"
import { useDialog } from "../../../ui/dialog"
import { DialogShellOutput } from "../../../component/dialog-shell-output"
export function ShellTab(props: { sessionID: string }) {
const data = useData()
@@ -13,6 +15,7 @@ export function ShellTab(props: { sessionID: string }) {
const theme = useTheme()
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
const dialog = useDialog()
const entries = createMemo(() =>
data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"),
@@ -23,6 +26,11 @@ export function ShellTab(props: { sessionID: string }) {
const selectedEntry = createMemo(() => entries()[store.selected])
const open = () => {
const entry = selectedEntry()
if (entry) dialog.replace(() => <DialogShellOutput shell={entry} location={entry.location} />)
}
createEffect(() => {
if (store.selected >= entries().length) setStore("selected", Math.max(0, entries().length - 1))
})
@@ -42,7 +50,13 @@ export function ShellTab(props: { sessionID: string }) {
const cleanup = composer.register({
id: "shell",
label: "Shell",
hints: () => (selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : []),
hints: () =>
selectedEntry()
? [
{ label: "output", shortcut: shortcuts.get("composer.shell.select") ?? "" },
{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" },
]
: [],
})
onCleanup(cleanup)
})
@@ -74,6 +88,12 @@ export function ShellTab(props: { sessionID: string }) {
setStore("selected", (prev) => (prev + 1) % list.length)
},
},
{
id: "composer.shell.select",
title: "View shell output",
group: "Composer",
run: open,
},
{
id: "composer.shell.kill",
title: "Kill shell command",
@@ -106,6 +126,10 @@ export function ShellTab(props: { sessionID: string }) {
active() ? theme.background.action.primary.focused : theme.background.action.primary.default
}
onMouseOver={() => setStore("selected", index())}
onMouseUp={() => {
setStore("selected", index())
open()
}}
>
<text
fg={active() ? theme.text.action.primary.focused : theme.text.action.primary.default}
@@ -11,6 +11,8 @@ import { LocationProvider } from "../../../src/context/location"
import { RouteProvider, useRoute } from "../../../src/context/route"
import { ThemeProvider } from "../../../src/context/theme"
import { Composer } from "../../../src/routes/session/composer"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -31,6 +33,7 @@ async function renderComposer(
const events = createEventStream()
const interrupted: string[] = []
const removed: string[] = []
const viewed: string[] = []
const ready = Promise.withResolvers<void>()
let closed = 0
let dispatch!: ReturnType<typeof Keymap.use>["dispatch"]
@@ -53,6 +56,13 @@ async function renderComposer(
})
}
const shellID = url.pathname.match(/^\/api\/shell\/([^/]+)$/)?.[1]
if (shellID && request.method === "GET") {
viewed.push(shellID)
return json({ location: { directory }, data: shells.find((shell) => shell.id === shellID) })
}
if (url.pathname.endsWith("/output")) {
return json({ location: { directory }, data: { output: "", cursor: 0, size: 0, truncated: false } })
}
if (shellID && request.method === "DELETE") {
removed.push(shellID)
return new Response(null, { status: 204 })
@@ -100,7 +110,11 @@ async function renderComposer(
<LocationProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<Content />
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</RouteProvider>
</LocationProvider>
@@ -119,6 +133,7 @@ async function renderComposer(
app,
interrupted,
removed,
viewed,
route: () => route.data,
dispatch: (command: string) => dispatch(command),
closed: () => closed,
@@ -154,15 +169,18 @@ test("disabled shell bindings have no component fallbacks", async () => {
const composer = await renderComposer("shell", {
"composer.shell.up": "none",
"composer.shell.down": "none",
"composer.shell.select": "none",
"composer.shell.kill": "none",
})
try {
expect(composer.app.captureCharFrame()).toContain("bun test")
composer.app.mockInput.pressArrow("up")
composer.app.mockInput.pressEnter()
composer.app.mockInput.pressKey("d", { ctrl: true })
await composer.app.renderOnce()
expect(composer.closed()).toBe(0)
expect(composer.removed).toEqual([])
expect(composer.viewed).toEqual([])
composer.app.mockInput.pressArrow("down")
composer.dispatch("composer.shell.kill")
@@ -198,6 +216,22 @@ test("ctrl+c closes the active composer", async () => {
}
})
test("shell output respects a configured binding with a focused textarea", async () => {
const composer = await renderComposer("shell", { "composer.shell.select": "ctrl+o" }, true)
try {
composer.app.mockInput.pressEnter()
await composer.app.renderOnce()
expect(composer.viewed).toEqual([])
composer.app.mockInput.pressKey("o", { ctrl: true })
await wait(() => composer.viewed.length > 0)
await composer.app.renderOnce()
expect(composer.app.captureCharFrame()).toContain("Shell output")
expect(composer.viewed).toEqual(["sh-a"])
} finally {
composer.app.renderer.destroy()
}
})
function session(id: string, title: string, parentID?: string) {
return {
id,
+7 -1
View File
@@ -15,6 +15,8 @@ import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider } from "../../../src/context/route"
import { ThemeProvider } from "../../../src/context/theme"
import { Composer } from "../../../src/routes/session/composer"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
import { emptyThemeSource } from "../../fixture/fixture"
@@ -2020,7 +2022,11 @@ test("keeps shell state scoped to location", async () => {
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
<ToastProvider>
<DialogProvider>
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</RouteProvider>
@@ -0,0 +1,221 @@
/** @jsxImportSource @opentui/solid */
import { ScrollBoxRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { ShellInfo } from "@opencode-ai/client"
import { expect, test } from "bun:test"
import { createSignal, onMount } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { ClientProvider } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { Keymap } from "../../src/context/keymap"
import { RouteProvider } from "../../src/context/route"
import { ThemeProvider } from "../../src/context/theme"
import { Composer } from "../../src/routes/session/composer"
import { DialogProvider } from "../../src/ui/dialog"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
import { createApi, createEventStream, createFetch, json } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
async function setup(width: number, output = "") {
const temporary = await tmpdir()
const location = { directory: `${temporary.path}/original`, workspaceID: "workspace_fixture" }
const shell: ShellInfo = {
id: "sh_fixture",
command: "render-scene --quality high",
cwd: location.directory,
shell: "/bin/sh",
file: `${temporary.path}/capture.out`,
status: "running",
metadata: { sessionID: "ses_fixture" },
time: { started: 0 },
}
const state = { output, missing: false, failure: false }
const requests: { url: URL; method: string }[] = []
const events = createEventStream()
const envelope = (data: unknown) => json({ location, data })
const api = createApi(
createFetch((url, request) => {
if (!url.pathname.startsWith("/api/shell")) return undefined
requests.push({ url, method: request.method })
if (url.pathname === "/api/shell") return envelope([shell])
if (state.missing)
return json({ _tag: "ShellNotFoundError", id: shell.id, message: "Shell not found" }, { status: 404 })
if (state.failure) return new Response("Unavailable", { status: 503 })
if (url.pathname === `/api/shell/${shell.id}`) return envelope(shell)
const bytes = Buffer.from(state.output)
const cursor = Math.min(Number(url.searchParams.get("cursor") ?? 0), bytes.length)
const end = Math.min(cursor + Number(url.searchParams.get("limit") ?? 65536), bytes.length)
return envelope({
output: bytes.subarray(cursor, end).toString(),
cursor: end,
size: bytes.length,
truncated: false,
})
}, events).fetch,
)
function Shells() {
const data = useData()
const [open, setOpen] = createSignal(true)
onMount(() => void data.shell.sync(location))
return <Composer sessionID="ses_fixture" open={open()} defaultTab="shell" onClose={() => setOpen(false)} />
}
const app = await testRender(
() => (
<TestTuiContexts directory={temporary.path} paths={{ state: temporary.path }}>
<ConfigProvider config={createTuiResolvedConfig({ session: { terminal: false } })}>
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_fixture" }}>
<ClientProvider api={api}>
<DataProvider directory={temporary.path}>
<ThemeProvider mode={width === 40 ? "light" : "dark"} source={emptyThemeSource}>
<Keymap.Provider>
<ToastProvider>
<DialogProvider>
<Shells />
</DialogProvider>
</ToastProvider>
</Keymap.Provider>
</ThemeProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 30, kittyKeyboard: true },
)
app.renderer.start()
await app.waitForFrame((frame) => frame.includes(shell.command))
return {
...app,
state,
shell,
location,
requests,
events,
async [Symbol.asyncDispose]() {
app.renderer.destroy()
await temporary[Symbol.asyncDispose]()
},
}
}
test.each([40, 100])("shell output opens, follows, scrolls, and survives exit at %s columns", async (width) => {
await using app = await setup(width, Array.from({ length: 50 }, (_, i) => `Frame ${i + 1}\n`).join(""))
expect(app.captureCharFrame()).toContain("output")
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Shell output") && frame.includes("Frame 50"))
const scroll = app.renderer.root.findDescendantById("shell-output-scroll")
if (!(scroll instanceof ScrollBoxRenderable)) throw new Error("Output scrollbox missing")
expect(scroll.scrollTop).toBeGreaterThan(0)
app.mockInput.pressKey("HOME")
await app.waitForFrame((frame) => frame.includes("Frame 1\n") || /Frame 1\s/.test(frame))
expect(scroll.scrollTop).toBe(0)
app.state.output += "Frame 51\n"
await app.waitFor(
() =>
app.requests.some(
(request) => request.url.searchParams.get("cursor") === String(Buffer.byteLength(app.state.output)),
),
{ maxPasses: 150 },
)
expect(scroll.scrollTop).toBe(0)
app.mockInput.pressKey("END")
await app.waitForFrame((frame) => frame.includes("Frame 51"))
app.shell.status = "exited"
app.shell.exit = 0
app.events.emit({
id: "evt_exit",
created: 0,
type: "shell.exited",
location: app.location,
data: { id: app.shell.id, exit: 0, status: "exited" },
})
await app.waitForFrame((frame) => frame.includes("code 0"), { maxPasses: 100 })
const metadataReads = app.requests.filter((request) => request.url.pathname === `/api/shell/${app.shell.id}`).length
// Terminal metadata can arrive before the capture's final flush.
app.state.output += "\u001b[32mRender complete\u001b[0m\r\n"
await app.waitForFrame((frame) => frame.includes("Render complete") && frame.includes("code 0"), { maxPasses: 100 })
expect(app.requests.filter((request) => request.url.pathname === `/api/shell/${app.shell.id}`)).toHaveLength(
metadataReads,
)
expect(app.captureCharFrame()).not.toContain("[32m")
expect(app.requests.every((request) => request.method === "GET")).toBe(true)
const reads = app.requests.filter((request) => request.url.pathname !== "/api/shell")
expect(reads.every((request) => request.url.searchParams.get("location[directory]") === app.location.directory)).toBe(
true,
)
expect(
reads.every((request) => request.url.searchParams.get("location[workspace]") === app.location.workspaceID),
).toBe(true)
app.mockInput.pressEscape()
await app.waitForFrame((frame) => !frame.includes("Shell output") && frame.includes("No shell commands"))
const count = app.requests.length
await Bun.sleep(1100)
expect(app.requests).toHaveLength(count)
})
test("empty output explains redirection, retries errors, and preserves output after removal", async () => {
await using app = await setup(100)
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("No captured output") && frame.includes("redirected"))
app.state.failure = true
await app.waitForFrame((frame) => frame.includes("Retrying"), { maxPasses: 100 })
app.state.failure = false
app.state.output = "Recovered output\n"
await app.waitForFrame((frame) => frame.includes("Recovered output") && !frame.includes("Retrying"), {
maxPasses: 100,
})
app.state.missing = true
await app.waitForFrame((frame) => frame.includes("no longer available"), { maxPasses: 100 })
expect(app.captureCharFrame()).toContain("Recovered output")
const count = app.requests.length
await Bun.sleep(1100)
expect(app.requests).toHaveLength(count)
})
test.each([40, 100])("mouse-wheel scrolling pauses and resumes output following at %s columns", async (width) => {
await using app = await setup(width, Array.from({ length: 50 }, (_, i) => `Frame ${i + 1}\n`).join(""))
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Shell output") && frame.includes("Frame 50"))
const scroll = app.renderer.root.findDescendantById("shell-output-scroll")
if (!(scroll instanceof ScrollBoxRenderable)) throw new Error("Output scrollbox missing")
const bottom = scroll.scrollTop
await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "up")
await app.waitFor(() => scroll.scrollTop < bottom)
const paused = scroll.scrollTop
const height = scroll.scrollHeight
app.state.output += "Frame 51\n"
await app.waitFor(() => scroll.scrollHeight > height, { maxPasses: 100 })
expect(scroll.scrollTop).toBe(paused)
expect(app.captureCharFrame()).toContain("Shell output")
await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "down")
await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "down")
await app.waitFor(() => scroll.scrollTop === scroll.scrollHeight - scroll.viewport.height)
const followed = scroll.scrollTop
app.state.output += "Frame 52\n"
await app.waitForFrame((frame) => frame.includes("Frame 52"), { maxPasses: 100 })
expect(scroll.scrollTop).toBeGreaterThan(followed)
expect(scroll.scrollTop).toBe(scroll.scrollHeight - scroll.viewport.height)
})
test("large captures open at a bounded tail and clicking a shell opens the viewer", async () => {
await using app = await setup(100, "old output\n".repeat(20000) + "Latest frame\n")
const row = app
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes(app.shell.command))
await app.mockMouse.click(6, row)
await app.waitForFrame((frame) => frame.includes("Latest frame") && frame.includes("Earlier output omitted"))
const reads = app.requests.filter((request) => request.url.pathname.endsWith("/output"))
expect(reads[0]?.url.searchParams.get("cursor")).toBe(String(Number.MAX_SAFE_INTEGER))
expect(reads[1]?.url.searchParams.get("cursor")).toBe(String(Buffer.byteLength(app.state.output) - 65536))
expect(reads[1]?.url.searchParams.get("limit")).toBe("65536")
})
@@ -9,6 +9,7 @@
overflow: hidden;
}
[data-component="split-button-v2"].session-review-v2-open-in-app,
[data-component="split-button-v2"]:is(:hover, :has([data-component="split-button-v2-menu-trigger"][data-expanded])) {
box-shadow: inset 0 0 0 1px var(--v2-border-border-muted);
}
@@ -1,4 +1,5 @@
import { Icon } from "@opencode-ai/ui/icon"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { SplitButton, SplitButtonAction, SplitButtonMenuTrigger } from "./split-button"
export default {
@@ -29,3 +30,16 @@ export const Disabled = {
</SplitButton>
),
}
export const OpenIn = {
render: () => (
<SplitButton class="session-review-v2-open-in-app">
<SplitButtonAction aria-label="Open in Finder">
<AppIcon id="finder" />
</SplitButtonAction>
<SplitButtonMenuTrigger aria-label="Open options">
<Icon name="chevron-down" size="small" />
</SplitButtonMenuTrigger>
</SplitButton>
),
}
+6 -2
View File
@@ -210,8 +210,12 @@ const layer = Layer.effect(
Effect.gen(function* () {
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const add = input.add ?? []
const npmOptions = yield* NpmConfig.load(input.config ?? input.dir)
const options = input.update ? { ...npmOptions, preferOnline: true, noGitRevCache: true } : npmOptions
const options = {
...(yield* NpmConfig.load(input.config ?? input.dir)),
...(input.update ? { preferOnline: true, noGitRevCache: true } : {}),
// Audit reports are unused here, but Arborist waits for them before completing an install.
audit: false,
}
const arborist = new Arborist({
...options,
path: input.dir,
@@ -317,8 +317,13 @@ The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, an
| `composer.subagent.interrupt` | `ctrl+d` | Interrupt subagent |
| `composer.shell.up` | `up` | Previous shell |
| `composer.shell.down` | `down` | Next shell |
| `composer.shell.select` | `return` | View shell output |
| `composer.shell.kill` | `ctrl+d` | Kill shell command |
Select a running command in the **Shell** tab and press **Enter**, or click it, to view captured stdout and stderr.
Use **↑/↓**, **Page Up/Down**, or **Home** to scroll, **End** to follow new output, and **Esc** to return without stopping the command.
The viewer shows recent output and stays open after exit; output redirected to a file is not included.
## Dialogs And Autocomplete
| ID | Default | Description |
+88
View File
@@ -0,0 +1,88 @@
import { Effect, Stream } from "effect"
import { Llm, OpenCodeDriver } from "opencode-drive"
const label = process.env.DEMO_LABEL ?? "AFTER"
// Run from the repository root with `opencode-drive run script/drive/shell-output.ts`.
// Set OPENCODE_DEV to an immutable base worktree and DEMO_LABEL=BEFORE for comparison.
// Only the conversation is simulated; shell execution and output reads are real.
export default OpenCodeDriver.use(
{
opencode: { dev: process.env.OPENCODE_DEV ?? process.cwd() },
keepArtifacts: true,
tui: { recording: true, keypressOverlay: true, viewport: { cols: 90, rows: 30 } },
config: { autoupdate: false, username: "Demo" },
tuiConfig: { theme: { name: "opencode", mode: "dark" }, animations: false, tabs: { enabled: false } },
project: {
git: true,
files: {
"README.md": "# Shell output demo\nDeterministic real shell output.\n",
"render-scene.sh": [
"#!/bin/sh",
"i=1",
'while [ "$i" -le 40 ]; do printf "Frame %02d: rendered successfully\\n" "$i"; i=$((i+1)); done',
"while [ ! -f continue ]; do sleep 0.1; done",
'while [ "$i" -le 48 ]; do printf "Frame %02d: rendered successfully\\n" "$i"; i=$((i+1)); sleep 0.25; done',
"while [ ! -f finish ]; do sleep 0.1; done",
"printf 'Diagnostics: no errors\\n' >&2",
"printf 'Render complete: 48 frames saved.\\n'",
].join("\n"),
},
},
},
({ ui, llm, tui, opencode, artifacts }) =>
Effect.gen(function* () {
const recording = tui.recording
if (!recording) return yield* Effect.fail(new Error("Recording required"))
yield* llm.serve(() => Stream.make(Llm.text("Ready to inspect the render job.")))
yield* ui.submit("Inspect the render job.")
yield* ui.waitFor("Ready to inspect the render job.")
const sessions = yield* opencode.session.list({ limit: 1, order: "desc" })
const session = sessions.data[0]
if (!session) return yield* Effect.fail(new Error("Session missing"))
yield* opencode.session.rename({ sessionID: session.id, title: "Shell output demo" })
yield* opencode.shell.create({ command: "sh render-scene.sh", timeout: 0, metadata: { sessionID: session.id } })
yield* ui.arrow("down")
yield* ui.arrow("right")
yield* ui.waitFor("sh render-scene.sh")
yield* recording.mark(`${label}: select a running shell`)
yield* Effect.sleep(1000)
yield* ui.enter()
yield* ui.waitFor(label === "AFTER" ? "Frame 40: rendered successfully" : "sh render-scene.sh")
yield* Effect.sleep(1000)
yield* recording.mark(`${label}: Enter ${label === "AFTER" ? "opens live output" : "does nothing"}`)
console.log("opened:", yield* ui.screenshot(`${label.toLowerCase()}-opened`))
yield* Effect.promise(() => Bun.write(`${artifacts}/files/continue`, "go"))
if (label === "AFTER") yield* ui.waitFor("Frame 48: rendered successfully")
yield* Effect.sleep(2800)
yield* ui.press("home")
yield* ui.waitFor(label === "AFTER" ? "Frame 01: rendered successfully" : "sh render-scene.sh")
yield* recording.mark(`${label}: ${label === "AFTER" ? "Home scrolls to earlier output" : "no output to scroll"}`)
yield* Effect.sleep(1500)
console.log("scrolled:", yield* ui.screenshot(`${label.toLowerCase()}-scrolled`))
yield* ui.press("end")
yield* ui.waitFor(label === "AFTER" ? "Frame 48: rendered successfully" : "sh render-scene.sh")
yield* recording.mark(`${label}: ${label === "AFTER" ? "End follows the latest output" : "no output to follow"}`)
yield* Effect.sleep(1000)
yield* Effect.promise(() => Bun.write(`${artifacts}/files/finish`, "go"))
yield* ui.waitFor(label === "AFTER" ? "Render complete: 48 frames saved." : "No shell commands")
if (label === "AFTER") yield* ui.waitFor("Exited · code 0")
yield* Effect.sleep(1600)
yield* recording.mark(
`${label}: ${label === "AFTER" ? "result stays open after exit" : "finished shell disappears"}`,
)
console.log("exited:", yield* ui.screenshot(`${label.toLowerCase()}-exited`))
yield* Effect.sleep(2000)
yield* ui.resize({ cols: 40, rows: 24 })
yield* Effect.sleep(500)
console.log("narrow:", yield* ui.screenshot(`${label.toLowerCase()}-narrow`))
yield* ui.resize({ cols: 90, rows: 30 })
yield* Effect.sleep(500)
yield* ui.press("escape")
if (label === "AFTER") yield* ui.waitFor("No shell commands")
yield* recording.mark(`${label}: Esc back`)
yield* Effect.sleep(1000)
console.log("back:", yield* ui.screenshot(`${label.toLowerCase()}-back`))
return console.log("video:", yield* recording.finish())
}),
)