Compare 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
Luke Parker 990f7ee000 fix(app): keep timeline notices on one line (#45479) 2026-08-27 04:47:50 +00:00
Luke Parker e2d6c4bd4a fix(app): merge adjacent patches inside used groups (#45477) 2026-08-27 04:36:27 +00:00
Aiden Cline fcc6568fcb feat(core): adjust bg shell completion msg (include output file) (#45461) 2026-08-26 23:04:11 -05:00
opencode-agent[bot]andrekram1-node 1c66cd7832 fix(tui): clarify tool grouping setting (#45470)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-26 23:02:27 -05:00
Luke Parker 5cc81a497c fix(app): keep active tools in existing groups (#45462) 2026-08-27 03:54:38 +00:00
68 changed files with 3212 additions and 759 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()
})
}
@@ -0,0 +1,89 @@
import { expect, test } from "@playwright/test"
import { setupTimeline } from "../performance/timeline-stability/fixture"
for (const width of [1400, 390]) {
for (const profile of [
{ locale: "en", direction: "ltr" },
{ locale: "en", direction: "rtl" },
{ locale: "ar", direction: "rtl" },
]) {
test(`keeps notices on one line: ${profile.locale} ${profile.direction} ${width}`, async ({ page }, info) => {
const command =
"bun run inspect --target src/renderer/session-timeline.ts --output artifacts/inspection-report.json ".repeat(5)
const descriptions = [
`${command}--finished`,
`Instructions changed\n${command}--updated`,
`\u0645\u0631\u0627\u062c\u0639\u0629 ${command}--reviewed`,
]
await setupTimeline(page, {
locale: profile.locale,
viewport: { width, height: 900 },
sessionMessages: [
{
id: "msg_notice_user",
type: "user",
text: "Inspect the project and report completion.",
time: { created: 1 },
},
{
id: "msg_notice_shell",
type: "synthetic",
text: "Complete",
description: descriptions[0],
metadata: { source: "shell", state: "completed" },
time: { created: 2 },
},
{ id: "msg_notice_system", type: "system", text: descriptions[1], time: { created: 3 } },
{
id: "msg_notice_agent",
type: "synthetic",
text: "Complete",
description: descriptions[2],
metadata: { source: "subagent", state: "completed", agent: "general" },
time: { created: 4 },
},
],
})
await page
.locator("html")
.evaluate((element, direction) => element.setAttribute("dir", direction), profile.direction)
const notices = page.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(3)
await expect(notices).toContainText(descriptions)
await page.locator("[data-timeline-virtual-content]").screenshot({ path: info.outputPath("notices.png") })
await expect
.poll(() =>
notices.evaluateAll((nodes) =>
nodes.map((node) => {
const style = getComputedStyle(node)
const element = node as HTMLElement
return {
direction: style.direction,
whiteSpace: style.whiteSpace,
textOverflow: style.textOverflow,
overflow: style.overflowX,
singleLine:
Math.abs(
element.clientHeight -
parseFloat(style.paddingTop) -
parseFloat(style.paddingBottom) -
parseFloat(style.lineHeight),
) <= 1,
clipped: element.scrollWidth > element.clientWidth,
}
}),
),
)
.toEqual(
Array.from({ length: 3 }, () => ({
direction: profile.direction,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
singleLine: true,
clipped: true,
})),
)
})
}
}
@@ -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,
@@ -208,6 +209,56 @@ test("navigates from a running subagent card and hides background controls in th
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
})
for (const name of ["shell", "subagent"] as const) {
test(`keeps the background shortcut available for a grouped running ${name}`, async ({ page }) => {
const message = assistant(false, true)
await setupTimeline(page, {
sessionMessages: [
user,
{
...message,
content: [
{
type: "tool",
id: "call_read",
name: "read",
state: {
status: "completed",
input: { path: "src/example.ts" },
content: [{ type: "text", text: "export const example = true" }],
metadata: {},
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_running",
name,
state: {
status: "running",
input:
name === "shell" ? { command: "echo checking" } : { agent: "general", description: "Inspect code" },
metadata: {},
},
time: { created: 3 },
},
],
},
],
})
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
await expect(group).toBeVisible()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
)
await page.keyboard.press("Control+b")
await request
})
}
test("shows a badge for active background work", async ({ page }) => {
const childID = "ses_background_child"
await setupTimeline(page, {
@@ -298,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,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
completedAssistantInfo,
@@ -44,7 +45,7 @@ test("expands a mixed collapsed tool stack without expanding its individual call
const group = page.locator(
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
)
const summary = group.getByRole("button", { name: "Used Shell, Explore, Patch" })
const summary = group.getByRole("button", { name: "Used Shell, Agent, Patch" })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCSS("height", "28px")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
@@ -78,6 +79,61 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
})
test("combines follow-up patches into one three-file stack inside Used", async ({ page }) => {
const file = (path: string, before: number, after: number) => ({
file: path,
status: "modified",
additions: 1,
deletions: 1,
patch: createTwoFilesPatch(
path,
path,
`export const value = ${before}\n`,
`export const value = ${after}\n`,
"",
"",
{ context: Infinity },
),
})
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
shell("patch_shell", "completed"),
toolPart(
"patch_first",
"patch",
"completed",
{},
{
metadata: { files: [file("src/a.ts", 0, 1), file("src/b.ts", 0, 1)] },
},
),
]),
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used Shell, Patch", exact: true }).click()
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await timeline.send(
partUpdated(
toolPart(
"patch_next",
"patch",
"completed",
{},
{
metadata: { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)] },
},
),
),
)
await expect(group.locator('[data-component="tag"]')).toHaveText("3")
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
})
test("keeps failed search calls and their error cards inside the collapsed stack", async ({ page }) => {
const parts = [
toolPart(
@@ -52,7 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.getByRole("button", { name: "Used Agent" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
@@ -77,7 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.getByRole("button", { name: "Used Agent" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
@@ -195,7 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.getByRole("button", { name: "Used Agent" }).click()
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
@@ -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,
},
@@ -349,8 +349,7 @@ function MessageTimelineView(
: projects.find((item) => containsDirectory(item.worktree, sessionDirectory()))
})
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const avatarProject = createMemo(() => {
if (!showProjectIcon()) return
const session = props.session.data.info()
@@ -459,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,
@@ -469,18 +468,19 @@ function MessageTimelineView(
})
const backgroundHintPartID = createMemo(() => {
const blocking = new Set(props.background.blocking().map((task) => task.partID))
const row = projection
if (blocking.size === 0) return
return projection
.rows()
.findLast(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && blocking.has(row.group.ref.partID),
.flatMap((row) =>
row._tag === "AssistantPart" ? (row.group.type === "part" ? [row.group.ref] : row.group.refs) : [],
)
if (row?._tag !== "AssistantPart" || row.group.type !== "part") return
return row.group.ref.partID
.findLast((ref) => blocking.has(ref.partID))?.partID
})
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
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}` })
}),
}
}),
),
+5 -6
View File
@@ -19,9 +19,8 @@ import { ToolOutput } from "../../tool-output.js"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION =
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
"You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly."
const OS =
process.platform === "darwin"
? "macOS"
@@ -95,8 +94,8 @@ const toolResult = (output: Output) => {
}
}
const backgroundResult = (shellID: string) => ({
output: BACKGROUND_STARTED,
const backgroundResult = (shellID: string, file: string) => ({
output: `Command moved to the background (shell ID: ${shellID}).\nOutput is streaming to: ${file}`,
shellID,
truncated: false,
status: "running" as const,
@@ -295,7 +294,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
return backgroundResult(info.id)
return backgroundResult(info.id, info.file)
}
const result = yield* runtime.job
@@ -304,7 +303,7 @@ export const Plugin = {
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
return backgroundResult(info.id)
return backgroundResult(info.id, info.file)
}
if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
+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
+20 -8
View File
@@ -1298,6 +1298,17 @@ describe("ShellTool", () => {
const shell = yield* Shell.Service
if (!shellID) return
const id = ShellSchema.ID.make(shellID)
const info = yield* shell.get(id)
expect(settled.content).toEqual([
{
type: "text",
text: `Command moved to the background (shell ID: ${shellID}).\nOutput is streaming to: ${info.file}`,
},
{
type: "text",
text: "You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly.",
},
])
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
expect((yield* shell.wait(id)).status).toBe("timeout")
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
@@ -1523,19 +1534,20 @@ describe("ShellTool", () => {
const settled = yield* Fiber.join(waiting)
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ truncated: false })
expect(settled.content?.[0]).toEqual({
type: "text",
text: "The command was moved to the background.",
})
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("DO NOT sleep, poll"),
})
expect(shellID).toStartWith("sh_")
const shell = yield* Shell.Service
if (!shellID) return
const id = ShellSchema.ID.make(shellID)
const info = yield* shell.get(id)
expect(settled.content?.[0]).toEqual({
type: "text",
text: `Command moved to the background (shell ID: ${shellID}).\nOutput is streaming to: ${info.file}`,
})
expect(settled.content?.[1]).toEqual({
type: "text",
text: "You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly.",
})
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(id)).status).toBe("running")
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
+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
@@ -0,0 +1,46 @@
import { expect, story } from "../../storybook/playwright/story"
story("merges follow-up patches into one stack with a distinct file count", async ({ mount }, info) => {
const root = await mount("current-tool-group--patch-follow-ups")
const group = root.locator('[data-component="collapsed-tool-group"]')
const patches = group.locator('[data-component="apply-patch-tool"]')
await expect(patches).toHaveCount(1)
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
const first = patches.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
await first.click()
await expect(first).toHaveAttribute("aria-expanded", "true")
await root.getByRole("button", { name: "Start follow-up patch" }).click()
await expect(group.locator('[data-component="tag"]')).toHaveText("3")
await expect(patches).toHaveCount(1)
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
await expect(patches).toHaveCount(1)
await expect(patches.getByText("3 files", { exact: true })).toBeVisible()
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
await expect(first).toHaveAttribute("aria-expanded", "true")
await expect(patches.locator('[data-component="file"]')).toBeVisible()
await group.screenshot({ path: info.outputPath("merged.png") })
})
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()
const group = root.locator('[data-component="collapsed-tool-group"]')
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"])
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"])
})
@@ -1,5 +1,32 @@
import { expect, story } from "../../storybook/playwright/story"
for (const tool of ["shell", "execute", "subagent"]) {
for (const open of [false, true]) {
story(`keeps ${tool} inside an existing ${open ? "open" : "closed"} group through execution`, async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", {
args: { existingGroup: true, tool },
})
const group = timeline.locator('[data-component="collapsed-tool-group"]')
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle")
if (open) await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
await timeline.getByRole("button", { name: "Start tool", exact: true }).click()
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
const original = await group.elementHandle()
for (const action of [undefined, "Complete input", "Run command", "Complete command"]) {
if (action) await timeline.getByRole("button", { name: action, exact: true }).click()
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(1)
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
if (open) await expect(group.locator('[data-timeline-part-id="tool_shell_lifecycle"]')).toBeVisible()
}
})
}
}
for (const expanded of [false, true]) {
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ mount }) => {
@@ -78,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",
)
}
},
)
}
@@ -0,0 +1,112 @@
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"]')
await expect(group.getByRole("button", { name: "Used Shell, Read, Agent", exact: true })).toBeVisible()
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(group.locator('[data-component="task-tool-title"]')).toHaveText(["General", "Explore"])
})
for (const width of [840, 390]) {
story(`keeps grouped cards inside their trigger bounds at ${width}px`, async ({ mount, page }) => {
await page.setViewportSize({ width, height: 600 })
const root = await mount("current-tool-group--mixed-tools")
const group = root.locator('[data-component="collapsed-tool-group"]')
const cards = group.locator('[data-component="task-tool-surface"]')
await expect(cards).toHaveCount(2)
await expect
.poll(() =>
cards.evaluateAll((nodes) =>
nodes.map((node) => {
const card = node.getBoundingClientRect()
const trigger = node.closest('[data-component="tool-trigger"]')!.getBoundingClientRect()
const item = node.closest('[data-slot="context-tool-group-item"]')!.getBoundingClientRect()
return (
card.height === 36 &&
card.top >= trigger.top &&
card.bottom <= trigger.bottom &&
card.top >= item.top &&
card.bottom <= item.bottom
)
}),
),
)
.toEqual([true, true])
const shell = group.locator('[data-timeline-part-id="group_shell"]')
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await shell.getByRole("button").click()
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("printf 'group geometry'")
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText("group geometry")
await expect
.poll(() =>
shell.evaluate((node) => {
const card = node.querySelector('[data-component="bash-output"]')!.getBoundingClientRect()
const item = node.closest('[data-slot="context-tool-group-item"]')!.getBoundingClientRect()
return card.top >= item.top && card.bottom <= item.bottom
}),
)
.toBe(true)
})
}
@@ -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);
@@ -724,7 +736,9 @@
width: 100%;
}
> [data-component="tool-part-wrapper"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"] {
> [data-component="tool-part-wrapper"]
> [data-component="collapsible"]
> [data-slot="collapsible-trigger"]:not([data-hide-details="true"]) {
height: 28px;
}
@@ -1514,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([
+53 -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:
@@ -508,10 +510,20 @@ function groupContent(
items.forEach((item) => {
const type =
item.content.type === "tool" ? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen) : undefined
item.content.type === "tool"
? 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
}
@@ -526,7 +538,12 @@ function groupContent(
return groups
}
function toolGroupType(content: Extract<Content, { type: "tool" }>, shellExpanded: boolean, editExpanded: boolean) {
function toolGroupType(
content: Extract<Content, { type: "tool" }>,
shellExpanded: boolean,
editExpanded: boolean,
hasContextGroup: boolean,
) {
if (content.name === "question" || hasLoadedFiles(content)) return undefined
if (content.state.status === "error") {
if ((content.name === "shell" || content.name === "execute") && shellExpanded) return undefined
@@ -535,6 +552,7 @@ function toolGroupType(content: Extract<Content, { type: "tool" }>, shellExpande
return "context"
}
if (
!hasContextGroup &&
(content.state.status !== "completed" ||
("metadata" in content.state && content.state.metadata?.status === "running")) &&
(content.name === "shell" || content.name === "execute" || content.name === "subagent")
@@ -552,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,6 +1,11 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client/promise"
import { Timeline, TimelineRow } from "./projection"
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
describe("current session timeline rows", () => {
test("derives turns and tagged rows from chronological current messages", () => {
@@ -32,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",
])
})
@@ -150,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 } },
@@ -158,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 } },
@@ -176,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",
])
@@ -208,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",
])
})
})
@@ -233,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(
[
@@ -724,7 +816,72 @@ describe("current session timeline rows", () => {
expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...types])
})
test("keeps active and background work visible outside collapsed stacks", () => {
test.each(["shell", "execute", "subagent"])("keeps %s in an existing group throughout execution", (name) => {
const initial = createTimelineProjection({
sessionMessages: storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
status: { type: "busy" },
reasoningMode: "hidden",
})
const phases = [
{ status: "streaming" },
{ status: "running" },
{ status: "completed", metadata: { status: "running" } },
{ status: "completed" },
{ status: "error" },
] as const
phases.reduce((previousRows, phase, index) => {
const result = createTimelineProjection({
sessionMessages: [
...storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
...storyDocument([
storyTool("active", name, phase.status, {}, "metadata" in phase ? { metadata: phase.metadata } : {}),
])
.messages.filter((message) => message.type === "assistant")
.map((message) => ({ ...message, id: "next-step" })),
],
status: { type: "busy" },
reasoningMode: "hidden",
previousRows,
})
const groups = result.rows.filter((row) => row._tag === "AssistantPart")
expect(groups).toHaveLength(1)
expect(groups[0].group).toMatchObject({
type: "context",
refs: [
{ messageID: "msg_tool_projection_assistant", partID: "earlier" },
{ messageID: "next-step", partID: "active" },
],
})
expect(TimelineRow.key(groups[0])).toBe(TimelineRow.key(initial.rows[1]))
if (index > 0) expect(groups[0]).toBe(previousRows.find((row) => row._tag === "AssistantPart")!)
return result.rows
}, initial.rows)
})
test.each([
{ name: "shell", expanded: true, types: ["context", "part"] },
{ 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"] },
{ name: "shell", separator: "reasoning", showReasoning: false, types: ["context"] },
] as const)("respects active tool grouping boundaries: %j", (profile) => {
const content = [
storyTool("earlier", "read", "completed", {}),
...(profile.separator ? [{ type: profile.separator, text: "Visible boundary" }] : []),
storyTool("active", profile.name, "running", {}),
]
const rows = Timeline.constructSessionMessageRows(
storyDocument(content).messages,
profile.showReasoning ?? false,
{ type: "busy" },
undefined,
profile.expanded ?? false,
).rows
expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...profile.types])
})
test("keeps active and background work standalone when no group precedes them", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
{
@@ -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}
/>
)}
@@ -368,7 +379,7 @@ export function createSessionTimelineRowRenderer(input: {
fallback={
<div
data-slot="session-timeline-notice"
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${padding()}`}
class={`w-full truncate pt-3 pb-1 text-13-regular text-text-weak ${padding()}`}
>
<bdi dir="auto" class="text-13-medium">
{content().label}
@@ -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: {
@@ -125,34 +125,63 @@ export const TestFailed = {
),
}
function InteractiveCommandStory(props: { expanded?: boolean; streaming?: boolean }) {
function InteractiveCommandStory(props: {
expanded?: boolean
streaming?: boolean
existingGroup?: boolean
tool?: "shell" | "execute" | "subagent"
}) {
const [state, setState] = createStore({
phase: props.streaming ? "streaming" : "completed",
started: !props.existingGroup,
lines: 3,
sibling: false,
busy: false,
})
const document = createMemo(() => {
const phase = state.phase as "streaming" | "input" | "running" | "completed"
const command = phase === "streaming" ? "" : "printf ready"
const content: SessionMessageAssistant["content"] = [
storyTool("tool_shell_lifecycle", "shell", phase === "input" ? "streaming" : phase, command ? { command } : {}, {
output:
phase === "running"
? "still running"
: Array.from({ length: state.lines }, (_, index) => `line ${index + 1}`).join("\n"),
...(phase === "streaming" ? { raw: "" } : {}),
}),
...(props.existingGroup
? [storyTool("tool_context_lifecycle", "read", "completed", { filePath: "/workspace/README.md" })]
: []),
...(state.started
? [
storyTool(
"tool_shell_lifecycle",
props.tool ?? "shell",
phase === "input" ? "streaming" : phase,
phase === "streaming"
? {}
: props.tool === "execute"
? { code: 'console.log("ready")' }
: props.tool === "subagent"
? { description: "Inspect lifecycle", agent: "explore", prompt: "Inspect lifecycle" }
: { command: "printf ready" },
{
output:
phase === "running"
? "still running"
: Array.from({ length: state.lines }, (_, index) => `line ${index + 1}`).join("\n"),
...(phase === "streaming" ? { raw: "" } : {}),
},
),
]
: []),
...(state.sibling ? [{ type: "text" as const, text: "Sibling content" }] : []),
]
return {
...storyDocument(content, phase !== "completed"),
status: { type: phase !== "completed" || state.busy ? ("busy" as const) : ("idle" as const) },
...storyDocument(content, state.started && phase !== "completed"),
status: { type: (state.started && phase !== "completed") || state.busy ? ("busy" as const) : ("idle" as const) },
}
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-3">
<div class="flex flex-wrap gap-3">
{props.existingGroup && (
<button type="button" onClick={() => setState({ started: true, phase: "streaming" })}>
Start tool
</button>
)}
<button type="button" onClick={() => setState("phase", "input")}>
Complete input
</button>
@@ -182,16 +211,19 @@ function InteractiveCommandStory(props: { expanded?: boolean; streaming?: boolea
)
}
const RunACommand = {
args: { expanded: false, streaming: false },
render: (args: { expanded: boolean; streaming: boolean }) => <InteractiveCommandStory {...args} />,
}
export const TerminalCommands = {
args: { scenario: "command", expanded: false, streaming: false },
argTypes: { scenario: { control: "select", options: ["command", "collapsed"] } },
render: (args: { scenario: string; expanded: boolean; streaming: boolean }) =>
args.scenario === "collapsed" ? CollapsedShell.render() : RunACommand.render(args),
args: { scenario: "command", expanded: false, streaming: false, existingGroup: false, tool: "shell" },
argTypes: {
scenario: { control: "select", options: ["command", "collapsed"] },
tool: { control: "select", options: ["shell", "execute", "subagent"] },
},
render: (args: {
scenario: string
expanded: boolean
streaming: boolean
existingGroup: boolean
tool: "shell" | "execute" | "subagent"
}) => (args.scenario === "collapsed" ? CollapsedShell.render() : <InteractiveCommandStory {...args} />),
}
export const FixedAndPassed = {
@@ -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 }
}
@@ -0,0 +1,166 @@
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 { type ContextGroupPart, CurrentContextToolGroup } from "./tool-renderer"
export default {
title: "OpenCode/Work/Tool group",
id: "current-tool-group",
component: CurrentContextToolGroup,
}
export const MixedTools = {
render: () => {
const [open, setOpen] = createSignal(true)
const tools = [
storyTool(
"group_shell",
"shell",
"completed",
{ command: "printf 'group geometry'" },
{ output: "group geometry" },
),
storyTool("group_read", "read", "completed", { path: "src/group.ts" }),
storyTool("group_general", "subagent", "completed", { agent: "general", description: "Inspect grouped tools" }),
storyTool("group_explore", "subagent", "completed", { agent: "explore", description: "Check card geometry" }),
]
return (
<section style={{ width: "100%", "max-width": "720px", padding: "24px" }}>
<CurrentSessionProviders document={storyDocument(tools)}>
<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>
)
},
}
export const PatchFollowUps = {
args: { separator: "none" },
argTypes: { separator: { control: "select", options: ["none", "shell", "error", "reasoning"] } },
render: (args: { separator: string }) => {
const [state, setState] = createStore({ phase: "initial", open: true, reasoning: true })
const file = (path: string, before: number, after: number) => ({
file: path,
status: "modified",
additions: 1,
deletions: 1,
patch: createTwoFilesPatch(
path,
path,
`export const value = ${before}\n`,
`export const value = ${after}\n`,
"",
"",
{ context: Infinity },
),
})
const parts = createMemo<ContextGroupPart[]>(() => [
storyTool("patch_shell", "shell", "completed", { command: "printf checked" }, { output: "checked" }),
storyTool(
"patch_first",
"patch",
"completed",
{},
{
metadata: { files: [file("src/a.ts", 0, 1), file("src/b.ts", 0, 1)] },
},
),
...(state.phase === "initial"
? []
: [
...(args.separator === "shell"
? [storyTool("patch_separator", "shell", "completed", { command: "printf checked" })]
: []),
...(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",
state.phase === "running" ? "running" : "completed",
{},
{
metadata: state.phase === "running" ? {} : { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)] },
},
),
]),
])
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<div class="flex flex-wrap gap-3">
<button type="button" onClick={() => setState("phase", "running")}>
Start follow-up patch
</button>
<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(parts())}>
<CurrentContextToolGroup
parts={parts()}
busy={state.phase === "running"}
open={state.open}
onOpenChange={(open) => setState("open", open)}
/>
</CurrentSessionProviders>
</section>
)
},
}
+227 -138
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,26 +473,30 @@ 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" && typeof input.agent === "string" && input.agent)
return input.agent[0]!.toUpperCase() + input.agent.slice(1)
if (tool.name === "subagent") return i18n.t("ui.tool.agent.default")
return getToolInfo(tool.name, input, currentToolMetadata(tool)).title
}),
),
@@ -501,22 +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 === "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)
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
}
groups.push([tool])
return groups
}, []),
},
[],
),
)
const change = (open: boolean) => {
props.onOpenChange(open)
@@ -524,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"}
@@ -542,116 +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={
<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}
<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
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>
}
>
{(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>
@@ -663,24 +735,27 @@ export function CurrentContextToolGroup(props: {
export function CurrentFileToolGroup(props: {
tools: SessionMessageAssistantTool[]
fileOpen: (path: string) => boolean | undefined
onFileOpenChange: (path: string, open: boolean) => void
fileOpen?: (path: string) => boolean | undefined
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])
@@ -1396,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)
@@ -1428,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}
+2 -2
View File
@@ -86,12 +86,12 @@ export const settings: Setting[] = [
keywords: ["syntax", "concealment", "rendering"],
},
{
title: "Grouping",
title: "Tool grouping",
category: "Session",
path: ["session", "grouping"],
default: "auto",
values: ["none", "auto"],
keywords: ["transcript", "messages"],
keywords: ["transcript", "messages", "reads", "searches"],
},
{
title: "Transcript images",
+9
View File
@@ -75,6 +75,15 @@ test("shows the TPS default in session settings", () => {
expect(setting?.default).toBe(true)
})
test("names tool grouping explicitly in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "session.grouping")).toMatchObject({
title: "Tool grouping",
category: "Session",
default: "auto",
values: ["none", "auto"],
})
})
test("validates terminal copy behavior", () => {
expect(decodeInfo({ terminal: { copy: "manual" } })).toEqual({ terminal: { copy: "manual" } })
expect(decodeInfo({ terminal: { copy: "select" } })).toEqual({ terminal: { copy: "select" } })
+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",