Compare commits

...
12 Commits
Author SHA1 Message Date
Brendan Allan b1374978c9 fix(app): prevent Safari shell content from collapsing 2026-08-27 16:59:43 +08:00
Luke Parker ed95fdaa27 fix(app): show MCP connection failures as toasts (#45522) 2026-08-27 08:46:54 +00:00
Luke Parker 53a4829672 fix(app): restore fullscreen settings version footer (#45419) 2026-08-27 17:57:04 +10:00
Luke Parker 7036294543 fix(app): align thinking states and reasoning settings (#45515) 2026-08-27 17:50:39 +10:00
opencode-agent[bot]andHona 23506b5fb4 fix(app): scope MCP picker toggles to workspace (#45509)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-27 17:20:38 +10:00
Luke Parker 2af02d0ad7 fix(app): use worktree inventory for session accents (#45506) 2026-08-27 07:03:16 +00:00
opencode-agent[bot]andBrendonovich 26ee104829 fix(app): prevent renderer OOM on multiline paste (#45497)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-27 14:31:28 +08:00
opencode-agent[bot]andBrendonovich 8252897a33 fix(app): precache complete builds before serving cached HTML (#45495)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-27 14:23:31 +08:00
Luke Parker 7000607fd0 fix(app): stop shimmer when background shells finish (#45494) 2026-08-27 16:15:34 +10:00
opencode-agent[bot]andBrendonovich fa1ab5f8e1 fix(app): restore worktree message accent for further trial (#45486)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-27 05:18:54 +00:00
Aiden Cline e288e0fc4d feat(plugin): allow tool call repair before lookup (#45453) 2026-08-27 00:10:43 -05:00
Luke Parker ff5b5d00f9 fix(app): preserve tool disclosures when groups update (#45474) 2026-08-27 15:02:39 +10:00
60 changed files with 2626 additions and 733 deletions
+6
View File
@@ -244,6 +244,12 @@ jobs:
CI: true
timeout-minutes: 30
- name: Verify service worker precaching and upgrades
if: env.E2E_ENABLED == 'true'
working-directory: packages/app
run: bunx playwright test --config e2e/service-worker/playwright.config.ts
timeout-minutes: 5
- name: Upload Playwright artifacts
if: always() && env.E2E_ENABLED == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -0,0 +1,120 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_large_paste"
const directory = "/repo/large-paste"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_large_paste",
worktree: directory,
vcs: "git",
name: "large-paste",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", "dark")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
const input = page.locator('[data-component="composer-editor"]')
await expectAppVisible(input)
await expect(input).toBeEditable()
await expect
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
.toBe(`"${String.fromCodePoint(0x200b)}"`)
await input.click()
})
for (const lines of [6000, 25000]) {
test(`keeps a ${lines}-line crash report editable in a new session`, async ({ page }) => {
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
const text = "Thread 0 Crashed:\n" + "0 Example 0x0000000100000000 frame + 32\n".repeat(lines) + "End of report"
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
const events = await input.evaluateHandle((element) => {
const events = { count: 0 }
element.addEventListener("input", () => events.count++)
return events
})
await page.keyboard.press("ControlOrMeta+V")
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
expect(await events.evaluate((events) => events.count)).toBe(1)
await expect(input).toBeFocused()
await page.keyboard.type("!")
await expect.poll(async () => (await input.innerText()) === text + "!").toBe(true)
})
}
for (const text of [
"single line <b> &amp;",
"first\nsecond",
"\n\n indented\ttext \n\nlast\n\n",
'literal <b>bold</b> &amp; & < > "quotes"\n<script>not code</script>\n<img src="example">',
"first\r\nsecond\rthird",
]) {
test(`preserves text and native undo: ${JSON.stringify(text)}`, async ({ page }) => {
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
await page.keyboard.press("ControlOrMeta+V")
const expected = text.replace(/\r\n?/g, "\n")
await expect.poll(() => input.innerText()).toBe(expected)
await expect(input.locator("b, script, img")).toHaveCount(0)
await page.keyboard.press("ControlOrMeta+Z")
await expect(input).toBeEmpty()
await page.keyboard.press("ControlOrMeta+Shift+Z")
await expect.poll(() => input.innerText()).toBe(expected)
})
}
test("replaces only the selected text and leaves the caret after the paste", async ({ page }) => {
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
await page.evaluate(() => navigator.clipboard.writeText("one\ntwo"))
await page.keyboard.type("before replace after")
await expect(input).toHaveText("before replace after")
await page.evaluate(() => document.fonts.ready)
const word = await input.evaluate((element) => {
const range = document.createRange()
range.setStart(element.firstChild!, 7)
range.setEnd(element.firstChild!, 14)
const rect = range.getBoundingClientRect()
return { x: rect.x, y: rect.y + rect.height / 2, width: rect.width }
})
await page.mouse.move(word.x, word.y)
await page.mouse.down()
await page.mouse.move(word.x + word.width, word.y, { steps: 5 })
await page.mouse.up()
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("replace")
await page.keyboard.press("ControlOrMeta+V")
await expect.poll(() => input.innerText()).toBe("before one\ntwo after")
await page.keyboard.press("ControlOrMeta+Z")
await expect(input).toHaveText("before replace after")
await page.keyboard.press("ControlOrMeta+Shift+Z")
await expect.poll(() => input.innerText()).toBe("before one\ntwo after")
await page.keyboard.type("!")
await expect.poll(() => input.innerText()).toBe("before one\ntwo! after")
})
@@ -0,0 +1,178 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:\\OpenCode\\main"
const workspace = "C:\\OpenCode\\worktree"
const projectID = "proj_mcp_workspace"
const sessionID = "ses_mcp_workspace"
const title = "Workspace MCP routing"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
for (const shared of [true, false]) {
test(`toggles the workspace MCP when the default location ${shared ? "has" : "does not have"} the server`, async ({
page,
}, testInfo) => {
const connected = new Set<string>()
const requests: { path: string; directory: string }[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "mcp-workspace",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [workspace],
},
provider: { all: [], connected: [], default: {} },
sessions: [{ id: sessionID, projectID, directory: workspace, title }],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/connect") {
connected.add(target)
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
connected.delete(target)
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: target },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: !shared && target !== workspace
? []
: [{ name: "figma-desktop", status: { status: connected.has(target) ? "connected" : "disabled" } }],
},
})
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await page.keyboard.press("ControlOrMeta+;")
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
const toggle = dialog.getByRole("switch")
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
requests.length = 0
await dialog.locator('[data-slot="switch-control"]').click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
expect(connected).toEqual(new Set([workspace]))
expect(requests).toContainEqual({ path: "/api/mcp/figma-desktop/connect", directory: workspace })
expect(requests).toContainEqual({ path: "/api/mcp/resource", directory: workspace })
expect(requests.every((request) => request.directory === workspace)).toBe(true)
await testInfo.attach("workspace-connected", { body: await page.screenshot(), contentType: "image/png" })
requests.length = 0
await dialog.getByText("figma-desktop", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
expect(connected.size).toBe(0)
expect(requests).toContainEqual({ path: "/api/mcp/figma-desktop/disconnect", directory: workspace })
expect(requests.every((request) => request.directory === workspace)).toBe(true)
})
}
for (const surface of ["popover", "dialog"] as const) {
test(`shows connection failures from the MCP ${surface} and allows reconnecting`, async ({ page }, testInfo) => {
const error = "Streamable HTTP error: Error POSTing to endpoint: 404 Not Found"
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
const requests: { path: string; directory: string }[] = []
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
})
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "mcp-workspace",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [workspace],
},
provider: { all: [], connected: [], default: {} },
sessions: [{ id: sessionID, projectID, directory: workspace, title }],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/connect") {
state.status = state.fail ? "failed" : "connected"
// Connection failures are reported by the refreshed status, not the HTTP response.
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: target },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [
{
name: "figma-desktop",
status: { status: target === workspace ? state.status : "connected", error },
},
],
},
})
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
const panel =
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
const toggle = panel.getByRole("switch")
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
requests.length = 0
await panel.locator('[data-slot="switch-control"]').click()
const toast = page
.getByRole("listitem", { includeHidden: true })
.filter({ has: page.getByText("Request failed", { exact: true }) })
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
])
expect(requests.every((request) => request.directory === workspace)).toBe(true)
await expect(toast).toHaveCSS("opacity", "1")
await testInfo.attach("mcp-connection-error", { body: await page.screenshot(), contentType: "image/png" })
if (surface === "popover") await page.keyboard.press("Escape")
if (surface === "dialog") await panel.getByRole("button", { name: "Close", exact: true }).click()
await expect(panel).toBeHidden()
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
await expect(toast).toBeHidden()
state.fail = false
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
await expect(toggle).toBeEnabled()
await panel.locator('[data-slot="switch-control"]').click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
await expect(toast).toBeHidden()
})
}
@@ -261,7 +261,7 @@ for (const delivery of ["steer", "queue"] as const) {
const transcript = page.locator("[data-timeline-virtual-content]")
const thinking = transcript.locator('[data-timeline-row="Thinking"]')
await expect(transcript.getByText("A1: I will inspect the current implementation.", { exact: true })).toBeVisible()
await expect(thinking).toBeVisible()
await expect(thinking).toHaveCount(0)
await expect(view.input).toBeEditable()
await view.input.fill(followUp)
await view.input.press("Enter")
@@ -274,12 +274,14 @@ for (const delivery of ["steer", "queue"] as const) {
const queued = view.rows.filter({ hasText: followUp })
await expect(queued).toBeVisible()
await expect(pending).toHaveCount(0)
await expect(thinking).toHaveCount(0)
await queued.hover()
await queued.getByRole("button", { name: "Steer", exact: true }).click()
await expect.poll(() => mock.changes).toEqual([{ inboxID, action: "steer" }])
}
await expect(view.rows).toHaveCount(0)
await expect(pending).toContainText(followUp)
await expect(thinking).toHaveCount(0)
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
@@ -308,7 +310,7 @@ for (const delivery of ["steer", "queue"] as const) {
await expect(tools).toBeVisible()
await expect(tools).toContainText(/Used\s*Read, Grep/)
await expect(tools.locator('[data-component="tag"]')).toHaveText("2")
await expect(thinking).toBeVisible()
await expect(thinking).toHaveCount(0)
await expect(pending).toBeVisible()
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
{ id: inboxID, delivery: "steer" },
@@ -316,27 +318,21 @@ for (const delivery of ["steer", "queue"] as const) {
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
// Soft assertions let delivery run too, even when the pending ordering regresses.
await expect
.soft(tools.or(thinking).or(pending))
.toHaveText([/Used\s*Read, Grep/, /Thinking/, /U2: Also check the retry path\./])
await expect.soft(tools.or(pending)).toHaveText([/Used\s*Read, Grep/, /U2: Also check the retry path\./])
await expect
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
.toHaveAttribute("data-message-id", userID)
await expect
.configure({ soft: true })
.poll(async () => {
const boxes = await Promise.all([tools.boundingBox(), thinking.boundingBox(), pending.boundingBox()])
return (
boxes.every((box) => box !== null) &&
boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y &&
boxes[1]!.y + boxes[1]!.height <= boxes[2]!.y
)
const boxes = await Promise.all([tools.boundingBox(), pending.boundingBox()])
return boxes.every((box) => box !== null) && boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y
})
.toBe(true)
mock.rows.splice(0, 1)
mock.emit("session.inbox.delivered", { sessionID, inboxID })
await expect(thinking).toHaveAttribute("data-message-id", inboxID)
await expect(thinking).toHaveCount(0)
await expect(pending).toHaveCount(1)
await expect(transcript.locator('[data-timeline-row="UserMessage"]')).toHaveCount(2)
await expect(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })).toHaveAttribute(
@@ -352,11 +348,11 @@ for (const delivery of ["steer", "queue"] as const) {
.locator('[data-timeline-row="AssistantPart"]')
.filter({ hasText: "A3: Now checking the retry path for U2." })
await expect(response).toHaveAttribute("data-message-id", inboxID)
await expect(tools.or(pending).or(response).or(thinking)).toHaveText([
await expect(thinking).toHaveCount(0)
await expect(tools.or(pending).or(response)).toHaveText([
/Used\s*Read, Grep/,
/U2: Also check the retry path\./,
/A3: Now checking the retry path for U2\./,
/Thinking/,
])
})
}
@@ -0,0 +1,191 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, ShellInfo } from "@opencode-ai/client/promise"
import { directory, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const shell = {
id: "sh_background",
status: "running",
command: "bun run check",
cwd: directory,
shell: "bash",
file: "/tmp/check.out",
metadata: { sessionID },
time: { started: 2 },
} satisfies ShellInfo
for (const grouped of [false, true]) {
for (const status of ["exited", "killed", "timeout"] as const) {
test(`stops ${grouped ? "grouped" : "standalone"} background shell shimmer when ${status}`, async ({
page,
}, info) => {
const message: SessionMessageAssistant = {
id: "msg_background",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [shell.id, "sh_other"].map((id) => ({
type: "tool",
id: `call_${id}`,
name: "shell",
state: {
status: "completed",
input: { command: shell.command },
content: [{ type: "text", text: "Command moved to the background." }],
metadata: { shellID: id, status: "running" },
},
time: { created: 2, completed: 3 },
})),
time: { created: 2, completed: 3 },
}
if (grouped)
message.content.unshift({
type: "tool",
id: "call_read",
name: "read",
state: {
status: "completed",
input: { path: "package.json" },
content: [{ type: "text", text: "{}" }],
metadata: {},
},
time: { created: 1, completed: 2 },
})
const timeline = await setupTimeline(page, {
viewport: { width: grouped ? 390 : 1400, height: 900 },
settings: { shellToolPartsExpanded: !grouped },
sessionStatus: { [sessionID]: { type: "busy" } },
sessionMessages: [
{ id: "msg_user", type: "user", text: "Run two independent checks.", time: { created: 1 } },
message,
],
})
const state = { finished: false, requests: 0 }
await page.route("**/api/shell?*", (route) =>
route.fulfill({
json: { location: { directory }, data: [...(state.finished ? [] : [shell]), { ...shell, id: "sh_other" }] },
}),
)
await page.route("**/api/shell/*/output?*", (route) => {
const url = new URL(route.request().url())
const target = url.pathname.includes(`/${shell.id}/`)
if (target) state.requests++
const output = target && state.finished ? "Checking project\nCheck finished\n" : "Checking project\n"
const cursor = Number(url.searchParams.get("cursor") ?? 0)
const end = Math.min(output.length, cursor + 17)
return route.fulfill({
json: {
location: { directory },
data: {
output: output.slice(cursor, end),
cursor: end,
size: output.length,
truncated: false,
},
},
})
})
await page.clock.install()
await page.reload()
await timeline.transport.waitForConnection()
const group = page.locator('[data-component="collapsed-tool-group"]')
const groupTrigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
if (grouped) {
await expect(group).toHaveAttribute("data-timeline-part-ids", "call_read,call_sh_background,call_sh_other")
await expect(groupTrigger).toHaveAttribute("aria-expanded", "false")
await groupTrigger.click()
}
const card = page.locator(`[data-timeline-part-id="call_${shell.id}"]`)
const shimmer = card.locator('[data-component="text-shimmer"]')
const other = page.locator('[data-timeline-part-id="call_sh_other"] [data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(other).toHaveAttribute("data-active", "true")
if (grouped) await card.locator('[data-slot="collapsible-trigger"]').click()
await expect(card.locator('[data-slot="bash-result"]')).toHaveText("Checking project")
state.finished = true
await timeline.transport.send({
id: "evt_shell_exited",
created: 4,
type: "shell.exited",
location: { directory },
data: { id: shell.id, status, exit: status === "exited" ? 0 : 1 },
})
await expect(shimmer).toHaveAttribute("data-active", "false")
await expect(other).toHaveAttribute("data-active", "true")
await expect(card.locator('[data-slot="bash-result"]')).toHaveText("Checking project\nCheck finished")
await expect(card.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await page.locator("[data-timeline-virtual-content]").screenshot({ path: info.outputPath("shell-finished.png") })
const requests = state.requests
await page.clock.fastForward(5_000)
expect(state.requests).toBe(requests)
await page.reload()
if (grouped) await groupTrigger.click()
await expect(shimmer).toHaveAttribute("data-active", "false")
await expect(other).toHaveAttribute("data-active", "true")
})
}
}
test("shows the authoritative foreground result after streaming shell output", async ({ page }) => {
const timeline = await setupTimeline(page, {
settings: { shellToolPartsExpanded: true },
sessionMessages: [
{ id: "msg_user", type: "user", text: "Run the check.", time: { created: 1 } },
{
id: "msg_foreground",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_foreground",
name: "shell",
state: { status: "running", input: { command: shell.command }, metadata: { shellID: shell.id } },
time: { created: 2 },
},
],
time: { created: 2 },
},
],
})
await page.route("**/api/shell/*/output?*", (route) =>
route.fulfill({
json: {
location: { directory },
data: {
output: Number(new URL(route.request().url()).searchParams.get("cursor")) === 0 ? "Checking project\n" : "",
cursor: 17,
size: 17,
truncated: false,
},
},
}),
)
await page.reload()
await timeline.transport.waitForConnection()
const card = page.locator('[data-timeline-part-id="call_foreground"]')
const shimmer = card.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(card.locator('[data-slot="bash-result"]')).toHaveText("Checking project")
await timeline.transport.send({
id: "evt_foreground_complete",
created: 3,
type: "session.tool.success",
durable: { aggregateID: sessionID, seq: 0, version: 2 },
data: {
sessionID,
assistantMessageID: "msg_foreground",
id: "call_foreground",
executed: true,
content: [{ type: "text", text: "Checking project\nCheck finished\nCommand exited with code 0." }],
metadata: { status: "completed", exit: 0 },
},
})
await expect(shimmer).toHaveAttribute("data-active", "false")
await expect(card.locator('[data-slot="bash-result"]')).toHaveText(
"Checking project\nCheck finished\nCommand exited with code 0.",
)
})
@@ -84,6 +84,39 @@ const assistantMessage = {
} satisfies SessionMessageInfo
test.describe("regression: session timeline local row state", () => {
test("preserves a patch file choice as new calls join its Used group", async ({ page }) => {
const events: EventPayload[] = []
const part = { ...editPart, tool: "patch" }
await mockServer(page, events, [userMessage, { ...assistantMessage, content: [toolContent(part)] }])
await configurePage(page, false)
await page.goto(sessionHref())
await expectSessionTitle(page, title)
const group = page.locator('[data-component="collapsed-tool-group"]')
const summary = group.getByRole("button", { name: "Used Patch", exact: true })
await summary.click()
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
element.setAttribute("data-disclosure-probe", "existing")
})
const wrapper = group.locator('[data-disclosure-probe="existing"]')
const trigger = wrapper.locator('[data-scope="apply-patch"] button')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
const original = await wrapper.elementHandle()
for (const count of [2, 3]) {
if (count === 3) await trigger.click()
const id = `prt_patch_${count}`
events.push(...toolEvents({ ...part, id, callID: id }))
await expect(group.locator('[data-component="tag"]')).toHaveText(String(count))
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
await expect(summary).toHaveAttribute("aria-expanded", "true")
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
}
})
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
const events: EventPayload[] = []
await mockServer(page, events)
@@ -208,19 +241,19 @@ test.describe("regression: session timeline local row state", () => {
})
})
async function configurePage(page: Page) {
await page.addInitScript(() => {
async function configurePage(page: Page, expanded = true) {
await page.addInitScript((expanded) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
editToolPartsExpanded: expanded,
shellToolPartsExpanded: expanded,
showReasoningSummaries: true,
},
}),
)
})
}, expanded)
}
async function expectExpanded(locator: Locator, expected: boolean) {
@@ -109,31 +109,85 @@ test("shimmers and expands a running shell command", async ({ page }) => {
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
})
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
const reasoningID = "prt_reasoning_hidden"
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: { showReasoningSummaries: false },
cpuRate: 4,
for (const open of [false, true]) {
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
page,
}) => {
const reasoningID = `prt_reasoning_hidden_${open}`
const shellID = `prt_reasoning_shell_${open}`
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: { showReasoningSummaries: false },
cpuRate: 4,
})
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
const thought = reasoning.locator('[data-slot="collapsible-trigger"]')
await expect(thought).toHaveAttribute("aria-expanded", "false")
await thought.click()
await expect(thought).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
await expect(thought).toHaveAttribute("aria-expanded", String(open))
await timeline.send(partUpdated(shell(shellID, "running")))
const group = page.locator('[data-component="collapsed-tool-group"]')
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
await expect(group).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(thought).toContainText("Thought")
await expect(thought).not.toContainText("Inspecting stability")
await expect(thought).toHaveAttribute("aria-expanded", String(open))
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
const used = group.getByRole("button", { name: "Used Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(open),
)
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
})
await timeline.send(status("busy"), 150)
}
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
await timeline.send(status("idle"), 300)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
})
for (const transition of ["reasoning-end", "idle", "retry"] as const) {
test(`stops active Thinking on ${transition} without a following tool`, async ({ page }) => {
const id = `prt_reasoning_stop_${transition}`
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
})
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
const trigger = part.locator('[data-slot="collapsible-trigger"]')
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.send(transition === "reasoning-end" ? partUpdated(reasoningPart(id, text)) : status(transition))
await expect(trigger).toContainText("Thought")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(transition === "retry" ? 1 : 0)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(part.getByText("The timeline is ready for the next step.", { exact: true })).toBeVisible()
})
}
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
test("does not infer Thinking from busy, retry, or recovery without reasoning", async ({ page }) => {
const assistant = assistantMessage([], { completed: false })
const timeline = await setupTimeline(page, {
messages: [
@@ -153,18 +207,17 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
assistant,
],
})
await timeline.send(status("busy"), 140)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
await timeline.send(status("retry"), 180)
await timeline.send(status("retry"))
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.send(stepStarted(assistant), 180)
await timeline.send(stepStarted(assistant))
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
await timeline.send(status("idle"), 350)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText(
"Recovered response",
@@ -5,6 +5,7 @@ import {
compactionEnded,
compactionFailed,
compactionStarted,
directory,
event,
session,
sessionID,
@@ -348,6 +349,24 @@ test("separates blocking and already-backgrounded work into two rows", async ({
},
})
await timeline.transport.send({
id: "evt_background_shell_created",
created: 3,
type: "shell.created",
location: { directory },
data: {
info: {
id: "shell_backgrounded",
status: "running",
command: "sleep 120",
cwd: directory,
shell: "bash",
file: "/tmp/background.out",
metadata: { sessionID },
time: { started: 2 },
},
},
})
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await page.getByRole("button", { name: "Session details" }).click()
@@ -4,89 +4,144 @@ import {
assistantMessage,
reasoningPart,
setupTimeline,
status,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
const profiles = [
{ name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false },
{
name: "summaries off reasoning heading",
summaries: false,
reasoning: "## Inspecting stability",
other: false,
thinking: true,
body: false,
},
{
name: "summaries off with visible tool",
summaries: false,
reasoning: "## Inspecting stability",
other: true,
thinking: true,
body: false,
},
{ name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false },
{
name: "summaries on blank reasoning",
summaries: true,
reasoning: " ",
other: false,
thinking: true,
body: false,
},
{
name: "summaries on visible reasoning",
summaries: true,
reasoning: "## Inspecting stability",
other: false,
thinking: false,
body: true,
},
{
name: "summaries on visible tool no reasoning",
summaries: true,
reasoning: "",
other: true,
thinking: false,
body: false,
},
] as const
for (const profile of profiles) {
test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => {
const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}`
const parts = [
...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []),
...(profile.other
? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })]
: []),
]
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage(parts, { completed: false })],
settings: { showReasoningSummaries: profile.summaries },
})
await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0)
if (!profile.summaries && profile.reasoning.trim()) {
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
}
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
reasoningPart(
"prt_reasoning_settings",
"## Inspecting stability\n\nThe selected mode controls these details.",
),
],
{ completed: false },
),
],
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
const settings = page.getByTestId("settings-screen")
const select = settings.locator('[data-action="settings-reasoning-mode"] [data-component="select-v2"]')
for (const label of ["Full", "Hidden", "Compact"] as const) {
await page.keyboard.press("Control+,")
await expect(settings.getByText("Model reasoning", { exact: true })).toBeVisible()
await expect(select).toHaveAttribute("aria-expanded", "false")
await select.click()
await expect(page.getByRole("listbox").getByRole("option")).toHaveText(["Hidden", "Compact", "Full"])
await page.getByRole("option", { name: label, exact: true }).click()
await expect(select).toHaveText(label)
await expect(select).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.reasoningMode))
.toBe(label.toLowerCase())
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(label === "Hidden" ? 0 : 1)
await expect(part).toHaveCount(label === "Hidden" ? 0 : 1)
if (label === "Hidden") {
await expect(page.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
continue
}
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", String(label === "Full"))
if (label === "Full")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
if (label === "Compact") {
await expect(part.getByRole("button")).toContainText("Inspecting stability")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
}
}
await page.keyboard.press("Control+,")
await expect(select).toHaveText("Compact")
})
// The persisted boolean migrates to compact (false) or full (true).
for (const summaries of [false, true]) {
for (const profile of ["none", "blank", "heading", "tool", "text"] as const) {
test(`projects legacy ${summaries ? "full" : "compact"} reasoning with ${profile}`, async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
...(profile === "none"
? []
: [
reasoningPart(
`prt_reasoning_${summaries}_${profile}`,
profile === "blank"
? " "
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
),
]),
...(profile === "tool"
? [toolPart(`prt_reasoning_tool_${summaries}`, "skill", "running", { name: "inspect" })]
: []),
...(profile === "text" ? [textPart(`prt_reasoning_text_${summaries}`, "The timeline is stable.")] : []),
],
{ completed: false },
),
],
settings: { showReasoningSummaries: summaries },
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(
profile === "blank" || profile === "heading" ? 1 : 0,
)
if (profile === "none") {
await expect(part).toHaveCount(0)
return
}
if (profile === "blank") {
await expect(part).toContainText("Thinking")
await expect(part.getByRole("heading")).toHaveCount(0)
return
}
if (profile === "tool") {
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toContainText("UsedSkill")
await expect(used).toHaveAttribute("aria-expanded", "false")
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(1)
}
if (profile === "text") await expect(page.getByText("The timeline is stable.", { exact: true })).toBeVisible()
const trigger = part.locator('[data-slot="collapsible-trigger"]')
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
await expect(trigger).toContainText(profile === "heading" ? "Thinking" : "Thought")
await expect(trigger).toHaveAttribute("aria-expanded", String(summaries))
if (!summaries) {
await expect(body).toBeHidden()
if (profile === "heading") await expect(trigger).toContainText("Inspecting stability")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
}
await expect(body).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(body).toBeHidden()
if (profile !== "heading") await expect(trigger).not.toContainText("Inspecting stability")
})
}
}
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
const timeline = await setupTimeline(page, {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }),
],
settings: { showReasoningSummaries: true },
})
await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
@@ -1,6 +1,7 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import pkg from "../../package.json" with { type: "json" }
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionA = session("ses_tab_a", "Tab A session")
@@ -175,6 +176,9 @@ test("appearance experimental setting switches tab orientation", async ({ page }
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
const version = settings.getByRole("tablist").getByText(`v${pkg.version}`, { exact: true })
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toBeInViewport()
await expect(version).toBeInViewport()
await settings.getByRole("tab", { name: "Appearance" }).click()
await expect(settings.getByRole("heading", { name: "Experimental" })).toBeVisible()
@@ -194,6 +198,17 @@ test("appearance experimental setting switches tab orientation", async ({ page }
await page.setViewportSize({ width: 800, height: 720 })
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
await expect(version).toBeInViewport()
await page.setViewportSize({ width: 390, height: 720 })
await expect(version).toBeInViewport()
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
await expect(version).toBeInViewport()
await expect(version).toHaveCSS("direction", "ltr")
await page.setViewportSize({ width: 390, height: 360 })
await version.scrollIntoViewIfNeeded()
await expect(version).toBeInViewport()
})
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
@@ -0,0 +1,194 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import type { OpenCodeEvent, WorktreeDirectory } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionReady } from "../utils/waits"
const root = "C:/OpenCode/WorkspaceAccent"
const workspace = `${root}/.worktrees/feature`
const projectID = "proj_workspace_accent"
const sessionID = "ses_workspace_accent"
const title = "Workspace accent regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const inventory: WorktreeDirectory[] = [
{ directory: root },
{ directory: workspace, strategy: "git" },
{ directory: "C:/OpenCode/LinkedWorkspace", strategy: "git" },
{ directory: "C:/OpenCode/WorkspaceCopy", strategy: "copy" },
{ directory: "C:/OpenCode/RegisteredDirectory" },
]
test.use({ serviceWorkers: "block" })
for (const scenario of [
{ name: "managed Git worktree", directory: workspace, accent: true },
{ name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true },
{
name: "linked Git worktree on a narrow screen",
directory: "C:/OpenCode/LinkedWorkspace",
accent: true,
viewport: { width: 390, height: 844 },
},
{ name: "main root with Windows case and separators", directory: "c:\\OPENCODE\\workspaceaccent\\", accent: false },
{ name: "nested main directory", directory: `${root}/packages/app`, accent: false },
{ name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true },
{
name: "workspace with Windows case and separators",
directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\",
accent: true,
},
{ name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false },
{ name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true },
{ name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true },
]) {
test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => {
if (scenario.viewport) await page.setViewportSize(scenario.viewport)
const view = await openSession(page, scenario.directory)
await view.input.fill("Inspect this fixture workspace.")
await expect(view.send).toBeEnabled()
if (scenario.name === "managed Git worktree") {
// Capture before the color assertion so both red and green runs have evidence.
const path = testInfo.outputPath("workspace-accent.png")
await view.composer.screenshot({ path })
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
}
await expectBackground(view.send, scenario.accent ? "accent" : "contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectBackground(message, scenario.accent ? "accent" : "layer-02", "background-color")
})
}
test("inventory updates recolor the send button without navigation; disabled and stop stay neutral", async ({
page,
}) => {
const view = await openSession(page, workspace, [{ directory: root }])
await view.input.fill("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
const url = page.url()
const refreshed = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
)
view.worktrees.push({ directory: workspace, strategy: "git" })
view.events.push({
id: "evt_workspace_accent_inventory",
created: 1700000001000,
type: "worktree.updated",
data: { projectID },
})
expect((await refreshed).ok()).toBe(true)
await expectBackground(view.send, "accent")
await expect(page).toHaveURL(url)
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await view.input.fill("")
await expect(view.send).toBeDisabled()
await expectBackground(view.send, "contrast")
view.events.push({
id: "evt_workspace_accent_running",
created: 1700000002000,
type: "session.execution.started",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID },
})
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
await expect(stop).toBeEnabled()
await expectBackground(stop, "contrast")
await view.input.fill("Send a follow-up instead of stopping.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "accent")
await expect(page).toHaveURL(url)
})
async function openSession(page: Page, directory: string, worktrees = [...inventory]) {
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
canonical: root,
worktree: root,
vcs: "git",
name: "workspace-accent",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "accent-model": { id: "accent-model", name: "Accent Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "accent-model" },
},
sessions: [
{
id: sessionID,
projectID,
directory,
title,
model: { id: "accent-model", providerID: "opencode" },
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({
items: [
{
id: "msg_workspace_accent",
type: "user",
text: "Check this fixture workspace.",
time: { created: 1700000000000 },
},
],
}),
events: () => events.splice(0),
})
// Keep authoritative inventory independent of the raw project's empty sandboxes.
await page.route(`**/api/worktree/${projectID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
})
await page.addInitScript(() => {
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", "light")
})
const loaded = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
expect((await loaded).ok()).toBe(true)
await expectSessionReady(page, { server, sessionID, title })
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "light")
const composer = page.locator('[data-component="composer"]')
await expectAppVisible(composer)
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
await expect(input).toBeEditable()
await expect(composer.locator('[data-action="composer-model"]')).toHaveText("Accent Model")
return { composer, input, send: composer.getByRole("button", { name: "Send", exact: true }), events, worktrees }
}
async function expectBackground(element: Locator, token: string, property = "background-image") {
const color = await element.evaluate((element, token) => {
// Resolve semantic colors through the browser, without reproducing the button's gradient.
const probe = document.createElement("span")
probe.hidden = true
probe.style.backgroundColor = `var(--v2-background-bg-${token})`
element.append(probe)
const color = getComputedStyle(probe).backgroundColor
probe.remove()
return color
}, token)
await expect(element).toHaveCSS(property, new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
}
+351 -97
View File
@@ -1,60 +1,124 @@
import { expect, test } from "@playwright/test"
import { readFile } from "node:fs/promises"
import { createServer } from "node:http"
import { expect, test, type Page } from "@playwright/test"
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
import { createServer, type ServerResponse } from "node:http"
import { once } from "node:events"
import { createHash } from "node:crypto"
import { join, extname, relative, sep } from "node:path"
import { tmpdir } from "node:os"
import { fileURLToPath } from "node:url"
import { build } from "vite"
import { serviceWorker } from "../../vite.pwa"
const legacy = `
self.addEventListener("install", event => event.waitUntil(
caches.open("workbox-precache-v2-" + self.registration.scope).then(cache =>
cache.addAll(["/index.html", "/assets/app-old.js", "/assets/lazy-old.js"])
)
))
self.addEventListener("fetch", event => {
if (event.request.mode === "navigate") {
event.respondWith(caches.match("/index.html"))
return
}
event.respondWith(caches.match(event.request).then(response => response || fetch(event.request)))
})
`
type Site = {
url: string
deploy: (fault?: "failed" | "html" | "corrupt" | "mixed-html" | "blocked") => void
legacy: () => void
requests: string[]
release: () => void
}
const fixture = test.extend<{ site: { url: string; upgrade: () => void; repair: () => void } }>({
site: async ({}, use) => {
const worker = await readFile(new URL("../../dist/sw.js", import.meta.url), "utf8")
const state = { version: "old", repaired: false }
const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<string, Buffer>> }>({
builds: [
async ({}, use) => {
const directory = await mkdtemp(join(tmpdir(), "opencode-precache-"))
const builds: Record<string, Record<string, Buffer>> = {}
try {
for (const version of ["old", "new"]) {
const root = join(directory, version)
const outDir = join(root, "dist")
await mkdir(join(root, "public", "nested"), { recursive: true })
await Promise.all(
Object.entries({
"index.html": `<html><head></head><body><h1>Loading</h1><label>Draft<textarea></textarea></label><button>Load lazy</button><output></output><script type="module" src="/main.js"></script></body></html>`,
"main.js": `document.querySelector("h1").textContent = "${version}";
document.querySelector("button").onclick = async () => {
document.querySelector("output").textContent = await (await import("./lazy.js")).load()
};`,
"lazy.js": `export async function load() { return (await import("./nested.js")).value }`,
"nested.js": `export const value = "${version} nested lazy loaded"`,
"public/nested/data.json": JSON.stringify({ version }),
"public/nested/font.woff2": `font-${version}`,
"public/nested/module.wasm": Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]),
"public/large.bin": Buffer.alloc(2 * 1024 * 1024 + 1, version === "old" ? 1 : 2),
"public/_headers": "/*\n Cache-Control: no-cache",
"public/_redirects": "/* /index.html 200",
}).map(([path, contents]) => writeFile(join(root, path), contents)),
)
await build({
configFile: false,
root,
logLevel: "silent",
build: { outDir, assetsDir: "_assets", sourcemap: true },
plugins: serviceWorker(outDir),
})
builds[version] = Object.fromEntries(
await Promise.all(
(await readdir(outDir, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map(async (entry) => {
const path = join(entry.parentPath, entry.name)
return ["/" + relative(outDir, path).split(sep).join("/"), await readFile(path)]
}),
),
)
}
await use(builds)
} finally {
await rm(directory, { recursive: true, force: true })
}
},
{ scope: "worker" },
],
site: async ({ builds }, use) => {
const state = { version: "old", fault: "", legacy: false }
const requests: string[] = []
const blocked: ServerResponse[] = []
const release = () => blocked.splice(0).forEach((response) => response.end(builds.new["/large.bin"]))
const server = createServer((request, response) => {
const pathname = new URL(request.url ?? "/", "http://localhost").pathname
const prefix = state.version === "old" ? "/assets" : "/_assets"
const path = new URL(request.url ?? "/", "http://localhost").pathname
requests.push(path)
response.setHeader("cache-control", "no-store")
if (pathname === "/sw.js") {
if (path === "/observer.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
if (path === "/api/health")
return void response.writeHead(200, { "content-type": "application/json" }).end('{"healthy":true}')
if (path === "/sw.js" && state.legacy && state.version === "old") {
// Model the shipped worker's shared precache name and cache-first navigation behavior.
const urls = Object.keys(builds.old).filter(
(path) => path === "/index.html" || (path.startsWith("/_assets/") && path.endsWith(".js")),
)
response.setHeader("content-type", "text/javascript")
response.end(state.version === "old" ? legacy : worker)
return
}
if (pathname === `${prefix}/app-${state.version}.js`) {
response.setHeader("content-type", "text/javascript")
response.end(`import "${prefix}/startup-${state.version}.js"`)
return
}
if (pathname === `${prefix}/startup-${state.version}.js`) {
response.setHeader("content-type", "text/javascript")
response.end(`
document.getElementById("root").innerHTML = '<h1>${state.version}</h1><label>Draft<input></label><button>Load older chunk</button><output></output>'
document.querySelector("button").onclick = () => import("/assets/lazy-old.js")
return void response.end(`
self.addEventListener("install", event => event.waitUntil(
caches.open("workbox-precache-v2-" + self.registration.scope).then(cache => cache.addAll(${JSON.stringify(urls)}))
));
self.addEventListener("fetch", event => event.respondWith(
caches.match(event.request.mode === "navigate" ? "/index.html" : event.request)
.then(response => response || fetch(event.request))
));
`)
return
}
if (
(pathname === "/assets/lazy-old.js" && state.version === "old") ||
(pathname === "/_assets/retry.js" && state.repaired)
) {
response.setHeader("content-type", "text/javascript")
response.end('document.querySelector("output").textContent = "Older chunk loaded"')
return
if (path === "/index.html" && state.fault === "mixed-html")
return void response.writeHead(200, { "content-type": "text/html" }).end(builds.old["/index.html"])
if (path === "/large.bin" && state.fault && state.fault !== "mixed-html") {
if (state.fault === "blocked") return void blocked.push(response)
if (state.fault === "failed") return void response.writeHead(503).end("Unavailable")
if (state.fault === "html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<html>Wrong fallback</html>")
return void response.end("Incorrect bytes with a successful status")
}
// Deliberately retain the old server's fallback so the worker must reject HTML asset responses itself.
const file = builds[state.version][path]
const types: Record<string, string> = {
".js": "text/javascript",
".html": "text/html",
".json": "application/json",
".wasm": "application/wasm",
}
response.setHeader("content-type", types[extname(path)] ?? "application/octet-stream")
if (file) return void response.end(file)
if (extname(path)) return void response.writeHead(404).end("Not found")
response.setHeader("content-type", "text/html")
response.end(`<div id="root"></div><script type="module" src="${prefix}/app-${state.version}.js"></script>`)
response.end(builds[state.version]["/index.html"])
})
server.listen(0, "127.0.0.1")
await once(server, "listening")
@@ -63,75 +127,265 @@ const fixture = test.extend<{ site: { url: string; upgrade: () => void; repair:
try {
await use({
url: `http://127.0.0.1:${address.port}`,
upgrade: () => (state.version = "new"),
repair: () => (state.repaired = true),
deploy: (fault = undefined) => {
state.version = "new"
state.fault = fault ?? ""
},
legacy: () => {
state.legacy = true
},
requests,
release,
})
} finally {
release()
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
},
})
fixture("updates a legacy worker without reloading drafts or deleting old chunks", async ({ page, site }) => {
await page.goto(site.url)
async function install(page: Page, url: string) {
await page.goto(url)
await expect(page.getByRole("heading")).toHaveText("old")
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
await page.goto(site.url)
await page.reload()
await expect.poll(() => page.evaluate(() => navigator.serviceWorker.controller?.state)).toBe("activated")
await expect(page.getByRole("heading")).toHaveText("old")
await page.getByLabel("Draft").fill("Keep this unsent prompt")
}
site.upgrade()
await page.evaluate(async () => {
const cache = await caches.open("opencode-assets")
await cache.put(
"/_assets/startup-new.js",
new Response("<html>stale fallback</html>", {
headers: { "content-type": "text/html" },
}),
)
const changed = new Promise<void>((resolve) =>
navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true }),
)
async function update(page: Page) {
return page.evaluateHandle(async () => {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) throw new Error("Missing legacy worker")
await registration.update()
await changed
})
await expect(page.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
await page.getByRole("button", { name: "Load older chunk" }).click()
await expect(page.getByRole("status")).toHaveText("Older chunk loaded")
await page.goto(`${site.url}/workspace/example`)
await expect(page.getByRole("heading")).toHaveText("new")
await expect
.poll(() =>
page.evaluate(async () =>
(await (await caches.open("opencode-assets")).match("/_assets/startup-new.js"))?.headers.get("content-type"),
if (!registration) throw new Error("Missing installed worker")
const found = new Promise<ServiceWorker>((resolve) =>
registration.addEventListener(
"updatefound",
() => {
if (!registration.installing) throw new Error("Missing installing worker")
resolve(registration.installing)
},
{ once: true },
),
)
.toBe("text/javascript")
await registration.update()
return found
})
}
async function waiting(page: Page) {
await expect
.poll(() => page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.waiting?.state))
.toBe("installed")
}
fixture(
"opens an uncached route offline and executes never-used nested lazy chunks",
async ({ page, context, site }) => {
await install(page, site.url)
await expect(page.getByRole("status")).toBeEmpty()
await context.setOffline(true)
await page.goto(`${site.url}/workspace/never-visited`)
await expect(page.getByRole("heading")).toHaveText("old")
await page.getByRole("button", { name: "Load lazy" }).click()
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
},
)
fixture(
"precaches public files of every type and size, excluding deployment metadata and source maps",
async ({ page, site, builds, context }) => {
await install(page, site.url)
const files = ["/nested/data.json", "/nested/font.woff2", "/nested/module.wasm", "/large.bin"]
await context.setOffline(true)
for (const path of files) {
const digest = await page.evaluate(
async (path) =>
Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await (await fetch(path)).arrayBuffer()))),
path,
)
expect(Buffer.from(digest)).toEqual(createHash("sha256").update(builds.old[path]).digest())
}
expect(site.requests).not.toContain("/_headers")
expect(site.requests).not.toContain("/_redirects")
expect(site.requests.filter((path) => path.endsWith(".map"))).toEqual([])
},
)
fixture(
"keeps drafts and removed old lazy chunks until every controlled tab closes",
async ({ page, context, site, builds }) => {
await install(page, site.url)
const second = await context.newPage()
await second.goto(site.url)
await expect(second.getByRole("heading")).toHaveText("old")
await second.getByLabel("Draft").fill("Keep this unsent prompt")
site.deploy()
const created = context.waitForEvent("serviceworker")
const worker = await update(page)
const replacement = await created
await waiting(page)
expect(await worker.evaluate((worker) => worker.state)).toBe("installed")
await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
await page.close()
await waiting(second)
await expect(second.getByRole("heading")).toHaveText("old")
await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
const removed = Object.keys(builds.old).find((path) => path.includes("/nested-") && path.endsWith(".js"))
expect(removed).toBeDefined()
expect((await second.request.get(`${site.url}${removed}`)).status()).toBe(404)
await second.getByRole("button", { name: "Load lazy" }).click()
await expect(second.getByRole("status")).toHaveText("old nested lazy loaded")
await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
await second.close()
await expect
.poll(() =>
replacement.evaluate(() => {
const registration = (self as unknown as { registration: ServiceWorkerRegistration }).registration
return { waiting: !!registration.waiting, active: registration.active?.state }
}),
)
.toEqual({ waiting: false, active: "activated" })
await context.setOffline(true)
const observer = await context.newPage()
await observer.goto(`${site.url}/workspace/reopened`)
await expect(observer.getByRole("heading")).toHaveText("new")
await observer.getByRole("button", { name: "Load lazy" }).click()
await expect(observer.getByRole("status")).toHaveText("new nested lazy loaded")
},
)
for (const fault of ["failed", "html", "corrupt", "mixed-html"] as const) {
fixture(`retains the old complete build when a precache download is ${fault}`, async ({ page, context, site }) => {
await install(page, site.url)
await page.getByLabel("Draft").fill("Still editing")
site.deploy(fault)
const worker = await update(page)
await expect.poll(() => worker.evaluate((worker) => worker.state)).toBe("redundant")
expect(await page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.waiting)).toBeNull()
await expect(page.getByLabel("Draft")).toHaveValue("Still editing")
await context.setOffline(true)
await page.goto(`${site.url}/workspace/after-failure`)
await expect(page.getByRole("heading")).toHaveText("old")
await page.getByRole("button", { name: "Load lazy" }).click()
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
})
}
fixture("does not expose new HTML while a precache download is blocked", async ({ page, context, site }) => {
await install(page, site.url)
site.requests.length = 0
site.deploy("blocked")
const worker = await update(page)
await expect.poll(() => site.requests.includes("/large.bin")).toBe(true)
expect(await worker.evaluate((worker) => worker.state)).toBe("installing")
const second = await context.newPage()
await second.goto(`${site.url}/workspace/during-install`)
await expect(second.getByRole("heading")).toHaveText("old")
site.release()
await waiting(page)
await second.reload()
await expect(second.getByRole("heading")).toHaveText("old")
})
fixture("does not cache HTML responses under asset URLs", async ({ page, site }) => {
site.upgrade()
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("new")
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
fixture("upgrades the legacy shared precache only after old tabs close", async ({ page, context, site, builds }) => {
site.legacy()
const observer = await context.newPage()
await observer.goto(`${site.url}/observer.html`)
await install(page, site.url)
await page.getByLabel("Draft").fill("Legacy unsent prompt")
// A stale runtime-cache HTML response must not contaminate the new generated precache.
const entry = Object.keys(builds.new).find((path) => path.includes("/index-") && path.endsWith(".js"))
expect(entry).toBeDefined()
await page.evaluate(async (entry) => {
await (
await caches.open("opencode-assets")
).put(entry!, new Response("<html>stale fallback</html>", { headers: { "content-type": "text/html" } }))
}, entry)
site.deploy()
await update(page)
await waiting(page)
await expect(page.getByLabel("Draft")).toHaveValue("Legacy unsent prompt")
await page.getByRole("button", { name: "Load lazy" }).click()
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
await page.close()
await expect
.poll(() => observer.evaluate(async () => !!(await navigator.serviceWorker.getRegistration())?.waiting))
.toBe(false)
await context.setOffline(true)
await observer.goto(`${site.url}/workspace/legacy-upgraded`)
await expect(observer.getByRole("heading")).toHaveText("new")
await observer.getByRole("button", { name: "Load lazy" }).click()
await expect(observer.getByRole("status")).toHaveText("new nested lazy loaded")
})
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
await install(page, site.url)
const api = await page.goto(`${site.url}/api/health`)
expect(await api?.json()).toEqual({ healthy: true })
expect(api?.fromServiceWorker()).toBe(false)
const asset = await page.goto(`${site.url}/_assets/missing.js`)
expect(asset?.status()).toBe(404)
expect(await asset?.text()).toBe("Not found")
})
test("the production build precaches every deployable file", async ({ page, context }) => {
const directory = new URL("../../dist/", import.meta.url)
const files = (await readdir(directory, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => "/" + relative(fileURLToPath(directory), join(entry.parentPath, entry.name)).split(sep).join("/"))
.filter((path) => !path.endsWith(".map") && !["/_headers", "/_redirects", "/sw.js"].includes(path))
expect(files.length).toBeGreaterThan(1)
const server = createServer(async (request, response) => {
const path = new URL(request.url ?? "/", "http://localhost").pathname
response.setHeader("cache-control", "no-store")
if (path === "/probe.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Precache probe</title>")
const bytes = await readFile(new URL(`.${path}`, directory)).catch(() => undefined)
if (!bytes) return void response.writeHead(404).end("Not found")
if (path.endsWith(".js")) response.setHeader("content-type", "text/javascript")
if (path.endsWith(".html")) {
response.setHeader("content-type", "text/html")
// Inspect the real cached HTML without executing the app or contacting a backend.
response.setHeader("content-security-policy", "default-src 'none'")
}
response.end(bytes)
})
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("new")
expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe(
"text/html",
)
site.repair()
expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe(
"text/javascript",
)
server.listen(0, "127.0.0.1")
await once(server, "listening")
const address = server.address()
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
const url = `http://127.0.0.1:${address.port}`
try {
await page.goto(`${url}/probe.html`)
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
const cached = await page.evaluate(async () =>
(
await Promise.all(
(await caches.keys()).map(async (name) =>
(await (await caches.open(name)).keys()).map((request) => new URL(request.url).pathname),
),
)
)
.flat()
.sort(),
)
expect(cached).toEqual(files.sort())
await context.setOffline(true)
const response = await page.goto(`${url}/workspace/offline-probe`)
expect(response?.fromServiceWorker()).toBe(true)
expect(await response?.text()).toBe(await readFile(new URL("index.html", directory), "utf8"))
} finally {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
})
@@ -1,9 +1,14 @@
import { defineConfig } from "@playwright/test"
// Tiny fixture builds do not need a Rolldown thread for every host CPU.
process.env.RAYON_NUM_THREADS ??= "2"
export default defineConfig({
testDir: ".",
testMatch: "*.spec.ts",
outputDir: "../test-results/service-worker",
timeout: 30_000,
timeout: 60_000,
workers: 1,
expect: { timeout: 15_000 },
use: { browserName: "chromium" },
})
@@ -384,10 +384,18 @@ export function createComposerEditor(input: {
void attachments.handlePaste(event)
return
}
const text = clipboard?.getData("text/plain")
const text = clipboard?.getData("text/plain").replace(/\r\n?/g, "\n")
if (!text) return
event.preventDefault()
if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return
// insertText emits input events per line, repeatedly parsing and saving the draft.
// Escaped HTML inserts multiline text once and preserves native selection and undo.
const multiline = text.includes("\n")
const value = multiline ? text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;") : text
if (
typeof document.execCommand === "function" &&
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
)
return
const target = event.currentTarget
const selection = window.getSelection()
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
@@ -25,7 +25,7 @@ export const DialogSelectMcp: Component = () => {
.sort((a, b) => a.name.localeCompare(b.name)),
)
const toggle = useMcpToggle()
const toggle = useMcpToggle(() => sdk().directory)
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length)
@@ -40,6 +40,9 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
data.location.mcp.server.invalidate(ref)
data.location.mcp.resource.invalidate(ref)
await Promise.all([data.location.mcp.server.sync(ref), data.location.mcp.resource.sync(ref), onSuccess?.()])
// A successful HTTP response can still leave the MCP connection in a failed state.
const status = data.location.mcp.server.list(ref)?.find((item) => item.name === name)?.status
if (status?.status === "failed") throw new Error(`${name}: ${status.error}`)
},
onError: (error) =>
showToast({
+5
View File
@@ -983,6 +983,11 @@ export const dict = {
"settings.general.row.followUpBehavior.steer": "Steer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.reasoningMode.title": "Model reasoning",
"settings.general.row.reasoningMode.description": "Choose how model reasoning is displayed in the timeline",
"settings.general.row.reasoningMode.hidden": "Hidden",
"settings.general.row.reasoningMode.compact": "Compact",
"settings.general.row.reasoningMode.full": "Full",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
"settings.general.row.shellToolPartsExpanded.description":
"Show shell tool parts expanded by default in the timeline",
+12 -2
View File
@@ -5,7 +5,8 @@ import { useFile } from "@/workspaces/files/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useData } from "@/runtime/server/current"
import { same } from "@/runtime/persistence/equality"
import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { containsDirectory, isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { projectForSession } from "@/shell/layout/helpers"
import { createSessionTabs } from "./helpers"
import {
normalizeSessionTab,
@@ -90,7 +91,16 @@ export function useSessionModel() {
isDesktop,
workspace: {
directory: createMemo(() => info()?.location.directory ?? location().directory),
current: createMemo(() => isWorkspaceDirectory(project(), info()?.location.directory ?? location().directory)),
current: createMemo(() => {
const current = info()
const directory = current?.location.directory ?? location().directory
// Global sync enriches projects with discovered worktrees; raw project metadata does not.
const projects = server.ctx.sync.data.project
const value = current
? projectForSession(current, projects)
: projects.find((item) => isProjectDirectory(item, directory))
return isWorkspaceDirectory(value, directory)
}),
},
identity: {
params: layout.params,
@@ -49,7 +49,7 @@ describe("visibleTimelineMessages", () => {
time: { created: 5, completed: 6 },
} satisfies SessionMessageInfo
test("keeps work and thinking above an undelivered steer", () => {
test("keeps work above an undelivered steer without adding a thinking row", () => {
const source = [...messages.slice(0, 3), work]
const visible = visibleTimelineMessages(source, [steer])
expect(visible.map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_5", "msg_3"])
@@ -60,7 +60,7 @@ describe("visibleTimelineMessages", () => {
const projection = createTimelineProjection({
sessionMessages: () => visible,
status: () => ({ type: "busy" }),
showReasoningSummaries: () => false,
reasoningMode: () => "compact",
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
pendingUserMessageIDs: () => new Set([steer.id]),
@@ -69,7 +69,6 @@ describe("visibleTimelineMessages", () => {
expect(projection.rows().map((row) => [row._tag, row.userMessageID])).toEqual([
["UserMessage", "msg_1"],
["AssistantPart", "msg_1"],
["Thinking", "msg_1"],
["TurnGap", "msg_3"],
["UserMessage", "msg_3"],
])
@@ -104,7 +104,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
const projection = createTimelineProjection({
sessionMessages: projectedMessages,
status: input.session.data.status,
showReasoningSummaries: settings.general.showReasoningSummaries,
reasoningMode: settings.general.reasoningMode,
shellToolDefaultOpen: settings.general.shellToolPartsExpanded,
editToolDefaultOpen: settings.general.editToolPartsExpanded,
pendingUserMessageIDs,
@@ -235,7 +235,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
childTitle,
showHeader,
projection,
showReasoningSummaries: settings.general.showReasoningSummaries,
reasoningMode: settings.general.reasoningMode,
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
editToolPartsExpanded: settings.general.editToolPartsExpanded,
},
@@ -458,7 +458,7 @@ function MessageTimelineView(
}
},
actions: props.actions,
showReasoningSummaries: props.data.showReasoningSummaries,
reasoningMode: props.data.reasoningMode,
shellToolDefaultOpen: props.data.shellToolPartsExpanded,
editToolDefaultOpen: props.data.editToolPartsExpanded,
disclosure: virtualized.disclosure,
@@ -480,6 +480,7 @@ function MessageTimelineView(
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
return (
<VirtualizedTimeline
workspaceSession={workspaceSession}
bottomSpacer={
<Show when={backgroundHintPresence.present()}>
<div
@@ -1,5 +1,10 @@
import type { ModelRef, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import { reuseTimelineRows, Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import {
reuseTimelineRows,
Timeline,
TimelineRow,
type ReasoningMode,
} from "@opencode-ai/session-ui/timeline/projection"
import { createMemo, type Accessor } from "solid-js"
export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection"
@@ -7,7 +12,7 @@ export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection"
export function createTimelineProjection(input: {
sessionMessages: Accessor<SessionMessageInfo[]>
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
reasoningMode: Accessor<ReasoningMode>
shellToolDefaultOpen: Accessor<boolean>
editToolDefaultOpen: Accessor<boolean>
pendingUserMessageIDs: Accessor<ReadonlySet<string>>
@@ -80,7 +85,7 @@ export function createTimelineProjection(input: {
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
input.showReasoningSummaries(),
input.reasoningMode() !== "hidden",
input.status(),
input.pendingUserMessageIDs(),
input.shellToolDefaultOpen(),
@@ -62,6 +62,7 @@ type Input = {
type ViewProps = {
header: JSX.Element
bottomSpacer?: JSX.Element
workspaceSession: Accessor<boolean>
deferred: (row: TimelineRow.TimelineRow) => boolean
renderRow: (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => JSX.Element
}
@@ -400,7 +401,7 @@ export function createTimelineVirtualizer(input: Input) {
}
return (
<div class="relative w-full h-full min-w-0">
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
+30 -11
View File
@@ -4,6 +4,7 @@ import { Button } from "@opencode-ai/ui/button"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { TextInput } from "@opencode-ai/ui/text-input"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useUpdaterAction } from "@/shell/updates/action"
@@ -183,6 +184,34 @@ const FollowUpBehaviorSetting: Component = () => {
)
}
const ReasoningModeSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: ReasoningMode; label: string }[] => [
{ value: "hidden", label: language.t("settings.general.row.reasoningMode.hidden") },
{ value: "compact", label: language.t("settings.general.row.reasoningMode.compact") },
{ value: "full", label: language.t("settings.general.row.reasoningMode.full") },
])
return (
<SettingsRow
title={language.t("settings.general.row.reasoningMode.title")}
description={language.t("settings.general.row.reasoningMode.description")}
>
<Select
data-action="settings-reasoning-mode"
options={options()}
current={options().find((option) => option.value === settings.general.reasoningMode())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.general.setReasoningMode(option.value)}
/>
</SettingsRow>
)
}
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
@@ -331,17 +360,7 @@ export const SettingsGeneral: Component<{
<TerminalPlacementSetting />
<FollowUpBehaviorSetting />
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-feed-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRow>
<ReasoningModeSetting />
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
+29 -1
View File
@@ -1,5 +1,33 @@
import { describe, expect, test } from "bun:test"
import { monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
import { migrateSettings, monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
describe("settings reasoning mode migration", () => {
test.each([
[true, "full"],
[false, "compact"],
])("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
const value = { general: { showReasoningSummaries, showTerminal: true }, appearance: { fontSize: 16 } }
expect(migrateSettings(value)).toEqual({
...value,
general: { ...value.general, reasoningMode },
})
expect(value.general).not.toHaveProperty("reasoningMode")
})
test.each(["hidden", "compact", "full"])(
"preserves an explicit %s mode over either legacy value",
(reasoningMode) => {
;[true, false].forEach((showReasoningSummaries) => {
const value = { general: { reasoningMode, showReasoningSummaries } }
expect(migrateSettings(value)).toBe(value)
})
},
)
test.each([undefined, null, {}, { general: {} }])("leaves missing legacy settings to the defaults: %j", (value) => {
expect(migrateSettings(value)).toBe(value)
})
})
describe("settings font families", () => {
test("defaults normal text to Inter", () => {
+22 -9
View File
@@ -1,6 +1,7 @@
import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
import { persisted } from "@/runtime/persistence/storage"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
@@ -35,7 +36,7 @@ export interface Settings {
showStatus: boolean
showProjectIcon: boolean
showTerminal: boolean
showReasoningSummaries: boolean
reasoningMode: ReasoningMode
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
showCustomAgents: boolean
@@ -124,7 +125,7 @@ const defaultSettings: Settings = {
showStatus: false,
showProjectIcon: false,
showTerminal: false,
showReasoningSummaries: false,
reasoningMode: "compact",
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showCustomAgents: false,
@@ -166,11 +167,26 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
return createMemo(() => read() ?? fallback)
}
export function migrateSettings(value: unknown) {
if (!value || typeof value !== "object" || !("general" in value)) return value
const general = value.general
if (!general || typeof general !== "object") return value
if ("reasoningMode" in general && general.reasoningMode !== undefined) return value
if (!("showReasoningSummaries" in general) || typeof general.showReasoningSummaries !== "boolean") return value
return {
...value,
general: { ...general, reasoningMode: general.showReasoningSummaries ? "full" : "compact" },
}
}
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
gate: false,
init: () => {
const [store, setStore, , ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const [store, setStore, , ready] = persisted(
{ key: "settings.v3", migrate: migrateSettings },
createStore<Settings>(defaultSettings),
)
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -223,12 +239,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowTerminal(value: boolean) {
setStore("general", "showTerminal", value)
},
showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries,
defaultSettings.general.showReasoningSummaries,
),
setShowReasoningSummaries(value: boolean) {
setStore("general", "showReasoningSummaries", value)
reasoningMode: withFallback(() => store.general?.reasoningMode, defaultSettings.general.reasoningMode),
setReasoningMode(value: ReasoningMode) {
setStore("general", "reasoningMode", value)
},
shellToolPartsExpanded: withFallback(
() => store.general?.shellToolPartsExpanded,
+16
View File
@@ -54,10 +54,26 @@
.settings-nav {
display: flex;
width: 200px;
flex-shrink: 0;
flex-direction: column;
gap: 16px;
}
.settings-nav-footer {
display: flex;
flex-shrink: 0;
flex-direction: column;
gap: 8px;
margin-top: auto;
padding-block: 20px 4px;
padding-inline-start: 4px;
font-size: 11px;
font-weight: 440;
line-height: var(--line-height-tight);
color: var(--v2-text-text-faint);
user-select: none;
}
.settings-back {
display: flex;
height: 28px;
+8
View File
@@ -2,6 +2,7 @@ import { Component, createEffect, createMemo, createSignal, onCleanup, onMount,
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsGeneral } from "./general/general"
import { SettingsAppearance } from "./appearance/appearance"
import { SettingsKeybinds } from "./keybinds/keybinds"
@@ -26,6 +27,7 @@ export const SettingsScreen: Component<{
defaultValue?: string
}> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const command = useCommand()
const surface = useSettingsSurface()
@@ -161,6 +163,12 @@ export const SettingsScreen: Component<{
</div>
</div>
</div>
<div class="settings-nav-footer">
<span>{language.t("app.name.desktop")}</span>
<span>
<bdi dir="ltr">v{platform.version}</bdi>
</span>
</div>
</Tabs.List>
<Tabs.Content value="general" class="settings-panel">
@@ -55,6 +55,7 @@ export function SessionUIProvider(
data={sessionUIData()}
directory={directory()}
sessionID={params.id}
shellRunning={(id) => !!data.shell.get(id)}
shellOutput={(input) => serverSDK.api.shell.output(input)}
onNavigateToSession={navigateToSession}
onSessionHref={href}
+2 -1
View File
@@ -79,7 +79,8 @@ export default function Layout(props: ParentProps) {
/>
</aside>
</Show>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
{/* Size containment collapses percentage-height descendants in WebKit. */}
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content">
<div
class="flex size-full min-h-0 min-w-0 flex-col"
hidden={settings.store.open}
+3 -57
View File
@@ -1,7 +1,8 @@
import { sentryVitePlugin } from "@sentry/vite-plugin"
import { fileURLToPath } from "node:url"
import { defineConfig } from "vite"
import { VitePWA } from "vite-plugin-pwa"
import desktopPlugin from "./vite.js"
import { serviceWorker } from "./vite.pwa"
const sentry =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
@@ -21,62 +22,7 @@ const sentry =
: false
export default defineConfig({
plugins: [
desktopPlugin,
VitePWA({
strategies: "generateSW",
registerType: "prompt",
injectRegister: false,
manifest: false,
workbox: {
clientsClaim: false,
skipWaiting: true,
inlineWorkboxRuntime: true,
// Always fetch the current HTML. Precaching a partial build can strand it without its chunks after an upgrade.
navigateFallback: null,
globPatterns: [],
runtimeCaching: [
{
urlPattern: ({ url }) =>
url.origin === self.location.origin &&
(url.pathname.startsWith("/_assets/") || url.pathname.startsWith("/assets/")),
handler: "CacheFirst",
options: {
cacheName: "opencode-assets",
plugins: [
{
cachedResponseWillBeUsed: async ({ request, cachedResponse }) => {
if (
cachedResponse?.status === 200 &&
!/^(text\/html|application\/xhtml\+xml)\b/i.test(cachedResponse.headers.get("content-type") ?? "")
)
return cachedResponse
// Keep old tabs' precached chunks usable without retaining their stale HTML navigation handler.
const response = await caches.match(request, {
cacheName: `workbox-precache-v2-${self.location.origin}/`,
})
return response?.status === 200 &&
!/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get("content-type") ?? "")
? response
: null
},
cacheWillUpdate: async ({ response }) =>
response.status === 200 &&
!/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get("content-type") ?? "")
? response
: null,
},
],
expiration: {
maxEntries: 1000,
},
},
},
],
},
}),
sentry,
] as any,
plugins: [desktopPlugin, serviceWorker(fileURLToPath(new URL("./dist", import.meta.url))), sentry] as any,
server: {
host: "0.0.0.0",
allowedHosts: true,
+41
View File
@@ -0,0 +1,41 @@
import { createHash } from "node:crypto"
import { readFile } from "node:fs/promises"
import { resolve } from "node:path"
import { VitePWA } from "vite-plugin-pwa"
export function serviceWorker(directory: string) {
return VitePWA({
strategies: "generateSW",
registerType: "prompt",
injectRegister: false,
manifest: false,
workbox: {
globDirectory: directory,
clientsClaim: false,
// Keep each open tab on its complete build until all old clients close.
skipWaiting: false,
inlineWorkboxRuntime: true,
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
// Include lazy chunks and non-JS dependencies, not just the startup bundle.
globPatterns: ["**/*"],
globIgnores: ["**/*.map", "_headers", "_redirects"],
maximumFileSizeToCacheInBytes: Number.MAX_SAFE_INTEGER,
manifestTransforms: [
async (entries) => ({
manifest: await Promise.all(
entries.map(async (entry) => ({
...entry,
// A revision labels a cache entry; integrity rejects mixed deployments
// and HTML fallback responses instead of installing a broken build.
integrity: `sha256-${createHash("sha256")
.update(await readFile(resolve(directory, entry.url)))
.digest("base64")}`,
})),
),
warnings: [],
}),
],
},
})
}
+3 -8
View File
@@ -358,15 +358,10 @@ export const layer = Layer.effect(
? { webSocket: transport.bind(session.id) }
: {}),
}
const executeTool: Prepared["executeTool"] = (input) => {
const tool = hooked.get(input.call.name)
// A registered tool absent from the hooked set was removed or renamed by a hook.
if (!tool && registry.has(input.call.name))
return new Tool.Error({ message: `Tool is not available for this request: ${input.call.name}` })
return tools
.execute(tool ? { ...input, call: { ...input.call, name: tool.name } } : input)
const executeTool: Prepared["executeTool"] = (input) =>
tools
.execute({ ...input, definitions: hooked })
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
}
return {
request,
options,
+32 -26
View File
@@ -49,6 +49,8 @@ export interface Snapshot {
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
/** Surviving request definitions, keyed by the names advertised after session context hooks. */
readonly definitions?: ReadonlyMap<string, ToolDefinition>
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error>
}
@@ -90,23 +92,23 @@ const layer = Layer.effect(
]
})
const beforeExecute = (name: string, input: unknown, context: Tool.Context) =>
hooks.trigger("tool", "execute.before", {
tool: name,
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
id: context.id,
input,
})
const executeTool = Effect.fn("Tool.execute")(function* (
tool: Tool.Info,
name: string,
input: unknown,
context: Tool.Context,
) {
const beforeEvent: PluginHooks.Domains["tool"]["execute.before"] = {
tool: name,
inputSchema: definition(tool).inputSchema,
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
id: context.id,
input,
}
yield* hooks.trigger("tool", "execute.before", beforeEvent)
const execution = yield* execute(tool, beforeEvent.input, context).pipe(
const execution = yield* execute(tool, input, context).pipe(
Effect.map((value) => ({ value })),
Effect.catchTag("Tool.Error", (failure) => Effect.succeed({ failure })),
)
@@ -116,7 +118,7 @@ const layer = Layer.effect(
agent: context.agent,
messageID: context.messageID,
id: context.id,
input: beforeEvent.input,
input,
}
if ("failure" in execution) {
const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = {
@@ -212,7 +214,11 @@ const layer = Layer.effect(
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
? CodeModeTool.create(codemode, (name, tool, input, context) =>
beforeExecute(name, input, context).pipe(
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
),
)
: undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
return {
@@ -223,13 +229,7 @@ const layer = Layer.effect(
.map(([, tool]) => definition(tool)),
...(codemodeTool ? [definition(codemodeTool)] : []),
],
execute: (input: {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => {
execute: Effect.fnUntraced(function* (input: Parameters<Snapshot["execute"]>[0]) {
const context: Tool.Context = {
sessionID: input.sessionID,
agent: input.agent,
@@ -237,12 +237,18 @@ const layer = Layer.effect(
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
return executeTool(codemodeTool, input.call.name, input.call.input, context)
const tool = direct.get(input.call.name)
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
},
const event = yield* beforeExecute(input.call.name, input.call.input, context)
const requested = input.definitions?.get(event.tool)
// Preserve session context removal and alias resolution, now after the repair hook.
if (!requested && input.definitions && (direct.has(event.tool) || codemodeTool?.name === event.tool))
return yield* new Tool.Error({ message: `Tool is not available for this request: ${event.tool}` })
const name = requested?.name ?? event.tool
if (name === "execute" && codemodeTool)
return yield* executeTool(codemodeTool, name, event.input, context)
const tool = direct.get(name)
if (tool) return yield* executeTool(tool, name, event.input, context)
return yield* new Tool.Error({ message: `Unknown tool: ${name}` })
}),
}
}),
),
+7 -10
View File
@@ -580,7 +580,7 @@ describe("Plugin", () => {
const registry = yield* Tool.Service
const executed: unknown[] = []
const seen: {
before?: { input: unknown; inputSchema: unknown }
before?: { input: unknown; tool: string }
after?: { input: unknown; status: string; content: unknown; metadata: unknown }
} = {}
@@ -605,7 +605,9 @@ describe("Plugin", () => {
yield* ctx.tool
.hook("execute.before", (event) =>
Effect.sync(() => {
seen.before = { input: event.input, inputSchema: event.inputSchema }
expect(event).not.toHaveProperty("inputSchema")
seen.before = { input: event.input, tool: event.tool }
event.tool = "echo"
event.input = { text: "before-mutated" }
}),
)
@@ -648,17 +650,12 @@ describe("Plugin", () => {
sessionID: Session.ID.make("ses_hooks"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_hooks"),
call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
call: { type: "tool-call", id: "call-hooks", name: "misspelled", input: { text: "original" } },
})
expect(seen.before).toEqual({
input: { text: "original" },
inputSchema: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
additionalProperties: false,
},
tool: "misspelled",
})
expect(executed).toEqual([{ text: "before-mutated" }])
expect(seen.after).toEqual({
@@ -712,7 +709,7 @@ describe("Plugin", () => {
sessionID: Session.ID.make("ses_hook_reject"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_hook_reject"),
call: { type: "tool-call", id: "call-hook-reject", name: "echo", input: { text: "original" } },
call: { type: "tool-call", id: "call-hook-reject", name: "missing", input: { text: "original" } },
})
.pipe(Effect.flip)
+6 -1
View File
@@ -611,6 +611,11 @@ describe("fromPromise", () => {
},
})
})
await ctx.tool.hook("execute.before", (event) => {
expect(event.tool).toBe("helllo")
expect(event).not.toHaveProperty("inputSchema")
event.tool = "hello"
})
},
})
@@ -624,7 +629,7 @@ describe("fromPromise", () => {
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool"),
progress: (update) => Effect.sync(() => progress.push(update)),
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
call: { type: "tool-call", id: "call_promise_tool", name: "helllo", input: { name: "world" } },
}),
).toMatchObject({
output: "Hello, world!",
@@ -6,6 +6,10 @@ import { Image } from "@opencode-ai/core/image"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { LanguageModel } from "@opencode-ai/ai"
import { route } from "@opencode-ai/ai/protocols/openai-chat"
import { State } from "@opencode-ai/core/state"
import { Tool } from "@opencode-ai/core/tool"
import type { Info } from "@opencode-ai/schema/tool"
@@ -38,7 +42,9 @@ const imageStore = Layer.mock(Image.Service, {
})
},
})
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [[Image.node, imageStore]])
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node, SessionModelRequest.node]), [
[Image.node, imageStore],
])
const it = testEffect(registryLayer)
const identity = {
agent: Agent.ID.make("build"),
@@ -86,6 +92,116 @@ describe("Tool", () => {
}),
)
it.effect("repairs names and inputs before lookup using the captured request tool set", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
yield* transform(service, { echo: constant("captured"), hidden: make() }, { codemode: false })
const snapshot = yield* service.snapshot()
const modelRequests = yield* SessionModelRequest.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
const echo = event.tools.echo
if (!echo) throw new Error("Expected echo definition")
event.tools.alias = echo
delete event.tools.echo
delete event.tools.hidden
}),
)
const prepared = yield* modelRequests.prepare({
scope: {
session: Schema.decodeUnknownSync(Session.Info)({
id: sessionID,
projectID: "project",
location: { directory: "/test" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}),
agentID: identity.agent,
model: SessionRunnerModel.resolved(LanguageModel.make({ id: "test", provider: "test", route }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
}),
tools: snapshot,
},
transcript: { system: [], messages: [] },
})
expect(prepared.request.tools.map((tool) => tool.name)).toEqual(["execute", "alias"])
yield* transform(service, { echo: constant("new") }, { codemode: false })
const before: string[] = []
const after: string[] = []
yield* hooks.register("tool", "execute.before", (event) =>
Effect.sync(() => {
expect(event).not.toHaveProperty("inputSchema")
before.push(event.tool)
event.tool = event.tool === "typo" ? "alias" : event.tool
event.input = { text: "corrected" }
}),
)
yield* hooks.register("tool", "execute.after", (event) =>
Effect.sync(() => {
after.push(event.tool)
expect(event.input).toEqual({ text: "corrected" })
}),
)
expect((yield* prepared.executeTool(call("typo"))).output).toEqual({ text: "captured" })
expect(before).toEqual(["typo"])
expect(after).toEqual(["echo"])
expect(yield* prepared.executeTool(call("hidden")).pipe(Effect.flip)).toMatchObject({
message: "Tool is not available for this request: hidden",
})
expect(yield* prepared.executeTool(call("echo")).pipe(Effect.flip)).toMatchObject({
message: "Tool is not available for this request: echo",
})
expect(yield* prepared.executeTool(call("missing")).pipe(Effect.flip)).toMatchObject({
message: "Unknown tool: missing",
})
expect(before).toEqual(["typo", "hidden", "echo", "missing"])
expect(after).toEqual(["echo"])
}),
)
it.effect("hooks execute and known Code Mode calls once but leaves unknown interpreter paths unchanged", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
yield* transform(service, { echo: make() })
const seen: string[] = []
yield* hooks.register("tool", "execute.before", (event) =>
Effect.sync(() => {
seen.push(event.tool)
expect(event).not.toHaveProperty("inputSchema")
if (event.tool === "run_code") event.tool = "execute"
}),
)
const snapshot = yield* service.snapshot()
const known = yield* snapshot.execute({
...call("run_code"),
call: {
type: "tool-call",
id: "known",
name: "run_code",
input: { code: 'return await tools.echo({ text: "hello" })' },
},
})
expect(known.output).toMatchObject({ output: '{\n "text": "hello"\n}' })
expect(seen).toEqual(["run_code", "echo"])
const unknown = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "unknown",
name: "execute",
input: { code: "return await tools.missing({})" },
},
})
expect(unknown.output).toMatchObject({ error: true })
expect(seen).toEqual(["run_code", "echo", "execute"])
}),
)
it.effect("replays mutations on refreshed sources and restores tools on disposal and scope cleanup", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+2 -3
View File
@@ -2,7 +2,7 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Effect, JsonSchema, Types } from "effect"
import type { Effect, Types } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolDraft {
@@ -18,8 +18,7 @@ export interface ToolDraft {
export interface ToolHooks {
readonly "execute.before": {
readonly tool: string
readonly inputSchema: JsonSchema.JsonSchema
tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
+2 -3
View File
@@ -5,7 +5,7 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema, Types } from "effect"
import type { Types } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolContext extends Omit<Tool.Context, "progress"> {
@@ -35,8 +35,7 @@ interface ToolDraft {
interface ToolHooks {
readonly "execute.before": {
readonly tool: string
readonly inputSchema: JsonSchema.JsonSchema
tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
@@ -22,7 +22,7 @@ story("merges follow-up patches into one stack with a distinct file count", asyn
await group.screenshot({ path: info.outputPath("merged.png") })
})
for (const separator of ["shell", "error"]) {
for (const separator of ["shell", "error", "reasoning"]) {
story(`does not merge patches across an intervening ${separator}`, async ({ mount }) => {
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator } })
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
@@ -32,3 +32,15 @@ for (const separator of ["shell", "error"]) {
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
})
}
story("does not retain patch files in the wrong batch when thoughts are shown", async ({ mount }) => {
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator: "reasoning" } })
await root.getByRole("button", { name: "Hide thoughts", exact: true }).click()
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
await root.getByRole("button", { name: "Show thoughts", exact: true }).click()
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
})
@@ -105,31 +105,67 @@ story("shimmers and expands a running shell command", async ({ mount }) => {
})
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("transitions thinking and hidden reasoning through busy to idle", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "hidden" } })
const reasoning = timeline.locator('[data-timeline-part-id="msg_hidden_reasoning_lifecycle:reasoning:0"]')
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
await expect(reasoning).toHaveCount(0)
await timeline.getByRole("button", { name: "Start shell" }).click()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible()
await timeline.getByRole("button", { name: "Finish session" }).click()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(reasoning).toHaveCount(0)
})
for (const open of [false, true]) {
story(
`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`,
async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "hidden" } })
const reasoning = timeline.locator('[data-timeline-part-id="msg_hidden_reasoning_lifecycle:reasoning:0"]')
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
const thought = reasoning.locator('[data-slot="collapsible-trigger"]')
await expect(thought).toHaveAttribute("aria-expanded", "false")
await thought.click()
await expect(thought).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
await expect(thought).toHaveAttribute("aria-expanded", String(open))
await timeline.getByRole("button", { name: "Start shell" }).click()
const group = timeline.locator('[data-component="collapsed-tool-group"]')
await expect(timeline.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible()
await expect(group).toHaveCount(0)
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(thought).toContainText("Thought")
await expect(thought).not.toContainText("Inspecting stability")
await expect(thought).toHaveAttribute("aria-expanded", String(open))
await timeline.getByRole("button", { name: "Finish session" }).click()
const used = group.getByRole("button", { name: "Used Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible()
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(open),
)
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
},
)
}
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("moves busy through retry and recovery to final idle content", async ({ mount }) => {
story("does not infer Thinking from busy, retry, or recovery without reasoning", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "retry" } })
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-row="UserMessage"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
await timeline.getByRole("button", { name: "Retry request" }).click()
await expect(timeline.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.getByRole("button", { name: "Recover request" }).click()
await expect(timeline.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.getByRole("button", { name: "Finish response" }).click()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-part-id="msg_retry_recovery_lifecycle:text:0"]')).toContainText(
@@ -1,66 +1,81 @@
import { expect, story } from "../../storybook/playwright/story"
const profiles = [
{ name: "summaries off no reasoning", summaries: false, reasoning: "none", tool: false, thinking: true, body: false },
{
name: "summaries off reasoning heading",
summaries: false,
reasoning: "heading",
tool: false,
thinking: true,
body: false,
heading: true,
},
{
name: "summaries off with visible tool",
summaries: false,
reasoning: "heading",
tool: true,
thinking: true,
body: false,
heading: true,
},
{ name: "summaries on no content", summaries: true, reasoning: "none", tool: false, thinking: true, body: false },
{
name: "summaries on blank reasoning",
summaries: true,
reasoning: "blank",
tool: false,
thinking: true,
body: false,
},
{
name: "summaries on visible reasoning",
summaries: true,
reasoning: "heading",
tool: false,
thinking: false,
body: true,
},
{
name: "summaries on visible tool no reasoning",
summaries: true,
reasoning: "none",
tool: true,
thinking: false,
body: false,
},
] as const
for (const profile of profiles) {
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
story(`projects busy reasoning profile ${profile.name}`, async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", {
args: { scenario: "reasoning", summaries: profile.summaries, reasoning: profile.reasoning, tool: profile.tool },
for (const mode of ["hidden", "compact", "full"] as const) {
for (const reasoning of ["none", "blank", "heading"] as const) {
story(`projects ${mode} mode with ${reasoning} active reasoning`, async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", {
args: { scenario: "reasoning", mode, reasoning },
})
await expect(timeline.locator('[data-timeline-row="UserMessage"]')).toContainText(
"Find why the Session header shifts after the first streamed response.",
)
const active = mode !== "hidden" && reasoning !== "none"
const part = timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(active ? 1 : 0)
await expect(part).toHaveCount(active ? 1 : 0)
if (!active || reasoning !== "heading") {
await expect(timeline.getByText("Inspecting stability", { exact: true })).toHaveCount(0)
return
}
const trigger = part.getByRole("button")
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", String(mode === "full"))
await expect(part.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
if (mode === "compact") {
await expect(trigger).toContainText("Inspecting stability")
await expect(body).toBeHidden()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
}
await expect(body).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(body).toBeHidden()
await expect(trigger).toContainText("Inspecting stability")
})
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
await expect(timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')).toHaveCount(
profile.body ? 1 : 0,
)
if ("heading" in profile) {
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
}
})
}
for (const following of ["tool", "text"] as const) {
story(`stops Thinking before ${following} in ${mode} mode`, async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", {
args: {
scenario: "reasoning",
mode,
reasoning: "heading",
tool: following === "tool",
text: following === "text" ? "The timeline is stable" : "",
},
})
const part = timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')
if (following === "tool") {
const group = timeline.locator('[data-component="collapsed-tool-group"]')
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(trigger).toContainText("UsedSkill")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeHidden()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-timeline-part-id="tool_reasoning_projection_skill"]')).toBeVisible()
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(mode === "hidden" ? 0 : 1)
}
if (following === "text")
await expect(timeline.getByText("The timeline is stable", { exact: true })).toBeVisible()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(part).toHaveCount(mode === "hidden" ? 0 : 1)
if (mode === "hidden") return
const thought = part.locator('[data-slot="collapsible-trigger"]')
await expect(thought.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Thought")
await expect(thought.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("7s")
await expect(thought).toHaveAttribute("aria-expanded", String(mode === "full"))
await expect(thought).not.toContainText("Inspecting stability")
await expect(part.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "false")
if (mode === "compact") await thought.click()
await expect(
part.getByText("I will inspect the timeline before changing its state.", { exact: true }),
).toBeVisible()
})
}
}
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
@@ -3,6 +3,7 @@ import { expect, story } from "../../storybook/playwright/story"
story("renders streamed reasoning without starting the app", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--streaming-reasoning-and-text")
await expect(timeline.locator('[data-component="session-timeline"]')).toBeVisible()
await timeline.locator('[data-component="reasoning-part"] [data-slot="collapsible-trigger"]').click()
await expect(timeline.getByText("Checking the current contract", { exact: true })).toBeVisible()
})
@@ -0,0 +1,48 @@
import { expect, story } from "../../storybook/playwright/story"
for (const open of [true, false]) {
story(
`preserves ${open ? "expanded" : "collapsed"} tool choices when calls join the group`,
async ({ mount }, info) => {
const root = await mount("current-session-file-changes--appending-tool-calls")
const group = root.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used Shell, Patch", exact: true }).click()
const shell = group.locator('[data-timeline-part-id="tool_shell_existing"] [data-slot="collapsible-trigger"]')
await group.locator('[data-timeline-part-id="tool_patch_existing"]').evaluate((element) => {
element.setAttribute("data-disclosure-probe", "existing")
})
const patch = group.locator('[data-disclosure-probe="existing"]')
const first = patch.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
const second = patch.locator('[data-scope="apply-patch"] button').filter({ hasText: "b.ts" })
const diff = patch.locator('[data-type="update"]').filter({ hasText: "b.ts" }).locator('[data-component="file"]')
await shell.click()
await first.click()
await second.click()
if (!open) {
await shell.click()
await first.click()
}
await expect(shell).toHaveAttribute("aria-expanded", String(open))
await expect(first).toHaveAttribute("aria-expanded", String(open))
await expect(second).toHaveAttribute("aria-expanded", "true")
await expect(diff).toBeVisible()
const original = await patch.elementHandle()
for (const count of [3, 4]) {
await root.getByRole("button", { name: "Append tool call", exact: true }).click()
await expect(group.locator('[data-component="tag"]')).toHaveText(String(count))
await expect(diff).toBeVisible()
await root
.locator('[data-component="session-timeline"]')
.screenshot({ path: info.outputPath(`append-${count}.png`) })
await expect(shell).toHaveAttribute("aria-expanded", String(open))
await expect(first).toHaveAttribute("aria-expanded", String(open))
await expect(second).toHaveAttribute("aria-expanded", "true")
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
await expect(group.getByRole("button", { name: "Used Shell, Patch", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
}
},
)
}
@@ -1,5 +1,66 @@
import { expect, story } from "../../storybook/playwright/story"
for (const reasoningDefaultOpen of [false, true]) {
story(
`keeps ordered thoughts and tool-only counts with reasoning ${reasoningDefaultOpen ? "expanded" : "collapsed"}`,
async ({ mount }) => {
const root = await mount("current-tool-group--mixed-reasoning", { args: { reasoningDefaultOpen } })
const group = root.locator('[data-component="collapsed-tool-group"]')
const used = group.getByRole("button", { name: "Used Read, Skill", exact: true })
const first = group.locator('[data-timeline-part-id="reasoning_first"]')
const second = group.locator('[data-timeline-part-id="reasoning_second"]')
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([
/Read.*group\.ts/,
/Thought/,
/Loaded.*opencode.*frontend-design.*skills/,
/Thought/,
/Loaded.*rtl-aware-development.*skill/,
])
await expect(
group.locator('[data-timeline-part-ids="reasoning_skill_first,reasoning_skill_second"]'),
).toBeVisible()
await expect(first.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(reasoningDefaultOpen),
)
await expect(second.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(reasoningDefaultOpen),
)
await first.getByRole("button", { name: "Thought", exact: true }).click()
await root.getByRole("button", { name: "Append follow-up read", exact: true }).click()
await expect(group.locator('[data-component="tag"]')).toHaveText("5")
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([
/Read.*group\.ts/,
/Thought/,
/Loaded.*opencode.*frontend-design.*skills/,
/Thought/,
/Loaded.*rtl-aware-development.*skill/,
/Read.*group\.test\.ts/,
])
await expect(first.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(!reasoningDefaultOpen),
)
await expect(second.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(reasoningDefaultOpen),
)
if (reasoningDefaultOpen) {
await expect(
first.getByText("The renderer groups adjacent tools. Check the relevant skills before changing it."),
).toBeHidden()
return
}
await expect(
first.getByText("The renderer groups adjacent tools. Check the relevant skills before changing it."),
).toBeVisible()
},
)
}
story("summarizes subagents as Agent while retaining their card titles", async ({ mount }) => {
const root = await mount("current-tool-group--mixed-tools")
const group = root.locator('[data-component="collapsed-tool-group"]')
@@ -273,11 +273,23 @@
line-height: var(--line-height-normal);
[data-component="markdown"] {
margin-top: 16px;
margin-top: 0;
font-style: normal;
font-size: 13px;
line-height: var(--line-height-base);
color: var(--v2-text-text-muted);
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
color: inherit;
}
strong,
b {
color: var(--v2-text-text-muted);
@@ -1516,3 +1528,8 @@
:root[data-color-scheme="light"] body [data-component="user-message"] [data-slot="user-message-text"] {
background: var(--v2-background-bg-layer-02);
}
:root body [data-workspace-session] [data-component="user-message"] [data-slot="user-message-text"] {
background: var(--v2-background-bg-accent);
color: var(--v2-text-text-contrast);
}
+2
View File
@@ -46,6 +46,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
data: Data
directory: string
sessionID?: string
shellRunning?: (id: string) => boolean
shellOutput?: (input: ShellOutputInput) => Promise<ShellOutputOutput>
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
@@ -62,6 +63,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,
shellRunning: props.shellRunning,
shellOutput: props.shellOutput,
}
},
@@ -6,7 +6,12 @@ import type {
import { Match, Switch } from "solid-js"
import type { SessionUserActions, SessionUserComment } from "../actions"
import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content"
import { CurrentContextToolGroup, CurrentFileToolGroup, ToolDisplay } from "../tools/tool-renderer"
import {
CurrentContextToolGroup,
CurrentFileToolGroup,
ToolDisplay,
type ContextGroupPart,
} from "../tools/tool-renderer"
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
export type { SessionUserActions, SessionUserComment } from "../actions"
@@ -42,6 +47,7 @@ export function SessionAssistantContent(props: {
showAssistantCopyPartID?: string | null
turnDurationMs?: number | null
defaultOpen?: boolean
reasoningDefaultOpen?: boolean
toolOpen?: boolean
onToolOpenChange?: (open: boolean) => void
onContentRendered?: () => void
@@ -63,8 +69,12 @@ export function SessionAssistantContent(props: {
{(content) => (
<AssistantReasoningContent
id={props.contentID}
text={content().text}
streaming={typeof props.message.time.completed !== "number"}
content={content()}
streaming={false}
defaultOpen={props.reasoningDefaultOpen}
open={props.toolOpen}
onOpenChange={props.onToolOpenChange}
onContentRendered={props.onContentRendered}
/>
)}
</Match>
@@ -92,7 +102,10 @@ export function SessionAssistantContent(props: {
}
export function SessionContextToolGroup(props: {
tools: SessionMessageAssistantTool[]
parts: ContextGroupPart[]
reasoningDefaultOpen?: boolean
reasoningOpen?: (id: string) => boolean | undefined
onReasoningOpenChange?: (id: string, open: boolean) => void
open: boolean
busy: boolean
onOpenChange: (open: boolean) => void
@@ -100,7 +113,10 @@ export function SessionContextToolGroup(props: {
}) {
return (
<CurrentContextToolGroup
tools={props.tools}
parts={props.parts}
reasoningDefaultOpen={props.reasoningDefaultOpen}
reasoningOpen={props.reasoningOpen}
onReasoningOpenChange={props.onReasoningOpenChange}
open={props.open}
busy={props.busy}
onOpenChange={props.onOpenChange}
@@ -13,11 +13,16 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { BasicTool } from "../components/basic-tool"
import { reasoningHeading } from "../timeline/projection"
import { Card } from "@opencode-ai/ui/card"
import type {
PromptAgentAttachment,
PromptFileAttachment,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageCompaction,
SessionMessageUser,
} from "@opencode-ai/client/promise"
@@ -490,12 +495,71 @@ export function AssistantTextContent(props: {
)
}
export function AssistantReasoningContent(props: { id: string; text: string; streaming: boolean }) {
export function AssistantReasoningContent(props: {
id: string
content: SessionMessageAssistantReasoning
streaming: boolean
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
onContentRendered?: () => void
}) {
const i18n = useI18n()
const [state, setState] = createStore<{ open?: boolean }>({})
const open = () => props.open ?? state.open ?? props.defaultOpen ?? false
const heading = createMemo(() => reasoningHeading(props.content.text))
const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
const duration = createMemo(() => {
const time = props.content.time
if (time?.completed === undefined) return undefined
const total = Math.max(0, Math.round((time.completed - time.created) / 1000))
if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) })
return i18n.t("ui.message.duration.minutesSeconds", {
minutes: numfmt().format(Math.floor(total / 60)),
seconds: numfmt().format(total % 60),
})
})
return (
<Show when={props.text}>
<div data-component="reasoning-part" data-timeline-part-id={props.id}>
<PacedMarkdown text={props.text} cacheKey={props.id} streaming={props.streaming} />
</div>
</Show>
<div data-component="reasoning-part" data-timeline-part-id={props.id}>
<BasicTool
icon="mcp"
status={props.streaming ? "running" : "completed"}
compact
allowOpenWhilePending
hideDetails={!props.content.text.trim()}
open={open()}
onOpenChange={(value) => {
setState("open", value)
props.onOpenChange?.(value)
props.onContentRendered?.()
}}
trigger={
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer
text={i18n.t(props.streaming ? "ui.sessionTurn.status.thinking" : "ui.message.thought")}
active={props.streaming}
/>
</span>
<Show
when={props.streaming && !open()}
fallback={
<Show when={!props.streaming && duration()}>
{(value) => <span data-slot="basic-tool-tool-subtitle">{value()}</span>}
</Show>
}
>
<span data-slot="basic-tool-tool-subtitle">
<TextReveal text={heading()} />
</span>
</Show>
</div>
</div>
}
>
<PacedMarkdown text={props.content.text} cacheKey={props.id} streaming={props.streaming} />
</BasicTool>
</div>
)
}
@@ -199,6 +199,51 @@ const fileScenarios = {
write: WrittenSource,
}
export const AppendingToolCalls = {
render: () => {
const [state, setState] = createStore({ calls: 0 })
const files = ["src/a.ts", "src/b.ts"].map((file) => ({
...storyPatchFile(file),
patch: createTwoFilesPatch(file, file, "export const before = true\n", "export const after = true\n"),
}))
const document = createMemo(() =>
storyDocument([
storyTool("tool_shell_existing", "shell", "completed", { command: "printf checked" }, { output: "checked" }),
storyTool(
"tool_patch_existing",
"patch",
"completed",
{ patchText: "Update two files" },
{
metadata: { files },
},
),
...Array.from({ length: state.calls }, (_, index) =>
storyTool(
`tool_patch_next_${index}`,
"patch",
"completed",
{ patchText: "Update src/a.ts again" },
{
metadata: { files: [files[0]] },
},
),
),
]),
)
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<button type="button" onClick={() => setState("calls", (count) => count + 1)}>
Append tool call
</button>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
},
}
export const ChangingFiles = {
args: { scenario: "streaming" },
argTypes: { scenario: { control: "select", options: Object.keys(fileScenarios) } },
@@ -130,7 +130,11 @@ describe("reuseTimelineRows", () => {
name: "does not create accidental key collisions",
previous: [context("context:a", ["a", "b", "c"])],
rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
expected: ["assistant-part:context:context:b", "assistant-part:context:context:a", "assistant-part:context:context:c"],
expected: [
"assistant-part:context:context:b",
"assistant-part:context:context:a",
"assistant-part:context:context:c",
],
reused: [],
},
])("$name", ({ previous, rows, expected, reused }) => {
@@ -173,7 +177,7 @@ describe("createTimelineProjection", () => {
const result = createTimelineProjection({
sessionMessages: messages,
status: { type: "busy" },
showReasoningSummaries: true,
reasoningMode: "full",
})
expect(result.activeMessageID).toBe("user-2")
@@ -207,12 +211,12 @@ describe("createTimelineProjection", () => {
const first = createTimelineProjection({
sessionMessages: messages,
status: { type: "idle" },
showReasoningSummaries: true,
reasoningMode: "full",
})
const second = createTimelineProjection({
sessionMessages: messages,
status: { type: "idle" },
showReasoningSummaries: true,
reasoningMode: "full",
previousRows: first.rows,
})
@@ -244,7 +248,7 @@ describe("createTimelineProjection", () => {
const result = createTimelineProjection({
sessionMessages: messages,
status: { type: "idle" },
showReasoningSummaries: true,
reasoningMode: "full",
})
expect(result.assistantMessagesByParent.get("assistant-1")?.map((message) => message.id)).toEqual([
+45 -35
View File
@@ -13,6 +13,8 @@ import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "
export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
export type ReasoningMode = "hidden" | "compact" | "full"
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
type Content = SessionMessageAssistant["content"][number]
@@ -24,7 +26,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unkno
export type TimelineProjectionInput = {
sessionMessages: SessionMessageInfo[]
status: SessionStatus
showReasoningSummaries: boolean
reasoningMode: ReasoningMode
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
pendingUserMessageIDs?: ReadonlySet<string>
@@ -35,7 +37,7 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
const sessionMessageByID = new Map(input.sessionMessages.map((message) => [message.id, message] as const))
const projection = Timeline.constructSessionMessageRows(
input.sessionMessages,
input.showReasoningSummaries,
input.reasoningMode !== "hidden",
input.status,
input.pendingUserMessageIDs,
input.shellToolDefaultOpen ?? false,
@@ -70,7 +72,7 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
export function createReactiveTimelineProjection(input: {
sessionMessages: Accessor<SessionMessageInfo[]>
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
reasoningMode: Accessor<ReasoningMode>
shellToolDefaultOpen?: Accessor<boolean>
editToolDefaultOpen?: Accessor<boolean>
pendingUserMessageIDs?: Accessor<ReadonlySet<string>>
@@ -83,7 +85,7 @@ export function createReactiveTimelineProjection(input: {
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
input.showReasoningSummaries(),
input.reasoningMode() !== "hidden",
input.status(),
input.pendingUserMessageIDs?.(),
input.shellToolDefaultOpen?.() ?? false,
@@ -238,14 +240,16 @@ export namespace Timeline {
const lastAssistant = assistantMessages.at(-1)
const previousUserMessage = index > 0
const compaction = entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
const delegating = assistantMessages.some((message) =>
message.content.some(
(content) =>
content.type === "tool" &&
content.name === "subagent" &&
(content.state.status === "streaming" || content.state.status === "running"),
),
)
const lastContent = lastAssistant?.content.at(-1)
const thinking =
showReasoning &&
isActive &&
status.type === "busy" &&
lastAssistant?.time.completed === undefined &&
!lastAssistant?.error &&
!lastAssistant?.retry &&
lastContent?.type === "reasoning" &&
lastContent.time?.completed === undefined
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: turnID }))
if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID }))
@@ -257,7 +261,7 @@ export namespace Timeline {
const appendAssistantSegment = (messages: SessionMessageAssistant[]) => {
const refs = messages.flatMap((message, messageIndex) =>
contentEntries(message)
.filter((entry) => renderable(entry.content, showReasoning))
.filter((entry) => renderable(entry.content, showReasoning) && !(thinking && entry.content === lastContent))
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
)
const interruptedAt = messages.findIndex((message) => isInterrupted(message.error))
@@ -266,7 +270,7 @@ export namespace Timeline {
const appendGroups = (items: typeof refs) => {
let offset = 0
groupContent(items, shellToolDefaultOpen, editToolDefaultOpen).forEach((group) => {
const tool = group.type !== "part" || items[offset]?.content.type === "tool"
const tool = group.type !== "part" || items[offset]?.content.type !== "text"
offset += group.type === "part" ? 1 : group.refs.length
rows.push(
new TimelineRow.AssistantPart({
@@ -309,22 +313,13 @@ export namespace Timeline {
})
appendAssistantSegment(assistantSegment)
if (
isActive &&
status.type === "busy" &&
!lastAssistant?.error &&
!lastAssistant?.retry &&
!delegating &&
(showReasoning
? !assistantMessages.some((message) => message.content.some((content) => renderable(content, true)))
: true)
) {
const heading = assistantMessages
.flatMap((message) => message.content)
.map((content) => (content.type === "reasoning" && content.text ? reasoningHeading(content.text) : undefined))
.find((value): value is string => !!value)
rows.push(new TimelineRow.Thinking({ userMessageID: turnID, reasoningHeading: heading }))
if (thinking && lastAssistant) {
rows.push(
new TimelineRow.Thinking({
userMessageID: turnID,
ref: { messageID: lastAssistant.id, partID: contentEntries(lastAssistant).at(-1)!.id },
}),
)
}
return rows
@@ -490,11 +485,18 @@ function groupContent(
editToolDefaultOpen: boolean,
): PartGroup[] {
const groups: PartGroup[] = []
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[] } | undefined
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined
const flush = () => {
const current = adjacent
const first = current?.refs[0]
if (!first) return
if (!current.tools) {
groups.push(
...current.refs.map((ref) => ({ type: "part" as const, key: `part:${ref.messageID}:${ref.partID}`, ref })),
)
adjacent = undefined
return
}
groups.push({
type: current.type === "context" ? "context" : "file",
key:
@@ -509,11 +511,19 @@ function groupContent(
items.forEach((item) => {
const type =
item.content.type === "tool"
? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen, adjacent?.type === "context")
: undefined
? toolGroupType(
item.content,
shellToolDefaultOpen,
editToolDefaultOpen,
adjacent?.type === "context" && adjacent.tools,
)
: item.content.type === "reasoning"
? "context"
: undefined
if (type) {
if (adjacent?.type !== type) flush()
adjacent ??= { type, refs: [] }
adjacent ??= { type, refs: [], tools: false }
adjacent.tools ||= item.content.type === "tool"
adjacent.refs.push({ messageID: item.messageID, partID: item.partID })
return
}
@@ -560,7 +570,7 @@ function hasLoadedFiles(content: Extract<Content, { type: "tool" }>) {
return Array.isArray(loaded) && loaded.some((path) => typeof path === "string")
}
function reasoningHeading(text: string): string | undefined {
export function reasoningHeading(text: string): string | undefined {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
if (html?.[1]) {
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
@@ -33,7 +37,7 @@ describe("current session timeline rows", () => {
"assistant-part:part:part:msg_2:msg_2:text:0",
"turn-gap:msg_3",
"user-message:msg_3",
"assistant-part:part:part:msg_4:msg_4:reasoning:0",
"thinking:msg_3",
])
})
@@ -151,7 +155,7 @@ describe("current session timeline rows", () => {
])
})
test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
test("does not infer thinking from an optimistic busy turn", () => {
const source = [
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
{ id: "msg_a", type: "user", text: "pending", time: { created: 2 } },
@@ -159,15 +163,10 @@ describe("current session timeline rows", () => {
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
expect(result.activeMessageID).toBe("msg_a")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_z",
"turn-gap:msg_a",
"user-message:msg_a",
"thinking:msg_a",
])
expect(result.rows.map(TimelineRow.key)).toEqual(["user-message:msg_z", "turn-gap:msg_a", "user-message:msg_a"])
})
test("renders thinking above a queued user message", () => {
test("does not infer thinking above a queued user message", () => {
const source = [
{ id: "msg_active", type: "user", text: "active", time: { created: 1 } },
{ id: "msg_queued", type: "user", text: "queued", time: { created: 2 } },
@@ -177,7 +176,6 @@ describe("current session timeline rows", () => {
expect(result.activeMessageID).toBe("msg_active")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_active",
"thinking:msg_active",
"turn-gap:msg_queued",
"user-message:msg_queued",
])
@@ -209,9 +207,10 @@ describe("current session timeline rows", () => {
},
] satisfies SessionMessageInfo[]
expect(Timeline.constructSessionMessageRows(source, false, { type: "busy" }).rows.map((row) => row._tag)).toEqual(
["UserMessage", "AssistantPart"],
)
expect(Timeline.constructSessionMessageRows(source, true, { type: "busy" }).rows.map((row) => row._tag)).toEqual([
"UserMessage",
"AssistantPart",
])
})
})
@@ -234,6 +233,98 @@ describe("current session timeline rows", () => {
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry"])
})
test.each(["hidden", "compact", "full"] as const)("only shows active reasoning in %s mode", (reasoningMode) => {
const active = { type: "reasoning", text: "## Current thought", time: { created: 2 } } as const
const cases: { content: SessionMessageAssistant["content"]; thinking: boolean }[] = [
{ content: [], thinking: false },
{ content: [active], thinking: true },
{ content: [{ ...active, text: "" }], thinking: true },
{ content: [{ ...active, time: { created: 2, completed: 3 } }], thinking: false },
{ content: [active, { type: "text", text: "Answer" }], thinking: false },
...(["streaming", "running", "completed", "error"] as const).flatMap((status) =>
["shell", "read", "subagent", "question"].map((name) => ({
content: [active, storyTool("tool", name, status, {})],
thinking: false,
})),
),
]
cases.forEach((profile) => {
const document = storyDocument(profile.content, true)
const result = createTimelineProjection({
sessionMessages: document.messages,
status: document.status,
reasoningMode,
})
expect(result.rows.some((row) => row._tag === "Thinking")).toBe(reasoningMode !== "hidden" && profile.thinking)
})
})
test("stops thinking on idle, message completion, errors and retries", () => {
const document = storyDocument([{ type: "reasoning", text: "Current thought" }], true)
expect(
Timeline.constructSessionMessageRows(document.messages, true, { type: "idle" }).rows.map((row) => row._tag),
).toEqual(["UserMessage", "AssistantPart"])
const endings = [
{ time: { created: 1, completed: 2 } },
{ error: { type: "Interrupted", message: "Stopped" } },
{ retry: { attempt: 1, at: 10, error: { type: "ProviderError", message: "Retry" } } },
]
endings.forEach((ending) => {
const messages = document.messages.map((message) =>
message.type === "assistant" ? { ...message, ...ending } : message,
)
expect(
Timeline.constructSessionMessageRows(messages, true, { type: "busy" }).rows.some(
(row) => row._tag === "Thinking",
),
).toBe(false)
})
})
test("uses the latest reasoning part and groups earlier thoughts with tools", () => {
const document = storyDocument(
[
{ type: "reasoning", text: "Old thought", time: { created: 1, completed: 2 } },
storyTool("read", "read", "completed", {}),
{ type: "reasoning", text: "New thought", time: { created: 3 } },
],
true,
)
const result = Timeline.constructSessionMessageRows(document.messages, true, { type: "busy" })
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart", "Thinking"])
expect(result.rows[1]).toMatchObject({
group: { type: "context", refs: [{ partID: "msg_tool_projection_assistant:reasoning:0" }, { partID: "read" }] },
})
expect(result.rows[2]).toMatchObject({ ref: { partID: "msg_tool_projection_assistant:reasoning:1" } })
})
test("keeps actual thinking with the active prompt above an undelivered prompt", () => {
const document = storyDocument([{ type: "reasoning", text: "Active thought" }], true)
const result = Timeline.constructSessionMessageRows(
[...document.messages, { type: "user", id: "queued", text: "Next task", time: { created: 10 } }],
true,
document.status,
new Set(["queued"]),
)
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Thinking", "TurnGap", "UserMessage"])
expect(result.rows[1].userMessageID).toBe(document.messages[0].id)
})
test.each(["shell", "execute", "subagent"])("does not hide active %s behind a preceding thought", (name) => {
const document = storyDocument(
[
{ type: "reasoning", text: "Finished thought", time: { created: 1, completed: 2 } },
storyTool("active", name, "running", {}),
],
true,
)
const result = Timeline.constructSessionMessageRows(document.messages, true, document.status)
expect(result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([
"part",
"part",
])
})
test("keeps assistant errors and retries before later notices", () => {
const result = Timeline.constructSessionMessageRows(
[
@@ -729,7 +820,7 @@ describe("current session timeline rows", () => {
const initial = createTimelineProjection({
sessionMessages: storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
status: { type: "busy" },
showReasoningSummaries: false,
reasoningMode: "hidden",
})
const phases = [
{ status: "streaming" },
@@ -749,7 +840,7 @@ describe("current session timeline rows", () => {
.map((message) => ({ ...message, id: "next-step" })),
],
status: { type: "busy" },
showReasoningSummaries: false,
reasoningMode: "hidden",
previousRows,
})
const groups = result.rows.filter((row) => row._tag === "AssistantPart")
@@ -772,7 +863,7 @@ describe("current session timeline rows", () => {
{ name: "execute", expanded: true, types: ["context", "part"] },
{ name: "subagent", expanded: true, types: ["context"] },
{ name: "shell", separator: "text", types: ["context", "part", "part"] },
{ name: "shell", separator: "reasoning", showReasoning: true, types: ["context", "part", "part"] },
{ name: "shell", separator: "reasoning", showReasoning: true, types: ["context"] },
{ name: "shell", separator: "reasoning", showReasoning: false, types: ["context"] },
] as const)("respects active tool grouping boundaries: %j", (profile) => {
const content = [
@@ -6,12 +6,9 @@ import type {
} from "@opencode-ai/client/promise"
import { Card } from "@opencode-ai/ui/card"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
import type { SessionUserActions, SessionUserComment } from "../actions"
import { BasicTool } from "../components/basic-tool"
import { useData } from "../context"
import { TimelineSeparator } from "../components/timeline-separator"
import {
@@ -22,9 +19,16 @@ import {
SessionUserMessage,
currentContentDefaultOpen,
} from "../message/current-message"
import { SessionCompactionMessage } from "../message/message-content"
import { AssistantReasoningContent, SessionCompactionMessage } from "../message/message-content"
import type { ContextGroupPart } from "../tools/tool-renderer"
import { SessionRetry } from "../components/session-retry"
import { createReactiveTimelineProjection, Timeline, TimelineRow, unwrapErrorMessage } from "./projection"
import {
createReactiveTimelineProjection,
Timeline,
TimelineRow,
unwrapErrorMessage,
type ReasoningMode,
} from "./projection"
const emptyAssistantMessages: SessionMessageAssistant[] = []
type Projection = ReturnType<typeof createReactiveTimelineProjection>
@@ -41,7 +45,7 @@ export function createSessionTimelineRowRenderer(input: {
projection: Projection
presentation: (message: SessionMessageUser) => SessionUserPresentation | undefined
actions?: SessionUserActions
showReasoningSummaries: Accessor<boolean>
reasoningMode: Accessor<ReasoningMode>
shellToolDefaultOpen: Accessor<boolean>
editToolDefaultOpen: Accessor<boolean>
disclosure: {
@@ -79,19 +83,24 @@ export function createSessionTimelineRowRenderer(input: {
const renderAssistant = (row: Accessor<TimelineRow.AssistantPart>, onSizeChange?: () => void) => {
if (row().group.type === "context") {
const tools = createMemo(() => {
const parts = createMemo(() => {
const group = row().group
if (group.type !== "context") return []
return group.refs.flatMap((ref) => {
return group.refs.flatMap<ContextGroupPart>((ref) => {
const message = input.projection.messageByID().get(ref.messageID)
const content = Timeline.resolveContent(message, ref.partID)
return message?.type === "assistant" && content?.type === "tool" ? [content] : []
if (content?.type === "tool") return [content]
if (content?.type === "reasoning") return [{ ...content, id: ref.partID }]
return []
})
})
const key = () => `context:${row().group.key}`
return (
<SessionContextToolGroup
tools={tools()}
parts={parts()}
reasoningDefaultOpen={input.reasoningMode() === "full"}
reasoningOpen={(id) => input.disclosure.value(id)}
onReasoningOpenChange={(id, open) => input.disclosure.set(id, open)}
open={input.disclosure.value(key()) === true}
busy={
workingTurn(row().userMessageID) &&
@@ -154,8 +163,10 @@ export function createSessionTimelineRowRenderer(input: {
const defaultOpen = createMemo(() => {
const item = content()
if (!item) return undefined
if (item.type === "reasoning") return input.reasoningMode() === "full"
return currentContentDefaultOpen(item, input.shellToolDefaultOpen(), input.editToolDefaultOpen())
})
const disclosureKey = () => (content()?.type === "reasoning" ? ref()!.partID : row().group.key)
return (
<Show when={message()}>
{(message) => (
@@ -168,8 +179,8 @@ export function createSessionTimelineRowRenderer(input: {
showAssistantCopyPartID={copyContentID(row().userMessageID)}
turnDurationMs={duration(row().userMessageID)}
defaultOpen={defaultOpen()}
toolOpen={input.disclosure.value(row().group.key) ?? defaultOpen()}
onToolOpenChange={(open) => input.disclosure.set(row().group.key, open)}
toolOpen={input.disclosure.value(disclosureKey()) ?? defaultOpen()}
onToolOpenChange={(open) => input.disclosure.set(disclosureKey(), open)}
onContentRendered={onSizeChange}
/>
)}
@@ -475,11 +486,13 @@ export function createSessionTimelineRowRenderer(input: {
if (value._tag !== "AssistantPart") throw new Error("Expected an assistant-part timeline row")
return value
}
// Construct once per row key, not inside JSX that reruns when group refs change.
const content = renderAssistant(current, onSizeChange)
return (
<Frame row={current()}>
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
<div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(current().userMessageID)}>
{renderAssistant(current, onSizeChange)}
{content}
</div>
</div>
</Frame>
@@ -491,39 +504,28 @@ export function createSessionTimelineRowRenderer(input: {
if (value._tag !== "Thinking") throw new Error("Expected a thinking timeline row")
return value
}
const animateHeading = createMemo<boolean>((previous) => previous ?? !current().reasoningHeading)
const content = createMemo(() => {
const ref = current().ref
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
return content?.type === "reasoning" ? content : undefined
})
return (
<Frame row={current()}>
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
<div data-slot="session-turn-thinking-row">
<BasicTool
icon="mcp"
status="running"
compact
locked
hideDetails
trigger={
<div data-slot="session-turn-thinking">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
</span>
<Show when={!input.showReasoningSummaries()}>
<span data-slot="basic-tool-tool-subtitle">
<TextReveal
text={current().reasoningHeading}
class="session-turn-thinking-heading"
travel={animateHeading() ? 25 : 0}
duration={animateHeading() ? 700 : 0}
/>
</span>
</Show>
</div>
</div>
</div>
}
/>
<Show when={content()}>
{(content) => (
<AssistantReasoningContent
id={current().ref.partID}
content={content()}
streaming
defaultOpen={input.reasoningMode() === "full"}
open={input.disclosure.value(current().ref.partID)}
onOpenChange={(open) => input.disclosure.set(current().ref.partID, open)}
onContentRendered={onSizeChange}
/>
)}
</Show>
</div>
</div>
</Frame>
@@ -2,7 +2,7 @@ import { For, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionDocument } from "../document"
import type { SessionUserActions } from "../actions"
import { createReactiveTimelineProjection, TimelineRow } from "./projection"
import { createReactiveTimelineProjection, TimelineRow, type ReasoningMode } from "./projection"
import { createSessionTimelineRowRenderer, type SessionUserPresentation } from "./session-timeline-row"
export type { SessionUserPresentation } from "./session-timeline-row"
@@ -11,7 +11,7 @@ export type SessionTimelineProps = {
document: SessionDocument
presentation?: Record<string, SessionUserPresentation | undefined>
actions?: SessionUserActions
showReasoningSummaries?: boolean
reasoningMode?: ReasoningMode
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
class?: string
@@ -21,7 +21,7 @@ export function SessionTimeline(props: SessionTimelineProps) {
const projection = createReactiveTimelineProjection({
sessionMessages: () => props.document.messages,
status: () => props.document.status,
showReasoningSummaries: () => props.showReasoningSummaries ?? true,
reasoningMode: () => props.reasoningMode ?? "compact",
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
})
@@ -32,7 +32,7 @@ export function SessionTimeline(props: SessionTimelineProps) {
projection,
presentation: (message) => props.presentation?.[message.id],
actions: props.actions,
showReasoningSummaries: () => props.showReasoningSummaries ?? true,
reasoningMode: () => props.reasoningMode ?? "compact",
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
disclosure: {
@@ -3,6 +3,7 @@ import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionDocument } from "../document"
import { SessionTimeline } from "./session-timeline"
import type { ReasoningMode } from "./projection"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
CURRENT_SESSION_ID,
@@ -39,14 +40,7 @@ export default {
}
export const AgentThinking = {
render: () => (
<CurrentSessionTimelineStory
title="Agent thinking"
description="The prompt is admitted and the active turn is waiting for its first visible content."
document={thinkingDocument}
width="560px"
/>
),
render: () => <AgentReasoningStory mode="compact" reasoning="heading" tool={false} text="" />,
}
export const StreamingReasoningAndText = {
@@ -60,15 +54,18 @@ export const StreamingReasoningAndText = {
),
}
function AgentReasoningStory(props: { summaries: boolean; reasoning: string; tool: boolean; text: string }) {
function AgentReasoningStory(props: { mode: ReasoningMode; reasoning: string; tool: boolean; text: string }) {
const content = [
...(props.reasoning === "none"
? []
: [
{
type: "reasoning" as const,
text: props.reasoning === "blank" ? " " : "## Inspecting stability",
time: { created: STORY_TIME + 100 },
text:
props.reasoning === "blank"
? " "
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
time: { created: STORY_TIME + 100, ...(props.tool || props.text ? { completed: STORY_TIME + 7100 } : {}) },
},
]),
...(props.tool
@@ -78,7 +75,7 @@ function AgentReasoningStory(props: { summaries: boolean; reasoning: string; too
id: "tool_reasoning_projection_skill",
name: "skill",
state: { status: "running" as const, input: { name: "inspect" }, metadata: {} },
time: { created: STORY_TIME + 200, ran: STORY_TIME + 250 },
time: { created: STORY_TIME + 7200, ran: STORY_TIME + 7250 },
},
]
: []),
@@ -103,16 +100,16 @@ function AgentReasoningStory(props: { summaries: boolean; reasoning: string; too
return (
<section class="mx-auto w-full max-w-[720px] p-6">
<CurrentSessionProviders document={document}>
<SessionTimeline document={document} showReasoningSummaries={props.summaries} />
<SessionTimeline document={document} reasoningMode={props.mode} />
</CurrentSessionProviders>
</section>
)
}
const AgentReasoning = {
args: { summaries: true, reasoning: "heading", tool: false, text: "" },
args: { mode: "compact", reasoning: "heading", tool: false, text: "" },
argTypes: { reasoning: { control: "select", options: ["none", "blank", "heading"] } },
render: (args: { summaries: boolean; reasoning: string; tool: boolean; text: string }) => (
render: (args: { mode: ReasoningMode; reasoning: string; tool: boolean; text: string }) => (
<AgentReasoningStory {...args} />
),
}
@@ -174,7 +171,7 @@ function HiddenReasoningStory() {
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} showReasoningSummaries={false} />
<SessionTimeline document={document()} reasoningMode="compact" />
</CurrentSessionProviders>
</section>
)
@@ -592,12 +589,13 @@ const conversationScenarios = {
}
export const Conversation = {
args: { scenario: "notices", summaries: true, reasoning: "heading", tool: false, text: "" },
args: { scenario: "notices", mode: "compact", reasoning: "heading", tool: false, text: "" },
argTypes: {
scenario: { control: "select", options: Object.keys(conversationScenarios) },
reasoning: { control: "select", options: ["none", "blank", "heading"] },
mode: { control: "select", options: ["hidden", "compact", "full"] },
},
render: (args: { scenario: string; summaries: boolean; reasoning: string; tool: boolean; text: string }) => {
render: (args: { scenario: string; mode: ReasoningMode; reasoning: string; tool: boolean; text: string }) => {
if (args.scenario === "reasoning") return <AgentReasoningStory {...args} />
return conversationScenarios[args.scenario as Exclude<keyof typeof conversationScenarios, "reasoning">].render()
},
@@ -54,7 +54,7 @@ export namespace TimelineRow {
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
reasoningHeading?: string
ref: PartRef
}> {}
export class Error extends Data.TaggedClass("Error")<{
@@ -121,7 +121,7 @@ export type TimelineRowMap = {
previousAssistantPart: boolean
spacing?: "tool" | "content"
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Thinking: { userMessageID: string; ref: PartRef }
Retry: { userMessageID: string }
Error: { userMessageID: string; text: string }
}
@@ -1,9 +1,9 @@
import { createMemo, createSignal } from "solid-js"
import { createMemo, createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createTwoFilesPatch } from "diff"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { CurrentContextToolGroup } from "./tool-renderer"
import { type ContextGroupPart, CurrentContextToolGroup } from "./tool-renderer"
export default {
title: "OpenCode/Work/Tool group",
@@ -29,7 +29,48 @@ export const MixedTools = {
return (
<section style={{ width: "100%", "max-width": "720px", padding: "24px" }}>
<CurrentSessionProviders document={storyDocument(tools)}>
<CurrentContextToolGroup tools={tools} busy={false} open={open()} onOpenChange={setOpen} />
<CurrentContextToolGroup parts={tools} busy={false} open={open()} onOpenChange={setOpen} />
</CurrentSessionProviders>
</section>
)
},
}
export const MixedReasoning = {
args: { reasoningDefaultOpen: false },
render: (args: { reasoningDefaultOpen: boolean }) => {
const [open, setOpen] = createSignal(true)
const [appended, setAppended] = createSignal(false)
const parts = createMemo<ContextGroupPart[]>(() => [
storyTool("reasoning_read", "read", "completed", { path: "src/group.ts" }),
{
type: "reasoning",
id: "reasoning_first",
text: "The renderer groups adjacent tools. Check the relevant skills before changing it.",
},
storyTool("reasoning_skill_first", "skill", "completed", { id: "opencode" }),
storyTool("reasoning_skill_second", "skill", "completed", { id: "frontend-design" }),
{
type: "reasoning",
id: "reasoning_second",
text: "Keep these skill groups separate so the reasoning stays in chronological order.",
},
storyTool("reasoning_skill_third", "skill", "completed", { id: "rtl-aware-development" }),
...(appended() ? [storyTool("reasoning_read_next", "read", "completed", { path: "src/group.test.ts" })] : []),
])
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<button type="button" onClick={() => setAppended((value) => !value)}>
{appended() ? "Remove follow-up read" : "Append follow-up read"}
</button>
<CurrentSessionProviders document={storyDocument(parts())}>
<CurrentContextToolGroup
parts={parts()}
busy={false}
open={open()}
onOpenChange={setOpen}
reasoningDefaultOpen={args.reasoningDefaultOpen}
/>
</CurrentSessionProviders>
</section>
)
@@ -38,9 +79,9 @@ export const MixedTools = {
export const PatchFollowUps = {
args: { separator: "none" },
argTypes: { separator: { control: "select", options: ["none", "shell", "error"] } },
argTypes: { separator: { control: "select", options: ["none", "shell", "error", "reasoning"] } },
render: (args: { separator: string }) => {
const [state, setState] = createStore({ phase: "initial", open: true })
const [state, setState] = createStore({ phase: "initial", open: true, reasoning: true })
const file = (path: string, before: number, after: number) => ({
file: path,
status: "modified",
@@ -56,7 +97,7 @@ export const PatchFollowUps = {
{ context: Infinity },
),
})
const tools = createMemo(() => [
const parts = createMemo<ContextGroupPart[]>(() => [
storyTool("patch_shell", "shell", "completed", { command: "printf checked" }, { output: "checked" }),
storyTool(
"patch_first",
@@ -76,6 +117,15 @@ export const PatchFollowUps = {
...(args.separator === "error"
? [storyTool("patch_error", "patch", "error", {}, { error: "Patch failed" })]
: []),
...(args.separator === "reasoning" && state.reasoning
? [
{
type: "reasoning" as const,
id: "patch_reasoning",
text: "The first patch is ready. Now update the remaining files.",
},
]
: []),
storyTool(
"patch_next",
"patch",
@@ -96,10 +146,15 @@ export const PatchFollowUps = {
<button type="button" onClick={() => setState("phase", "completed")}>
Finish follow-up patch
</button>
<Show when={args.separator === "reasoning"}>
<button type="button" onClick={() => setState("reasoning", (value) => !value)}>
{state.reasoning ? "Hide thoughts" : "Show thoughts"}
</button>
</Show>
</div>
<CurrentSessionProviders document={storyDocument(tools())}>
<CurrentSessionProviders document={storyDocument(parts())}>
<CurrentContextToolGroup
tools={tools()}
parts={parts()}
busy={state.phase === "running"}
open={state.open}
onOpenChange={(open) => setState("open", open)}
+226 -152
View File
@@ -36,14 +36,18 @@ import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { changedFileDiff, patchFileGroups } from "../components/apply-patch-file"
import { animate } from "motion"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
import type { SessionMessageAssistantTool, SessionMessageShell } from "@opencode-ai/client/promise"
import type {
SessionMessageAssistantReasoning,
SessionMessageAssistantTool,
SessionMessageShell,
} from "@opencode-ai/client/promise"
import {
currentToolError,
currentToolInput,
currentToolMetadata,
currentToolOutput,
} from "../message/current-tool-state"
import { writeClipboard } from "../message/message-content"
import { AssistantReasoningContent, writeClipboard } from "../message/message-content"
function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined
@@ -469,22 +473,27 @@ function ExaOutput(props: { output?: string }) {
)
}
export type ContextGroupPart = SessionMessageAssistantTool | (SessionMessageAssistantReasoning & { id: string })
export function CurrentContextToolGroup(props: {
tools: SessionMessageAssistantTool[]
parts: ContextGroupPart[]
busy: boolean
open: boolean
onOpenChange: (open: boolean) => void
onSizeChange?: () => void
reasoningDefaultOpen?: boolean
reasoningOpen?: (id: string) => boolean | undefined
onReasoningOpenChange?: (id: string, open: boolean) => void
}) {
const i18n = useI18n()
const tools = createMemo(() => props.parts.filter((part) => part.type === "tool"))
const pending = createMemo(
() =>
props.busy || props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
() => props.busy || tools().some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
)
const names = createMemo(() =>
[
...new Set(
props.tools.map((tool) => {
tools().map((tool) => {
const input = currentToolInput(tool)
if (tool.name === "skill") return i18n.t("ui.tool.skill")
if (tool.name === "subagent") return i18n.t("ui.tool.agent.default")
@@ -500,31 +509,40 @@ export function CurrentContextToolGroup(props: {
return { text, before: text.slice(0, index).trim(), after: text.slice(index + tools.length).trim() }
})
const items = createMemo(() =>
props.tools.reduce<SessionMessageAssistantTool[][]>((groups, tool) => {
const previous = groups.at(-1)
if (
tool.name === "patch" &&
tool.state.status !== "error" &&
previous?.[0]?.name === "patch" &&
previous[0].state.status !== "error"
) {
previous.push(tool)
props.parts.reduce<(SessionMessageAssistantTool[] | (SessionMessageAssistantReasoning & { id: string }))[]>(
(groups, tool) => {
if (tool.type === "reasoning") {
groups.push(tool)
return groups
}
const previous = groups.at(-1)
if (
tool.name === "patch" &&
tool.state.status !== "error" &&
Array.isArray(previous) &&
previous?.[0]?.name === "patch" &&
previous[0].state.status !== "error"
) {
previous.push(tool)
return groups
}
if (
tool.name === "skill" &&
tool.state.status !== "error" &&
skillToolName(currentToolInput(tool), currentToolMetadata(tool)) &&
Array.isArray(previous) &&
previous?.[0]?.name === "skill" &&
previous[0].state.status !== "error" &&
skillToolName(currentToolInput(previous[0]), currentToolMetadata(previous[0]))
) {
previous.push(tool)
return groups
}
groups.push([tool])
return groups
}
if (
tool.name === "skill" &&
tool.state.status !== "error" &&
skillToolName(currentToolInput(tool), currentToolMetadata(tool)) &&
previous?.[0]?.name === "skill" &&
previous[0].state.status !== "error" &&
skillToolName(currentToolInput(previous[0]), currentToolMetadata(previous[0]))
) {
previous.push(tool)
return groups
}
groups.push([tool])
return groups
}, []),
},
[],
),
)
const change = (open: boolean) => {
props.onOpenChange(open)
@@ -532,7 +550,7 @@ export function CurrentContextToolGroup(props: {
}
return (
<div data-component="collapsed-tool-group" data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}>
<div data-component="collapsed-tool-group" data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}>
<BasicTool
icon="glasses"
status={pending() ? "running" : "completed"}
@@ -550,123 +568,162 @@ export function CurrentContextToolGroup(props: {
<Show when={label().after}>
{(after) => <span data-slot="context-tool-group-prefix">{after()}</span>}
</Show>
<Badge>{props.tools.length}</Badge>
<Badge>{tools().length}</Badge>
</span>
</div>
}
>
<div data-component="context-tool-group-list">
<Index each={items()}>
{(group) => {
const tool = createMemo(() => group()[0]!)
const trigger = createMemo(() => currentContextToolTrigger(tool(), i18n))
const skills = createMemo(() =>
group().flatMap((item) => {
const name = skillToolName(currentToolInput(item), currentToolMetadata(item))
return name ? [name] : []
}),
)
const marker = "__OPENCODE_LOADED_SKILL__"
const loaded = createMemo(() => i18n.plural("ui.tool.loadedSkills", skills().length, { name: marker }))
{(item) => {
const group = createMemo(() => {
const value = item()
return Array.isArray(value) ? value : undefined
})
const reasoning = createMemo(() => {
const value = item()
return Array.isArray(value) ? undefined : value
})
return (
<div data-slot="context-tool-group-item">
<Show
when={tool().state.status !== "error" && ["read", "glob", "grep", "list"].includes(tool().name)}
fallback={
<Show
when={tool().name === "skill" && group().length > 1 && skills().length === group().length}
fallback={
<Show
when={tool().name === "patch" && tool().state.status !== "error"}
fallback={
<ToolDisplay
id={tool().id}
tool={tool().name}
input={currentToolInput(tool())}
metadata={currentToolMetadata(tool())}
output={currentToolOutput(tool())}
error={currentToolError(tool())}
status={tool().state.status}
defaultOpen={false}
deferContent
virtualizeDiff={false}
onContentRendered={props.onSizeChange}
/>
}
>
<CurrentFileToolGroup tools={group()} onSizeChange={props.onSizeChange} />
</Show>
}
>
<div
data-component="tool-loaded-item"
data-timeline-part-ids={group()
.map((item) => item.id)
.join(",")}
aria-label={i18n.plural("ui.tool.loadedSkills", skills().length, {
name: skills().join(", "),
})}
>
<span data-slot="tool-loaded-label" aria-hidden="true">
{loaded().split(marker)[0]?.trim()}
</span>
<span data-slot="tool-loaded-value" aria-hidden="true">
<For each={skills()}>
{(name, index) => (
<>
<Show when={index() > 0}>, </Show>
<TextShimmer
as="span"
text={name}
active={["streaming", "running"].includes(group()[index()]!.state.status)}
/>
</>
)}
</For>
</span>
<Show when={loaded().split(marker)[1]?.trim()}>
{(suffix) => (
<span data-slot="tool-loaded-kind" aria-hidden="true">
{suffix()}
</span>
)}
</Show>
<Show
when={group()}
fallback={
<Show when={reasoning()}>
{(part) => (
<div data-slot="context-tool-group-item">
<AssistantReasoningContent
id={part().id}
content={part()}
streaming={false}
defaultOpen={props.reasoningDefaultOpen}
open={props.reasoningOpen?.(part().id)}
onOpenChange={(open) => props.onReasoningOpenChange?.(part().id, open)}
onContentRendered={props.onSizeChange}
/>
</div>
</Show>
}
>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer
text={trigger().title}
active={tool().state.status === "streaming" || tool().state.status === "running"}
/>
</span>
<Show when={trigger().subtitle}>
{(subtitle) => <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
</Show>
<For each={trigger().args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
</For>
</div>
<Show when={trigger().matches}>
{(matches) => (
<>
<span data-slot="context-tool-group-dot" />
<span data-slot="context-tool-group-matches">{matches()}</span>
</>
)}
)}
</Show>
}
>
{(group) => {
const tool = createMemo(() => group()[0]!)
const trigger = createMemo(() => currentContextToolTrigger(tool(), i18n))
const skills = createMemo(() =>
group().flatMap((item) => {
const name = skillToolName(currentToolInput(item), currentToolMetadata(item))
return name ? [name] : []
}),
)
const marker = "__OPENCODE_LOADED_SKILL__"
const loaded = createMemo(() =>
i18n.plural("ui.tool.loadedSkills", skills().length, { name: marker }),
)
return (
<div data-slot="context-tool-group-item">
<Show
when={
tool().state.status !== "error" && ["read", "glob", "grep", "list"].includes(tool().name)
}
fallback={
<Show
when={tool().name === "skill" && group().length > 1 && skills().length === group().length}
fallback={
<Show
when={tool().name === "patch" && tool().state.status !== "error"}
fallback={
<ToolDisplay
id={tool().id}
tool={tool().name}
input={currentToolInput(tool())}
metadata={currentToolMetadata(tool())}
output={currentToolOutput(tool())}
error={currentToolError(tool())}
status={tool().state.status}
defaultOpen={false}
deferContent
virtualizeDiff={false}
onContentRendered={props.onSizeChange}
/>
}
>
<CurrentFileToolGroup tools={group()} onSizeChange={props.onSizeChange} />
</Show>
}
>
<div
data-component="tool-loaded-item"
data-timeline-part-ids={group()
.map((item) => item.id)
.join(",")}
aria-label={i18n.plural("ui.tool.loadedSkills", skills().length, {
name: skills().join(", "),
})}
>
<span data-slot="tool-loaded-label" aria-hidden="true">
{loaded().split(marker)[0]?.trim()}
</span>
<span data-slot="tool-loaded-value" aria-hidden="true">
<For each={skills()}>
{(name, index) => (
<>
<Show when={index() > 0}>, </Show>
<TextShimmer
as="span"
text={name}
active={["streaming", "running"].includes(group()[index()]!.state.status)}
/>
</>
)}
</For>
</span>
<Show when={loaded().split(marker)[1]?.trim()}>
{(suffix) => (
<span data-slot="tool-loaded-kind" aria-hidden="true">
{suffix()}
</span>
)}
</Show>
</div>
</Show>
}
>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer
text={trigger().title}
active={
tool().state.status === "streaming" || tool().state.status === "running"
}
/>
</span>
<Show when={trigger().subtitle}>
{(subtitle) => <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
</Show>
<For each={trigger().args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
</For>
</div>
<Show when={trigger().matches}>
{(matches) => (
<>
<span data-slot="context-tool-group-dot" />
<span data-slot="context-tool-group-matches">{matches()}</span>
</>
)}
</Show>
</div>
</div>
</div>
</div>
</div>
</Show>
</div>
</div>
</Show>
</div>
)
}}
</Show>
)
}}
</Index>
@@ -682,20 +739,23 @@ export function CurrentFileToolGroup(props: {
onFileOpenChange?: (path: string, open: boolean) => void
onSizeChange?: () => void
}) {
const files = createMemo((previous: { key: string; value: unknown }[]) => {
const files = createMemo((previous: { key: string; toolID: string; value: unknown }[]) => {
const next = props.tools.flatMap((tool) => {
const files = currentToolMetadata(tool).files
if (!Array.isArray(files)) return []
return files.map((value, index) => ({ key: `${tool.id}:${index}`, value }))
return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value }))
})
const updates = new Map(next.map((entry) => [entry.key, entry.value]))
const existing = new Set(previous.map((entry) => entry.key))
const owners = new Set(props.tools.map((tool) => tool.id))
const result = [
...previous.map((entry) => {
if (!updates.has(entry.key)) return entry
const value = updates.get(entry.key)
return samePatchFile(value, entry.value) ? entry : { key: entry.key, value }
}),
...previous
.filter((entry) => owners.has(entry.toolID))
.map((entry) => {
if (!updates.has(entry.key)) return entry
const value = updates.get(entry.key)
return samePatchFile(value, entry.value) ? entry : { ...entry, value }
}),
...next.filter((entry) => !existing.has(entry.key)),
]
return result.length === previous.length && result.every((entry, index) => entry === previous[index])
@@ -1411,28 +1471,37 @@ ToolRegistry.register({
const i18n = useI18n()
const data = useData()
const streaming = () => props.status === "streaming"
const pending = () => streaming() || props.status === "running" || props.metadata.status === "running"
const pending = () =>
streaming() ||
props.status === "running" ||
(typeof props.metadata.shellID === "string" && data.shellRunning?.(props.metadata.shellID) === true)
const sawStreaming = streaming()
const [streamed, setStreamed] = createSignal("")
createEffect(() => {
const id = props.metadata.shellID
const shellOutput = data.shellOutput
if (typeof id !== "string" || !pending() || !shellOutput) return
if (typeof id !== "string" || !shellOutput) return
const directory = data.directory
const running = pending()
let cursor = 0
let loading = false
let disposed = false
const load = async () => {
if (loading) return
loading = true
const response = await shellOutput({ id, location: { directory }, cursor }).catch(() => undefined)
if (disposed) return
if (response?.data.output) setStreamed((output) => output + response.data.output)
if (response) cursor = response.data.cursor
do {
const response = await shellOutput({ id, location: { directory }, cursor }).catch(() => undefined)
if (disposed || !response) break
setStreamed((output) => (cursor === 0 ? response.data.output : output + response.data.output))
if (response.data.cursor <= cursor) break
cursor = response.data.cursor
if (running || cursor >= response.data.size) break
} while (!disposed)
loading = false
}
void load()
const interval = setInterval(() => void load(), 1_000)
// Refresh the final snapshot on exit, but poll only while the shell is live.
const interval = running ? setInterval(() => void load(), 1_000) : undefined
onCleanup(() => {
disposed = true
clearInterval(interval)
@@ -1443,7 +1512,12 @@ ToolRegistry.register({
if (typeof props.metadata.command === "string") return props.metadata.command
return ""
}
const output = createMemo(() => stripAnsi((pending() && streamed()) || props.output || "").replace(/\r\n?/g, "\n"))
const output = createMemo(() =>
stripAnsi((typeof props.metadata.shellID === "string" && streamed()) || props.output || "").replace(
/\r\n?/g,
"\n",
),
)
return (
<BasicTool
{...props}
+1
View File
@@ -214,6 +214,7 @@ const source = {
"ui.message.revertMessage": "Revert message",
"ui.message.copyResponse": "Copy response",
"ui.message.copied": "Copied",
"ui.message.thought": "Thought",
"ui.message.duration.seconds": "{{count}}s",
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
"ui.message.interrupted": "Interrupted",