mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 07:26:20 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d16dae82c9 | ||
|
|
5ebb2c895e | ||
|
|
e602a5ceff | ||
|
|
d626d17d39 | ||
|
|
4ece670a92 | ||
|
|
0d830ea214 | ||
|
|
0b47c24d4e | ||
|
|
4ed1f073e0 | ||
|
|
a4574fff0a | ||
|
|
4680a4aa6f | ||
|
|
88e4ab5735 |
@@ -358,6 +358,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -605,6 +606,21 @@
|
||||
"solid-js",
|
||||
],
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode-ai/plugin-browser",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/posts": {
|
||||
"name": "@opencode-ai/posts",
|
||||
"dependencies": {
|
||||
@@ -674,6 +690,7 @@
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -2143,6 +2160,8 @@
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
"@opencode-ai/plugin-browser": ["@opencode-ai/plugin-browser@workspace:packages/plugin-browser"],
|
||||
|
||||
"@opencode-ai/posts": ["@opencode-ai/posts@workspace:packages/posts"],
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
@@ -122,7 +123,7 @@ export async function setupTimeline(
|
||||
messages?: TimelineMessage[]
|
||||
sessionMessages?: SessionMessageInfo[]
|
||||
sessionStatus?: Record<string, SessionStatus>
|
||||
settings?: Record<string, boolean>
|
||||
settings?: Record<string, boolean | TimelineDetail>
|
||||
sessions?: Session[]
|
||||
cpuRate?: number
|
||||
viewport?: { width: number; height: number }
|
||||
|
||||
@@ -67,6 +67,12 @@ test("follows a live session move while the agent catalog is still loading", asy
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" }
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ general: { timelineDetail: { notices: { placement: "separate" } } } }),
|
||||
)
|
||||
})
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
@@ -95,9 +101,7 @@ test("follows a live session move while the agent catalog is still loading", asy
|
||||
await transport.waitForConnection()
|
||||
const resolved = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return (
|
||||
url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
|
||||
)
|
||||
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
|
||||
})
|
||||
session.directory = destination
|
||||
await transport.send({
|
||||
@@ -221,7 +225,8 @@ function recoveryRequests(page: Page) {
|
||||
const requests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path)
|
||||
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path))
|
||||
requests.push(path)
|
||||
})
|
||||
return requests
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
@@ -16,6 +17,9 @@ for (const width of [1400, 390]) {
|
||||
`\u0645\u0631\u0627\u062c\u0639\u0629 ${command}--reviewed`,
|
||||
]
|
||||
await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
|
||||
},
|
||||
locale: profile.locale,
|
||||
viewport: { width, height: 900 },
|
||||
sessionMessages: [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import {
|
||||
assistantMessage,
|
||||
@@ -36,7 +37,9 @@ test("renders a completed single-file patch", async ({ page }) => {
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
@@ -68,7 +71,9 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
|
||||
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
|
||||
await setupTimeline(page, {
|
||||
settings: { editToolPartsExpanded: true },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
messages: [
|
||||
userMessage([userText("Preceding context ".repeat(120))]),
|
||||
assistantMessage([
|
||||
@@ -100,6 +105,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
const row = page.locator("[data-timeline-key]", { has: wrapper })
|
||||
const trigger = wrapper.getByRole("button")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect
|
||||
.poll(() =>
|
||||
row.evaluate((element) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
@@ -23,20 +24,23 @@ for (const expanded of [false, true]) {
|
||||
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
|
||||
const id = `prt_shell_default_${expanded}`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
|
||||
settings: { shellToolPartsExpanded: expanded },
|
||||
messages: [userMessage(), assistantMessage([shell(id, "running", lines(3))], { completed: false })],
|
||||
settings: {
|
||||
timelineDetail: {
|
||||
...timelinePresets[2].value,
|
||||
shell: { placement: "separate", details: expanded ? "expanded" : "collapsed" },
|
||||
},
|
||||
},
|
||||
})
|
||||
const trigger = expanded
|
||||
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
|
||||
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
|
||||
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
|
||||
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 250)
|
||||
await timeline.send(partUpdated(shell(id, "completed", lines(6))))
|
||||
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")))
|
||||
await timeline.send(status("idle"))
|
||||
await expect(page.getByText("Sibling content", { exact: true })).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
})
|
||||
}
|
||||
@@ -46,6 +50,9 @@ test("transitions a streaming shell from writing through command execution", asy
|
||||
const command = "printf ready"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
|
||||
|
||||
@@ -95,7 +102,9 @@ test("shimmers and expands a running shell command", async ({ page }) => {
|
||||
const command = "sleep 10 && echo done"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
|
||||
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
@@ -110,7 +119,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
|
||||
})
|
||||
|
||||
for (const open of [false, true]) {
|
||||
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
|
||||
test(`keeps ${open ? "expanded" : "collapsed"} Separate reasoning intent through shell completion`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const reasoningID = `prt_reasoning_hidden_${open}`
|
||||
@@ -118,7 +127,13 @@ for (const open of [false, true]) {
|
||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { showReasoningSummaries: false },
|
||||
settings: {
|
||||
timelineDetail: {
|
||||
...timelinePresets[2].value,
|
||||
thinking: { placement: "separate", details: "collapsed" },
|
||||
shell: { placement: "separate", details: "collapsed" },
|
||||
},
|
||||
},
|
||||
cpuRate: 4,
|
||||
})
|
||||
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
|
||||
@@ -141,28 +156,11 @@ for (const open of [false, true]) {
|
||||
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 1 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(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
|
||||
await expect(group).toHaveCount(0)
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -172,6 +170,9 @@ for (const transition of ["reasoning-end", "idle", "retry"] as const) {
|
||||
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 })],
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
|
||||
const trigger = part.locator('[data-slot="collapsible-trigger"]')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import {
|
||||
compactionDelta,
|
||||
compactionEnded,
|
||||
@@ -49,6 +50,9 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
ownerWarnings.push(message.text())
|
||||
})
|
||||
await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
|
||||
},
|
||||
sessionMessages: [
|
||||
user,
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
||||
@@ -84,7 +88,12 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
|
||||
},
|
||||
sessionMessages: [user, assistant(true)],
|
||||
})
|
||||
|
||||
await timeline.send(
|
||||
compactionStarted({
|
||||
@@ -161,7 +170,12 @@ test("updates running compactions to failed and cancelled boundaries", async ({
|
||||
})
|
||||
|
||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
|
||||
await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
|
||||
},
|
||||
sessionMessages: [user, assistant(false, true)],
|
||||
})
|
||||
const card = page.locator('[data-component="task-tool-card"]')
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toContainText("Inspect code")
|
||||
@@ -198,6 +212,9 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
||||
const childID = "ses_running_child"
|
||||
await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
|
||||
},
|
||||
sessionMessages: [user, assistant(false, true, childID)],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
|
||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
||||
@@ -213,6 +230,7 @@ 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, {
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
@@ -281,6 +299,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
const backgroundID = "ses_background_existing"
|
||||
const blockingID = "ses_background_blocking"
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
@@ -369,6 +388,13 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
})
|
||||
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
|
||||
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
|
||||
const used = page
|
||||
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
|
||||
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(used).toHaveText(/^Used\s*2 Agent, 1 Shell$/)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
const summary = page.getByRole("button", { name: "2 items running in background" })
|
||||
await expect(summary).toContainText("2")
|
||||
@@ -376,6 +402,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
const list = page.locator('[data-component="session-background-list"]')
|
||||
await expect(list).toContainText("Background task")
|
||||
await expect(list).toContainText("sleep 120")
|
||||
await expect(list).not.toContainText("Foreground task")
|
||||
await expect(backgroundCard).toContainText("Background task (background)")
|
||||
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
await expect(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
@@ -14,7 +15,9 @@ test.describe("session timeline projection", () => {
|
||||
const first = "prt_patch_first"
|
||||
const second = "prt_patch_second"
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { editToolPartsExpanded: true },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
@@ -34,6 +37,7 @@ test.describe("session timeline projection", () => {
|
||||
const initial = page.locator(`[data-timeline-part-id="${first}"]`)
|
||||
const initialFile = initial.locator('[data-scope="apply-patch"] [data-type="update"]')
|
||||
await expect(initialFile).toBeVisible()
|
||||
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await initialFile.getByRole("button").click()
|
||||
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await initial.evaluate((element) => {
|
||||
@@ -110,8 +114,11 @@ test.describe("session timeline projection", () => {
|
||||
parentID: "msg_2000_second_user",
|
||||
created: 1700000006000,
|
||||
})
|
||||
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
|
||||
await timeline.send(status("idle"), 100)
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
messages: [firstUser, aborted, failed, nextUser, nextAssistant],
|
||||
})
|
||||
await timeline.send(status("idle"))
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
@@ -127,6 +134,7 @@ test.describe("session timeline projection", () => {
|
||||
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
|
||||
await setupTimeline(page, {
|
||||
viewport: { width: 420, height: 700 },
|
||||
settings: { timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } } },
|
||||
sessionMessages: [
|
||||
{
|
||||
id: "msg_model_fast_nano",
|
||||
|
||||
@@ -5,134 +5,48 @@ import {
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
|
||||
test("changes timeline presets and saves custom thinking details", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
reasoningPart(
|
||||
"prt_reasoning_settings",
|
||||
"## Inspecting stability\n\nThe selected mode controls these details.",
|
||||
),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
assistantMessage([
|
||||
reasoningPart("prt_reasoning_settings", "## Inspecting stability\n\nThe selected mode controls these details."),
|
||||
]),
|
||||
],
|
||||
})
|
||||
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).toHaveText(/^Used\s*1 Skill$/)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
|
||||
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
|
||||
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")
|
||||
})
|
||||
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
|
||||
await expect(slider).toBeEnabled()
|
||||
await slider.press("Home")
|
||||
for (const [index, name] of ["Everything", "Detailed", "Compact", "Quiet", "Text only"].entries()) {
|
||||
if (index) await slider.press("ArrowRight")
|
||||
await expect(slider).toHaveValue(String(index))
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", name)
|
||||
}
|
||||
}
|
||||
await slider.press("Home")
|
||||
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Thinking Placement Separate", exact: true }).click()
|
||||
await page.getByRole("option", { name: "Grouped", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Thinking Details Expanded", exact: true }).click()
|
||||
await page.getByRole("option", { name: "Collapsed", exact: true }).click()
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.timelineDetail?.thinking),
|
||||
)
|
||||
.toEqual({ placement: "grouped", details: "collapsed" })
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await page.getByRole("button", { name: "Reasoning", exact: true }).click()
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await part.getByRole("button").click()
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
|
||||
@@ -18,10 +18,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa
|
||||
const textID = "prt_event_order_text"
|
||||
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 100)
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
|
||||
await timeline.send(status("busy"))
|
||||
await timeline.send(status("idle"))
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")))
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
|
||||
@@ -138,7 +138,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
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 }) => {
|
||||
test("keeps failed search calls and their error cards outside the collapsed stack", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart(
|
||||
"prt_error_glob",
|
||||
@@ -161,12 +161,9 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
|
||||
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Glob, 1 Grep")
|
||||
await summary.click()
|
||||
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
|
||||
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
|
||||
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
|
||||
const glob = page.locator('[data-timeline-part-id="prt_error_glob"]')
|
||||
await expect(glob).toContainText("Invalid tool input")
|
||||
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
|
||||
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
|
||||
@@ -180,7 +177,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
.evaluate((element) => getComputedStyle(element, "::before").display),
|
||||
)
|
||||
.toBe("none")
|
||||
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
|
||||
await expect(page.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
|
||||
"Search timed out after 30 seconds",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
@@ -74,21 +75,29 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [file] } }),
|
||||
toolPart(
|
||||
patchID,
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update src/outline.ts" },
|
||||
{ metadata: { files: [file] } },
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
reducedMotion: true,
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
|
||||
const row = page.locator("[data-timeline-key]", { has: part })
|
||||
await expect(card).toBeVisible()
|
||||
await timeline.settle()
|
||||
await expect(card.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
const geometry = await row.evaluate((element) => {
|
||||
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
|
||||
@@ -106,8 +115,6 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
|
||||
cardHeight: cardRect.height,
|
||||
}
|
||||
})
|
||||
await timeline.settle()
|
||||
|
||||
expect(geometry.overflow).toBeCloseTo(0.49, 1)
|
||||
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
|
||||
const edges = await captureCardEdges(page, card)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
@@ -11,7 +12,9 @@ test("transitions shell and question through running error outcomes", async ({ p
|
||||
const shellID = "prt_transition_error_shell"
|
||||
const questionID = "prt_transition_error_question"
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "expanded" } },
|
||||
},
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
@@ -24,18 +27,17 @@ test("transitions shell and question through running error outcomes", async ({ p
|
||||
],
|
||||
})
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
|
||||
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
|
||||
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })))
|
||||
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toContainText("exit 1")
|
||||
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())))
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
|
||||
180,
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
|
||||
),
|
||||
250,
|
||||
)
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
|
||||
@@ -46,7 +48,9 @@ test("preserves surviving grouped patch state when its first patch fails", async
|
||||
const failed = "prt_grouped_patch_failed"
|
||||
const surviving = "prt_grouped_patch_surviving"
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { editToolPartsExpanded: true },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
@@ -80,6 +84,7 @@ test("preserves surviving grouped patch state when its first patch fails", async
|
||||
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
|
||||
const file = group.locator('[data-scope="apply-patch"] button')
|
||||
await expect(file).toBeVisible()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "false")
|
||||
await file.click()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "true")
|
||||
await group.evaluate((element) => {
|
||||
@@ -116,6 +121,7 @@ test("preserves surviving grouped patch state when its first patch fails", async
|
||||
test("groups instruction files loaded by the same read", async ({ page }) => {
|
||||
const id = "prt_read_instructions"
|
||||
await setupTimeline(page, {
|
||||
settings: { timelineDetail: { ...timelinePresets[2].value, tools: { placement: "separate" } } },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
@@ -148,7 +154,10 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
|
||||
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
|
||||
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
await setupTimeline(page, {
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
messages: [userMessage(), assistantMessage(parts)],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${parts.map((part) => part.id).join(",")}"]`)
|
||||
await group.getByRole("button").click()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
@@ -19,6 +20,9 @@ for (const width of [1400, 390]) {
|
||||
messages: [userMessage()],
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
viewport: { width, height: 900 },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toHaveCount(1)
|
||||
@@ -51,7 +55,9 @@ for (const name of ["shell", "patch", "subagent"] as const) {
|
||||
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[0].value, shell: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toBeVisible()
|
||||
@@ -89,13 +95,8 @@ for (const name of ["shell", "patch", "subagent"] as const) {
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata })))
|
||||
if (name === "shell") {
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(
|
||||
group.getByRole("button", { name: "Used 1 Shell", exact: true, includeHidden: true }),
|
||||
).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(group).toBeVisible()
|
||||
}
|
||||
await expect(tool).toBeVisible()
|
||||
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
||||
await expect(working).toBeVisible()
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
@@ -259,6 +260,7 @@ for (const failed of [false, true]) {
|
||||
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Check the working indicator immediately.")
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
const requested = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
|
||||
@@ -951,6 +951,41 @@ export const dict = {
|
||||
"settings.general.section.feed": "Feed",
|
||||
"settings.general.section.display": "Display",
|
||||
|
||||
"settings.timeline.title": "Timeline",
|
||||
"settings.timeline.detail": "Timeline detail",
|
||||
"settings.timeline.description": "Choose how much activity appears in the timeline. Messages stay visible.",
|
||||
"settings.timeline.preset.everything": "Everything",
|
||||
"settings.timeline.preset.detailed": "Detailed",
|
||||
"settings.timeline.preset.compact": "Compact",
|
||||
"settings.timeline.preset.quiet": "Quiet",
|
||||
"settings.timeline.preset.text-only": "Text only",
|
||||
"settings.timeline.description.everything": "Show all activity separately. Expand shell output, edits, and thinking.",
|
||||
"settings.timeline.description.detailed":
|
||||
"Expand shell output and edits. Show subagents separately and group other activity in Used.",
|
||||
"settings.timeline.description.compact": "Group all activity in Used with details collapsed.",
|
||||
"settings.timeline.description.quiet": "Group edits and subagents in Used. Hide other activity.",
|
||||
"settings.timeline.description.text-only": "Hide all activity. Show only messages.",
|
||||
"settings.timeline.description.custom": "Use your selected placement and details for each activity category.",
|
||||
"settings.timeline.custom": "Custom",
|
||||
"settings.timeline.advanced": "Advanced",
|
||||
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
|
||||
"settings.timeline.advanced.explainer": "Grouped activity goes into Used. Details applies after opening the group.",
|
||||
"settings.timeline.activity": "Activity",
|
||||
"settings.timeline.category.shell": "Shell",
|
||||
"settings.timeline.category.edit": "Edits",
|
||||
"settings.timeline.category.thinking": "Thinking",
|
||||
"settings.timeline.category.subagents": "Subagents",
|
||||
"settings.timeline.category.notices": "Notices",
|
||||
"settings.timeline.category.tools": "Other tools",
|
||||
"settings.timeline.placement.title": "Placement",
|
||||
"settings.timeline.placement.separate": "Separate",
|
||||
"settings.timeline.placement.grouped": "Grouped",
|
||||
"settings.timeline.placement.hidden": "Hidden",
|
||||
"settings.timeline.expansion.title": "Details",
|
||||
"settings.timeline.expansion.collapsed": "Collapsed",
|
||||
"settings.timeline.expansion.expanded": "Expanded",
|
||||
"settings.timeline.running": "Running",
|
||||
|
||||
"settings.general.row.language.title": "Language",
|
||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
||||
"settings.general.row.shell.title": "Terminal shell",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/p
|
||||
import { createRoot } from "solid-js"
|
||||
import { applyTimelineMessageHandoff, visibleTimelineMessages } from "./controller-projection"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
|
||||
const messages = [
|
||||
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
|
||||
@@ -63,6 +64,7 @@ describe("visibleTimelineMessages", () => {
|
||||
reasoningMode: () => "compact",
|
||||
shellToolDefaultOpen: () => false,
|
||||
editToolDefaultOpen: () => false,
|
||||
timelineDetail: () => timelinePresets[2].value,
|
||||
pendingUserMessageIDs: () => new Set([steer.id]),
|
||||
})
|
||||
expect(projection.activeMessageID()).toBe("msg_1")
|
||||
|
||||
@@ -21,6 +21,7 @@ import { applyTimelineMessageHandoff, timelineChildTitle, visibleTimelineMessage
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { getSessionMessageHandoff } from "@/session/handoff"
|
||||
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
|
||||
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
|
||||
@@ -101,12 +102,32 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
})
|
||||
})
|
||||
const showHeader = createMemo(() => !!input.session.identity.sessionID())
|
||||
const timelineDetail = createMemo(() => {
|
||||
const detail = settings.general.timelineDetail()
|
||||
return {
|
||||
shell: { ...detail.shell },
|
||||
edit: { ...detail.edit },
|
||||
thinking: { ...detail.thinking },
|
||||
subagents: { ...detail.subagents },
|
||||
notices: { ...detail.notices },
|
||||
tools: { ...detail.tools },
|
||||
}
|
||||
})
|
||||
const reasoningMode = (): ReasoningMode =>
|
||||
timelineDetail().thinking.placement === "hidden"
|
||||
? "hidden"
|
||||
: timelineDetail().thinking.details === "expanded"
|
||||
? "full"
|
||||
: "compact"
|
||||
const shellToolPartsExpanded = () => timelineDetail().shell.details === "expanded"
|
||||
const editToolPartsExpanded = () => timelineDetail().edit.details === "expanded"
|
||||
const projection = createTimelineProjection({
|
||||
sessionMessages: projectedMessages,
|
||||
status: input.session.data.status,
|
||||
reasoningMode: settings.general.reasoningMode,
|
||||
shellToolDefaultOpen: settings.general.shellToolPartsExpanded,
|
||||
editToolDefaultOpen: settings.general.editToolPartsExpanded,
|
||||
reasoningMode,
|
||||
shellToolDefaultOpen: shellToolPartsExpanded,
|
||||
editToolDefaultOpen: editToolPartsExpanded,
|
||||
timelineDetail,
|
||||
pendingUserMessageIDs,
|
||||
})
|
||||
const [pending, setPending] = createStore({ rename: false })
|
||||
@@ -235,9 +256,10 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
childTitle,
|
||||
showHeader,
|
||||
projection,
|
||||
reasoningMode: settings.general.reasoningMode,
|
||||
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
|
||||
editToolPartsExpanded: settings.general.editToolPartsExpanded,
|
||||
timelineDetail,
|
||||
reasoningMode,
|
||||
shellToolPartsExpanded,
|
||||
editToolPartsExpanded,
|
||||
},
|
||||
pending: {
|
||||
rename: () => pending.rename,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
|
||||
|
||||
type BackgroundTask = {
|
||||
id: string
|
||||
@@ -420,6 +421,7 @@ function MessageTimelineView(
|
||||
const messageByID = projection.messageByID
|
||||
const virtualized = createTimelineVirtualizer({
|
||||
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
|
||||
presentationKey: () => JSON.stringify(props.data.timelineDetail()),
|
||||
projection,
|
||||
showHeader,
|
||||
pinned,
|
||||
@@ -527,6 +529,7 @@ function MessageTimelineView(
|
||||
reasoningMode: props.data.reasoningMode,
|
||||
shellToolDefaultOpen: props.data.shellToolPartsExpanded,
|
||||
editToolDefaultOpen: props.data.editToolPartsExpanded,
|
||||
timelineDetail: props.data.timelineDetail,
|
||||
disclosure: virtualized.disclosure,
|
||||
centered: () => props.centered,
|
||||
padding: turnPadding,
|
||||
@@ -632,6 +635,28 @@ function MessageTimelineView(
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
!showWorking() &&
|
||||
!backgroundHintPresence.present() &&
|
||||
props.background
|
||||
.tasks()
|
||||
.some(
|
||||
(task) =>
|
||||
props.data.timelineDetail()[task.type === "subagent" ? "subagents" : "shell"].placement !==
|
||||
"separate",
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
role="status"
|
||||
class={`flex h-9 w-full min-w-0 items-center gap-2 pt-3 text-13-regular text-v2-text-text-muted ${turnPadding()}`}
|
||||
classList={{ "md:max-w-[1000px] md:mx-auto": props.centered }}
|
||||
>
|
||||
<SessionProgressIndicatorV2 />
|
||||
<span>{language.t("settings.timeline.running")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
deferred={(row) => {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import type { createTimelineProjection } from "./projection"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
@@ -33,7 +33,15 @@ const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
|
||||
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
|
||||
const endEpsilon = 0.5
|
||||
const upwardKeys = new Set(["up", "page-up", "home"])
|
||||
const cache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
|
||||
const cache = new Map<
|
||||
string,
|
||||
{
|
||||
measurements: VirtualItem[]
|
||||
toolOpen: Record<string, boolean | undefined>
|
||||
patchGroupKeys: Map<string, string>
|
||||
presentationKey?: string
|
||||
}
|
||||
>()
|
||||
|
||||
type Projection = Pick<
|
||||
ReturnType<typeof createTimelineProjection>,
|
||||
@@ -42,6 +50,7 @@ type Projection = Pick<
|
||||
|
||||
type Input = {
|
||||
sessionKey: Accessor<string>
|
||||
presentationKey?: Accessor<string>
|
||||
projection: Projection
|
||||
showHeader: Accessor<boolean>
|
||||
/** True while the timeline follows the newest content. Drives every anchoring decision. */
|
||||
@@ -77,11 +86,20 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
|
||||
const ownerSessionKey = input.sessionKey()
|
||||
const cached = cache.get(ownerSessionKey)
|
||||
const entry = cache.get(ownerSessionKey)
|
||||
const cached = entry?.presentationKey === input.presentationKey?.() ? entry : undefined
|
||||
const initialMeasurements = cached?.measurements
|
||||
const coldBottomMount = !initialMeasurements?.length && input.pinned()
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
|
||||
const patchGroupKeys = cached?.patchGroupKeys ?? new Map<string, string>()
|
||||
createEffect(
|
||||
on(
|
||||
() => input.presentationKey?.(),
|
||||
() => setToolOpen(reconcile({})),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
|
||||
const rows = input.projection.rows
|
||||
const rowByKey = input.projection.rowByKey
|
||||
@@ -189,7 +207,8 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
},
|
||||
scrollEndThreshold: 80,
|
||||
get scrollMargin() {
|
||||
return topOffset()
|
||||
// Empty projections still need the bottom spacer for running status.
|
||||
return rows().length > 0 ? topOffset() : 0
|
||||
},
|
||||
paddingEnd: 64,
|
||||
get rangeExtractor() {
|
||||
@@ -540,15 +559,13 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}}
|
||||
>
|
||||
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
|
||||
<Show when={rows().length > 0}>
|
||||
<div
|
||||
data-timeline-row="bottom-spacer"
|
||||
class="h-16 absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
|
||||
>
|
||||
{props.bottomSpacer}
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
data-timeline-row="bottom-spacer"
|
||||
class="h-16 absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
|
||||
>
|
||||
{props.bottomSpacer}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
@@ -557,7 +574,12 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
|
||||
onCleanup(() => {
|
||||
cache.delete(ownerSessionKey)
|
||||
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
|
||||
cache.set(ownerSessionKey, {
|
||||
measurements: virtualizer.takeSnapshot(),
|
||||
toolOpen: { ...toolOpen },
|
||||
patchGroupKeys,
|
||||
presentationKey: input.presentationKey?.(),
|
||||
})
|
||||
while (cache.size > 16) cache.delete(cache.keys().next().value!)
|
||||
coldPending = false
|
||||
contentObserver?.disconnect()
|
||||
@@ -569,6 +591,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
|
||||
return {
|
||||
disclosure: {
|
||||
patchGroupKeys,
|
||||
value: (key: string) => toolOpen[key],
|
||||
set: (key: string, open: boolean) => setToolOpen(key, open),
|
||||
},
|
||||
|
||||
@@ -4,7 +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 { TimelineDetailControl } from "@/settings/timeline-detail"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useUpdaterAction } from "@/shell/updates/action"
|
||||
@@ -184,34 +184,6 @@ 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 (
|
||||
@@ -360,8 +332,6 @@ export const SettingsGeneral: Component<{
|
||||
<TerminalPlacementSetting />
|
||||
<FollowUpBehaviorSetting />
|
||||
|
||||
<ReasoningModeSetting />
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("session.review.wrapLines")}
|
||||
description={language.t("settings.general.row.mobileDiffWrap.description")}
|
||||
@@ -378,30 +348,6 @@ export const SettingsGeneral: Component<{
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showProjectIcon.title")}
|
||||
@@ -585,6 +531,18 @@ export const SettingsGeneral: Component<{
|
||||
<div class="settings-tab-body">
|
||||
<GeneralSection />
|
||||
|
||||
<section class="settings-section" aria-label={language.t("settings.timeline.title")}>
|
||||
<h3 class="settings-section-title">{language.t("settings.timeline.title")}</h3>
|
||||
<SettingsList>
|
||||
<div class="py-5">
|
||||
<TimelineDetailControl
|
||||
value={settings.general.timelineDetail()}
|
||||
onChange={settings.general.setTimelineDetail}
|
||||
/>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</section>
|
||||
|
||||
<Show when={desktop()}>
|
||||
<UpdatesSection />
|
||||
</Show>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import {
|
||||
settingsSchema,
|
||||
@@ -16,54 +17,19 @@ const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
const encode = Schema.encodeSync(schema)
|
||||
|
||||
describe("settings reasoning mode migration", () => {
|
||||
test.each([
|
||||
[true, "full"],
|
||||
[false, "compact"],
|
||||
] as const)("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
|
||||
const value = { general: { showReasoningSummaries, showTerminal: true }, appearance: { fontSize: 16 } }
|
||||
const settings = decode(value)
|
||||
expect(settings.general.reasoningMode).toBe(reasoningMode)
|
||||
expect(settings.general.showTerminal).toBe(true)
|
||||
describe("settings timeline detail migration", () => {
|
||||
test("migrates saved switches and round trips the current settings", () => {
|
||||
const settings = decode({
|
||||
general: { shellToolPartsExpanded: true, editToolPartsExpanded: false, showReasoningSummaries: true },
|
||||
appearance: { fontSize: 16 },
|
||||
})
|
||||
expect(settings.general.timelineDetail).toEqual({
|
||||
...timelinePresets[2].value,
|
||||
shell: { placement: "separate", details: "expanded" },
|
||||
thinking: { placement: "separate", details: "expanded" },
|
||||
})
|
||||
expect(settings.appearance.fontSize).toBe(16)
|
||||
expect(settings.general).not.toHaveProperty("showReasoningSummaries")
|
||||
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(decode(value).general.reasoningMode).toBe(reasoningMode)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test.each([undefined, null, {}, { showReasoningSummaries: "true" }])(
|
||||
"defaults invalid or absent legacy settings: %j",
|
||||
(general) => {
|
||||
expect(decode({ general }).general.reasoningMode).toBe("compact")
|
||||
},
|
||||
)
|
||||
|
||||
test("migrates an undefined current mode but defaults an invalid current mode", () => {
|
||||
expect(decode({ general: { reasoningMode: undefined, showReasoningSummaries: true } }).general.reasoningMode).toBe(
|
||||
"full",
|
||||
)
|
||||
expect(decode({ general: { reasoningMode: "invalid", showReasoningSummaries: true } }).general.reasoningMode).toBe(
|
||||
"compact",
|
||||
)
|
||||
})
|
||||
|
||||
test("encodes only the current format and round trips migrated settings", () => {
|
||||
const settings = decode({ general: { showReasoningSummaries: true, obsolete: true }, obsolete: true })
|
||||
const encoded = encode(settings)
|
||||
expect(encoded).toEqual(settings)
|
||||
expect(encoded).not.toHaveProperty("obsolete")
|
||||
expect(encoded).not.toHaveProperty("general.obsolete")
|
||||
expect(encoded).not.toHaveProperty("general.showReasoningSummaries")
|
||||
expect(decode(encoded)).toEqual(settings)
|
||||
expect(decode(encode(settings))).toEqual(settings)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,13 +37,16 @@ describe("settings schema", () => {
|
||||
test("uses the supplied initial values independently of the current schema", () => {
|
||||
const initial = {
|
||||
...defaultSettings,
|
||||
general: { ...defaultSettings.general, reasoningMode: "hidden" as const, autoSave: false },
|
||||
general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, autoSave: false },
|
||||
appearance: { ...defaultSettings.appearance, fontSize: 20 },
|
||||
}
|
||||
const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial))
|
||||
expect(restore({})).toEqual(initial)
|
||||
expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial)
|
||||
expect(restore({ general: { showReasoningSummaries: true } }).general.reasoningMode).toBe("full")
|
||||
expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({
|
||||
placement: "separate",
|
||||
details: "expanded",
|
||||
})
|
||||
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
|
||||
})
|
||||
|
||||
@@ -92,9 +61,7 @@ describe("settings schema", () => {
|
||||
showStatus: false,
|
||||
showProjectIcon: false,
|
||||
showTerminal: false,
|
||||
reasoningMode: "compact",
|
||||
shellToolPartsExpanded: false,
|
||||
editToolPartsExpanded: false,
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
showCustomAgents: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
mobileDiffWrap: true,
|
||||
@@ -144,7 +111,7 @@ describe("settings schema", () => {
|
||||
showTerminal: true,
|
||||
autoSave: false,
|
||||
releaseNotes: true,
|
||||
reasoningMode: "compact",
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
followUpBehavior: "steer",
|
||||
})
|
||||
expect(settings.appearance).toEqual({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { reconcile } from "solid-js/store"
|
||||
import { reconcile, unwrap } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { timelinePresets, type TimelineCategory, type TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
@@ -68,7 +68,10 @@ export function terminalFontFamily(font: string | undefined) {
|
||||
return stack(font, terminalBase)
|
||||
}
|
||||
|
||||
const reasoningModeSchema = Schema.Literals(["hidden", "compact", "full"])
|
||||
const placementSchema = Schema.Literals(["separate", "grouped", "hidden"])
|
||||
const detailsSchema = Schema.Literals(["collapsed", "expanded"])
|
||||
const activitySchema = Persistence.struct({ placement: placementSchema, details: detailsSchema })
|
||||
const placementOnlySchema = Persistence.struct({ placement: placementSchema })
|
||||
|
||||
const generalSchema = Persistence.struct({
|
||||
autoSave: Schema.Boolean,
|
||||
@@ -79,9 +82,14 @@ const generalSchema = Persistence.struct({
|
||||
showStatus: Schema.Boolean,
|
||||
showProjectIcon: Schema.Boolean,
|
||||
showTerminal: Schema.Boolean,
|
||||
reasoningMode: reasoningModeSchema,
|
||||
shellToolPartsExpanded: Schema.Boolean,
|
||||
editToolPartsExpanded: Schema.Boolean,
|
||||
timelineDetail: Persistence.struct({
|
||||
shell: activitySchema,
|
||||
edit: activitySchema,
|
||||
thinking: activitySchema,
|
||||
subagents: placementOnlySchema,
|
||||
notices: placementOnlySchema,
|
||||
tools: placementOnlySchema,
|
||||
}),
|
||||
showCustomAgents: Schema.Boolean,
|
||||
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
|
||||
mobileDiffWrap: Schema.Boolean,
|
||||
@@ -134,25 +142,91 @@ export const settingsSchema = Persistence.struct({
|
||||
sounds: soundsSchema,
|
||||
})
|
||||
|
||||
function storedTimelineCategory(category: TimelineCategory) {
|
||||
return Persistence.optional(
|
||||
Schema.Union([
|
||||
Schema.Struct({
|
||||
placement: Persistence.optional(placementSchema),
|
||||
details: Persistence.optional(detailsSchema),
|
||||
}),
|
||||
Schema.Literals(["expanded", "collapsed", "hidden", "visible"]),
|
||||
]).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
if (typeof value !== "string") return value
|
||||
return {
|
||||
placement:
|
||||
value === "hidden"
|
||||
? "hidden"
|
||||
: category === "subagents"
|
||||
? "separate"
|
||||
: category === "tools"
|
||||
? "grouped"
|
||||
: value === "expanded"
|
||||
? "separate"
|
||||
: value === "collapsed"
|
||||
? "grouped"
|
||||
: undefined,
|
||||
details: value === "expanded" ? "expanded" : "collapsed",
|
||||
}
|
||||
}),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function legacyTimelineActivity(value: boolean | "hidden" | "compact" | "full" | null | undefined) {
|
||||
if (value === undefined || value === null) return
|
||||
const expanded = value === true || value === "full"
|
||||
return {
|
||||
placement: value === "hidden" ? "hidden" : expanded ? "separate" : "grouped",
|
||||
details: expanded ? "expanded" : "collapsed",
|
||||
} as const
|
||||
}
|
||||
|
||||
export const settingsPersistence = Persistence.migrate(
|
||||
settingsSchema,
|
||||
Schema.Struct({
|
||||
general: Persistence.optional(
|
||||
Schema.Struct({
|
||||
reasoningMode: Schema.optional(Schema.Unknown),
|
||||
// Keep invalid explicit values distinct from absent values so legacy preferences cannot replace them.
|
||||
timelineDetail: Schema.optional(
|
||||
Schema.NullOr(
|
||||
Schema.Struct({
|
||||
shell: storedTimelineCategory("shell"),
|
||||
edit: storedTimelineCategory("edit"),
|
||||
thinking: storedTimelineCategory("thinking"),
|
||||
subagents: storedTimelineCategory("subagents"),
|
||||
notices: storedTimelineCategory("notices"),
|
||||
tools: storedTimelineCategory("tools"),
|
||||
}),
|
||||
),
|
||||
).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(null)))),
|
||||
reasoningMode: Schema.optional(Schema.NullOr(Schema.Literals(["hidden", "compact", "full"]))).pipe(
|
||||
Schema.catchDecoding(() => Effect.succeed(Option.some(null))),
|
||||
),
|
||||
showReasoningSummaries: Persistence.optional(Schema.Boolean),
|
||||
shellToolPartsExpanded: Persistence.optional(Schema.Boolean),
|
||||
editToolPartsExpanded: Persistence.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
}).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
if (value.general?.reasoningMode !== undefined || value.general?.showReasoningSummaries === undefined)
|
||||
return value
|
||||
const general = value.general
|
||||
if (!general || general.timelineDetail !== undefined) return value
|
||||
return {
|
||||
...value,
|
||||
general: {
|
||||
...value.general,
|
||||
reasoningMode: value.general.showReasoningSummaries ? "full" : "compact",
|
||||
...general,
|
||||
timelineDetail: {
|
||||
shell: legacyTimelineActivity(general.shellToolPartsExpanded),
|
||||
edit: legacyTimelineActivity(general.editToolPartsExpanded),
|
||||
thinking: legacyTimelineActivity(
|
||||
general.reasoningMode === undefined ? general.showReasoningSummaries : general.reasoningMode,
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
@@ -171,9 +245,7 @@ export const defaultSettings: Settings = {
|
||||
showStatus: false,
|
||||
showProjectIcon: false,
|
||||
showTerminal: false,
|
||||
reasoningMode: "compact",
|
||||
shellToolPartsExpanded: false,
|
||||
editToolPartsExpanded: false,
|
||||
timelineDetail: { ...timelinePresets[2].value },
|
||||
showCustomAgents: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
mobileDiffWrap: true,
|
||||
@@ -256,23 +328,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowTerminal(value: boolean) {
|
||||
setStore("general", "showTerminal", value)
|
||||
},
|
||||
reasoningMode: withFallback(() => store.general?.reasoningMode, defaultSettings.general.reasoningMode),
|
||||
setReasoningMode(value: ReasoningMode) {
|
||||
setStore("general", "reasoningMode", value)
|
||||
},
|
||||
shellToolPartsExpanded: withFallback(
|
||||
() => store.general?.shellToolPartsExpanded,
|
||||
defaultSettings.general.shellToolPartsExpanded,
|
||||
),
|
||||
setShellToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "shellToolPartsExpanded", value)
|
||||
},
|
||||
editToolPartsExpanded: withFallback(
|
||||
() => store.general?.editToolPartsExpanded,
|
||||
defaultSettings.general.editToolPartsExpanded,
|
||||
),
|
||||
setEditToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "editToolPartsExpanded", value)
|
||||
timelineDetail: withFallback(() => store.general?.timelineDetail, defaultSettings.general.timelineDetail),
|
||||
setTimelineDetail(value: TimelineDetail) {
|
||||
setStore("general", "timelineDetail", structuredClone(unwrap(value)))
|
||||
},
|
||||
showCustomAgents,
|
||||
setShowCustomAgents(value: boolean) {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
[data-component="timeline-detail-control"] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
letter-spacing: -0.04px;
|
||||
container: timeline-detail / inline-size;
|
||||
|
||||
[data-slot="timeline-detail-heading"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 24px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-current"] {
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-scale"] {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-track"] {
|
||||
position: absolute;
|
||||
inset-inline: 8px;
|
||||
top: 12px;
|
||||
height: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: 2px;
|
||||
background: var(--v2-background-bg-layer-03);
|
||||
pointer-events: none;
|
||||
|
||||
span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--v2-border-border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
appearance: none;
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -6px;
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border-radius: 50%;
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-button-neutral);
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
box-sizing: border-box;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border-radius: 50%;
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-button-neutral);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-advanced"] {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"] {
|
||||
width: fit-content;
|
||||
height: 28px;
|
||||
align-self: flex-start;
|
||||
gap: 6px;
|
||||
font-size: inherit;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: inherit;
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
&:focus-visible [data-slot="collapsible-arrow"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:dir(rtl):not([aria-expanded="true"]) [data-slot="collapsible-arrow-icon"] {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-categories"] {
|
||||
--timeline-detail-columns: minmax(0, 1fr) 100px 108px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-explainer"] {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-field-label"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-columns"],
|
||||
[data-slot="timeline-detail-category"] {
|
||||
display: grid;
|
||||
grid-template-columns: var(--timeline-detail-columns);
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
line-height: var(--line-height-compact);
|
||||
|
||||
> span {
|
||||
min-width: 0;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-columns"] {
|
||||
min-height: 28px;
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
span:not(:first-child) {
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-category"] {
|
||||
min-height: 40px;
|
||||
padding-block: 8px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
[data-component="select-v2-root"],
|
||||
[data-slot="timeline-detail-placement"],
|
||||
[data-slot="timeline-detail-expansion"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="select-v2-root"][data-field] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component="select-v2"][data-appearance="inline"] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container timeline-detail (max-width: 250px) {
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-heading"] {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-current"] {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 8px;
|
||||
padding-block: 12px;
|
||||
|
||||
> span {
|
||||
font-weight: 530;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-field-label"] {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
padding-inline-start: 8px;
|
||||
font-size: 12px;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-expansion"]:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container timeline-detail (max-width: 310px) {
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-categories"] {
|
||||
--timeline-detail-columns: minmax(0, 1fr) 90px 96px;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"],
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
|
||||
column-gap: 4px;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
|
||||
min-height: 48px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { For, Show, createMemo, createUniqueId } from "solid-js"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import {
|
||||
timelineCategories,
|
||||
timelinePreset,
|
||||
timelinePresets,
|
||||
type TimelineDetail,
|
||||
type TimelineExpansion,
|
||||
type TimelinePlacement,
|
||||
} from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import "./timeline-detail.css"
|
||||
|
||||
const placements: TimelinePlacement[] = ["separate", "grouped", "hidden"]
|
||||
const expansions: TimelineExpansion[] = ["collapsed", "expanded"]
|
||||
|
||||
export function TimelineDetailControl(props: { value: TimelineDetail; onChange: (value: TimelineDetail) => void }) {
|
||||
const language = useLanguage()
|
||||
const id = createUniqueId()
|
||||
const preset = createMemo(() => timelinePreset(props.value))
|
||||
const position = () => {
|
||||
const current = preset()
|
||||
return current ? timelinePresets.indexOf(current) : 2
|
||||
}
|
||||
const label = () => {
|
||||
const current = preset()
|
||||
return current ? language.t(`settings.timeline.preset.${current.id}`) : language.t("settings.timeline.custom")
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="timeline-detail-control">
|
||||
<div data-slot="timeline-detail-heading">
|
||||
<label for={`${id}-slider`}>{language.t("settings.timeline.detail")}</label>
|
||||
<span data-slot="timeline-detail-current" aria-live="polite">
|
||||
{label()}
|
||||
</span>
|
||||
</div>
|
||||
<p id={`${id}-description`} class="sr-only">
|
||||
{language.t("settings.timeline.description")}
|
||||
</p>
|
||||
<div data-slot="timeline-detail-scale">
|
||||
<div data-slot="timeline-detail-track" aria-hidden="true">
|
||||
<For each={timelinePresets}>{() => <span />}</For>
|
||||
</div>
|
||||
<input
|
||||
id={`${id}-slider`}
|
||||
data-action="settings-timeline-detail"
|
||||
type="range"
|
||||
min="0"
|
||||
max={timelinePresets.length - 1}
|
||||
step="1"
|
||||
value={position()}
|
||||
aria-valuetext={label()}
|
||||
aria-describedby={`${id}-description ${id}-preset-description`}
|
||||
onInput={(event) => props.onChange({ ...timelinePresets[event.currentTarget.valueAsNumber].value })}
|
||||
/>
|
||||
</div>
|
||||
<p id={`${id}-preset-description`}>{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}</p>
|
||||
<Collapsible variant="ghost" data-slot="timeline-detail-advanced">
|
||||
<Collapsible.Trigger>
|
||||
<span>{language.t("settings.timeline.advanced")}</span>
|
||||
<Collapsible.Arrow />
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div
|
||||
data-slot="timeline-detail-categories"
|
||||
role="group"
|
||||
aria-label={language.t("settings.timeline.advanced.description")}
|
||||
>
|
||||
<p data-slot="timeline-detail-explainer">{language.t("settings.timeline.advanced.explainer")}</p>
|
||||
<div data-slot="timeline-detail-columns">
|
||||
<span>{language.t("settings.timeline.activity")}</span>
|
||||
<span id={`${id}-placement`}>{language.t("settings.timeline.placement.title")}</span>
|
||||
<span id={`${id}-expansion`}>{language.t("settings.timeline.expansion.title")}</span>
|
||||
</div>
|
||||
<For each={timelineCategories}>
|
||||
{(category) => (
|
||||
<div data-slot="timeline-detail-category" role="group" aria-labelledby={`${id}-${category}`}>
|
||||
<span id={`${id}-${category}`}>{language.t(`settings.timeline.category.${category}`)}</span>
|
||||
<div data-slot="timeline-detail-placement">
|
||||
<span data-slot="timeline-detail-field-label" aria-hidden="true">
|
||||
{language.t("settings.timeline.placement.title")}
|
||||
</span>
|
||||
<Select
|
||||
data-category={category}
|
||||
data-field="placement"
|
||||
aria-labelledby={`${id}-${category} ${id}-placement`}
|
||||
options={placements}
|
||||
current={props.value[category].placement}
|
||||
label={(value) => language.t(`settings.timeline.placement.${value}`)}
|
||||
onSelect={(placement) =>
|
||||
placement &&
|
||||
props.onChange({ ...props.value, [category]: { ...props.value[category], placement } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="timeline-detail-expansion">
|
||||
{category === "shell" || category === "edit" || category === "thinking" ? (
|
||||
<Show when={props.value[category].placement !== "hidden"}>
|
||||
<span data-slot="timeline-detail-field-label" aria-hidden="true">
|
||||
{language.t("settings.timeline.expansion.title")}
|
||||
</span>
|
||||
<Select
|
||||
data-category={category}
|
||||
data-field="details"
|
||||
aria-labelledby={`${id}-${category} ${id}-expansion`}
|
||||
options={expansions}
|
||||
current={props.value[category].details}
|
||||
label={(value) => language.t(`settings.timeline.expansion.${value}`)}
|
||||
onSelect={(details) =>
|
||||
details &&
|
||||
props.onChange({ ...props.value, [category]: { ...props.value[category], details } })
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -122,6 +122,7 @@
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
|
||||
@@ -50,16 +50,14 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let requested = 0
|
||||
let stopped = false
|
||||
let active: { path: string; scope: Scope.Closeable } | undefined
|
||||
const reconcile = (ignore: readonly string[]) => {
|
||||
const request = ++requested
|
||||
return lock.withPermit(
|
||||
const reconcile = () =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (stopped || request !== requested) return
|
||||
if (stopped) return
|
||||
const resolved = yield* target
|
||||
if (stopped || request !== requested) return
|
||||
const ignore = policy.current()
|
||||
const next = resolved && !resolved.aliases.some((alias) => ignore.includes(alias)) ? resolved.path : undefined
|
||||
if (active?.path === next) return
|
||||
if (active) yield* Scope.close(active.scope, Exit.void)
|
||||
@@ -79,12 +77,10 @@ const layer = Layer.effect(
|
||||
)
|
||||
}).pipe(Effect.withSpan("LocationWatcher.reconcile", { attributes: { directory: location.directory } })),
|
||||
)
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
stopped = true
|
||||
requested++
|
||||
if (active) yield* Scope.close(active.scope, Exit.void)
|
||||
active = undefined
|
||||
}),
|
||||
@@ -93,7 +89,7 @@ const layer = Layer.effect(
|
||||
yield* policy.observe(reconcile)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Plugin.awaitActivation
|
||||
yield* reconcile(policy.current())
|
||||
yield* reconcile()
|
||||
}).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
|
||||
@@ -77,6 +77,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
|
||||
import { WellKnown } from "../wellknown.js"
|
||||
import { WriteTool } from "../tool/plugin/write.js"
|
||||
import { AgentPlugin } from "./agent.js"
|
||||
import BrowserPlugin from "@opencode-ai/plugin-browser"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
@@ -188,6 +189,7 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
McpCodeModeExclusionPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
@@ -125,6 +126,7 @@ function provide(
|
||||
watcher?: Layer.Layer<Watcher.Service>,
|
||||
config: Layer.Layer<Config.Service> = configLayer,
|
||||
plugins?: LayerNode.Replacement,
|
||||
replacements: LayerNode.Replacements = [],
|
||||
) {
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -137,6 +139,7 @@ function provide(
|
||||
Location.node.replace(locationLayer),
|
||||
plugins ?? PluginSupervisor.node.replace(Layer.empty),
|
||||
...(watcher ? ([Watcher.node.replace(watcher)] as const) : []),
|
||||
...replacements,
|
||||
],
|
||||
)
|
||||
return Effect.provide(built)
|
||||
@@ -150,6 +153,7 @@ function withTmp<A, E, R>(
|
||||
watcher?: Layer.Layer<Watcher.Service>
|
||||
config?: Layer.Layer<Config.Service>
|
||||
plugins?: LayerNode.Replacement
|
||||
replacements?: LayerNode.Replacements
|
||||
},
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
@@ -172,7 +176,16 @@ function withTmp<A, E, R>(
|
||||
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap(({ tmp, vcs }) =>
|
||||
f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher, options?.config ?? configLayer, options?.plugins)),
|
||||
f(tmp.path, vcs).pipe(
|
||||
provide(
|
||||
tmp.path,
|
||||
vcs,
|
||||
options?.watcher,
|
||||
options?.config ?? configLayer,
|
||||
options?.plugins,
|
||||
options?.replacements,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -293,6 +306,64 @@ describe("LocationWatcher subscriptions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.live("uses the policy changed while target discovery was suspended", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const discovering = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const subscribed = yield* Deferred.make<void>()
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
let released = 0
|
||||
yield* withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
yield* Deferred.await(discovering)
|
||||
const update = yield* policy
|
||||
.transform((editor) => editor.add([".hg"]))
|
||||
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
expect(policy.current()).toEqual([".hg"])
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const registration = yield* Fiber.join(update)
|
||||
expect(subscriptions).toEqual([])
|
||||
|
||||
yield* registration.dispose
|
||||
yield* Deferred.await(subscribed)
|
||||
yield* policy.reload()
|
||||
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
|
||||
expect(released).toBe(0)
|
||||
}),
|
||||
{
|
||||
vcs: "hg",
|
||||
replacements: [
|
||||
Plugin.node.replace(Layer.mock(Plugin.Service, { awaitActivation: Effect.void })),
|
||||
FSUtil.node.replace(
|
||||
Layer.succeed(FSUtil.Service, {
|
||||
...fs,
|
||||
realPath: (target) =>
|
||||
Deferred.succeed(discovering, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.andThen(fs.realPath(target)),
|
||||
),
|
||||
}),
|
||||
),
|
||||
],
|
||||
watcher: Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: (input) =>
|
||||
Effect.sync(() => subscriptions.push(input)).pipe(
|
||||
Effect.andThen(Deferred.succeed(subscribed, undefined)),
|
||||
Effect.as(Stream.never.pipe(Stream.ensuring(Effect.sync(() => released++)))),
|
||||
),
|
||||
}),
|
||||
),
|
||||
},
|
||||
)
|
||||
expect(released).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not start before configured policy is ready", () => {
|
||||
const subscriptions: Watcher.WatchInput[] = []
|
||||
const watcher = Layer.succeed(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Experimental Browser Plugin
|
||||
|
||||
`@opencode-ai/plugin-browser` implements the server-side browser tool using only
|
||||
the public plugin API, public schemas, and Effect. Core registers it as a built-in
|
||||
plugin; the package does not depend on Core or Server. The shared RPC contract is
|
||||
`@opencode-ai/schema/browser`; desktop clients do not import Core.
|
||||
|
||||
The agent calls the browser through Code Mode's `execute` tool, not a separate
|
||||
raw tool. Discover its signature with `search({ query: "browser" })`, then call:
|
||||
|
||||
```js
|
||||
await tools.browser({ type: "open" })
|
||||
await tools.browser({ type: "navigate", url: "https://example.com" })
|
||||
return await tools.browser({ type: "snapshot" })
|
||||
```
|
||||
|
||||
Results retain their untrusted-content wrapper. Screenshots are attached to the
|
||||
`execute` result as images. The tool remains discoverable without a desktop
|
||||
attachment; calls fail with `No desktop browser is connected.` until one connects.
|
||||
Await actions on the same page in order.
|
||||
|
||||
Disable it through normal plugin configuration:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugins": ["-opencode.browser"],
|
||||
}
|
||||
```
|
||||
|
||||
The desktop implementation connects with `client.rpc(Browser.Definition)` at the
|
||||
session's location. Subscribe to server events before calling `attach`; wait for
|
||||
`server.connected`, then the matching `attached` control event. The `attach` call
|
||||
stays pending for the attachment lifetime. Abort it when its event stream ends or
|
||||
the desktop owner closes. Completing the attachment also ends that event consumer.
|
||||
|
||||
- `attach` holds one browser attachment per session until cancellation, plugin
|
||||
unload, session deletion, or session movement.
|
||||
- `state` reports the current page, or `null` when no page is open.
|
||||
- `result` completes a command with its request ID and outcome.
|
||||
- `control` events carry attachment confirmation, commands, and cancellation.
|
||||
|
||||
Control events use OpenCode's existing authenticated, server-wide event feed.
|
||||
Consumers filter by `connectionID`; this identifier is correlation, not private
|
||||
event delivery. State and results use RPC calls rather than broadcast events.
|
||||
|
||||
Per-URL permission checks are deferred to the final permission layer (#46530).
|
||||
Until that layer lands, browser actions do not enforce URL-specific ask or deny
|
||||
rules. Attachment ownership, page validation, and cancellation remain enforced.
|
||||
|
||||
Browser content is untrusted. Pages use the desktop's network, with no server-side
|
||||
tunnel. The desktop owns Chromium, page isolation, and native controls.
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin-browser",
|
||||
"version": "0.0.0",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/anomalyco/opencode.git",
|
||||
"directory": "packages/plugin-browser"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import pkg from "../package.json"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await $`bun run typecheck`
|
||||
await $`bun run build`
|
||||
const original = await Bun.file("package.json").text()
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
try {
|
||||
await Bun.write(
|
||||
"package.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
...pkg,
|
||||
exports: { ".": { import: "./dist/index.js", types: "./dist/index.d.ts" } },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
)
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", original)
|
||||
await rm(tarball, { force: true })
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import type { RpcRegistration } from "@opencode-ai/plugin/effect/rpc"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Deferred, Effect, Encoding, Stream } from "effect"
|
||||
import { Browser } from "@opencode-ai/schema/browser"
|
||||
|
||||
type Attachment = {
|
||||
connectionID: string
|
||||
state: Browser.State | null
|
||||
closed: Deferred.Deferred<void>
|
||||
pending: Map<string, Deferred.Deferred<Browser.Result, Tool.Error>>
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.browser",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const browsers = new Map<Session.ID, Attachment>()
|
||||
let active = true
|
||||
const close = (sessionID: Session.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const browser = browsers.get(sessionID)
|
||||
if (!browser) return
|
||||
browsers.delete(sessionID)
|
||||
yield* Deferred.succeed(browser.closed, undefined)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => {
|
||||
active = false
|
||||
return Effect.forEach(browsers.keys(), close, { discard: true })
|
||||
})
|
||||
const rpc: RpcRegistration<typeof Browser.Definition> = yield* ctx.rpc
|
||||
.register(Browser.Definition, {
|
||||
attach: (input, call) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* ctx.session
|
||||
.get({ sessionID: input.sessionID })
|
||||
.pipe(Effect.mapError(() => call.error("unavailable", "Session not found.", {})))
|
||||
if (
|
||||
session.location.directory !== ctx.location.directory ||
|
||||
session.location.workspaceID !== ctx.location.workspaceID
|
||||
)
|
||||
return yield* Effect.fail(call.error("unavailable", "Session belongs to another location.", {}))
|
||||
const browser = yield* Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
const closed = yield* Deferred.make<void>()
|
||||
if (!active) return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
|
||||
// The newest desktop attachment wins so a re-register that races the
|
||||
// previous connection's teardown does not leave the session detached.
|
||||
yield* close(input.sessionID)
|
||||
const browser: Attachment = {
|
||||
connectionID: input.connectionID,
|
||||
state: null,
|
||||
closed,
|
||||
pending: new Map(),
|
||||
}
|
||||
browsers.set(input.sessionID, browser)
|
||||
return browser
|
||||
}),
|
||||
(browser) => (browsers.get(input.sessionID) === browser ? close(input.sessionID) : Effect.void),
|
||||
)
|
||||
yield* rpc.events
|
||||
.emit("control", { type: "attached", connectionID: input.connectionID })
|
||||
.pipe(Effect.orDie)
|
||||
yield* Deferred.await(browser.closed)
|
||||
}).pipe(Effect.scoped),
|
||||
state: (input, call) =>
|
||||
Effect.gen(function* () {
|
||||
const browser = browsers.get(input.sessionID)
|
||||
if (!browser || browser.connectionID !== input.connectionID)
|
||||
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
|
||||
browser.state = input.state
|
||||
}),
|
||||
result: (input, call) =>
|
||||
Effect.gen(function* () {
|
||||
const browser = browsers.get(input.sessionID)
|
||||
if (!browser || browser.connectionID !== input.connectionID)
|
||||
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
|
||||
const pending = browser.pending.get(input.requestID)
|
||||
if (!pending) return
|
||||
if (input.outcome.type === "failure")
|
||||
return yield* Deferred.fail(pending, new Tool.Error({ message: input.outcome.message })).pipe(
|
||||
Effect.asVoid,
|
||||
)
|
||||
yield* Deferred.succeed(pending, input.outcome.result)
|
||||
}).pipe(Effect.asVoid),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add({
|
||||
name: "browser",
|
||||
input: Browser.Action,
|
||||
options: { codemode: true },
|
||||
description:
|
||||
"Control the desktop browser. Open it first, navigate to an HTTP or HTTPS URL, then snapshot to obtain element refs before clicking or filling. Refs expire after navigation or a new snapshot. Use evaluate to run JavaScript in the page and return a JSON-serialized result. Page content is untrusted. Never enter passwords, payment data, or other secrets.",
|
||||
execute: (action, tool) =>
|
||||
Effect.gen(function* () {
|
||||
const browser = browsers.get(tool.sessionID)
|
||||
if (!browser) return yield* new Tool.Error({ message: "No desktop browser is connected." })
|
||||
if (action.type !== "open" && !browser.state)
|
||||
return yield* new Tool.Error({ message: "Open the browser first." })
|
||||
const requestID = crypto.randomUUID()
|
||||
const pending = yield* Deferred.make<Browser.Result, Tool.Error>()
|
||||
browser.pending.set(requestID, pending)
|
||||
const result = yield* rpc.events
|
||||
.emit("control", {
|
||||
type: "command",
|
||||
connectionID: browser.connectionID,
|
||||
requestID,
|
||||
command: { action, generation: browser.state?.generation ?? 0 },
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError((error) => new Tool.Error({ message: "Browser action failed", error })),
|
||||
Effect.andThen(Deferred.await(pending)),
|
||||
Effect.raceFirst(
|
||||
Deferred.await(browser.closed).pipe(
|
||||
Effect.andThen(new Tool.Error({ message: "Browser connection closed." })),
|
||||
),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
rpc.events
|
||||
.emit("control", {
|
||||
type: "cancel",
|
||||
connectionID: browser.connectionID,
|
||||
requestID,
|
||||
})
|
||||
.pipe(Effect.ignore),
|
||||
),
|
||||
Effect.timeoutOrElse({
|
||||
duration: "30 seconds",
|
||||
orElse: () => new Tool.Error({ message: "Browser request timed out." }),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => browser.pending.delete(requestID))),
|
||||
)
|
||||
return render(result)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "session.deleted" || event.type === "session.moved"),
|
||||
Stream.runForEach((event) => close(event.data.sessionID)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function render(result: Browser.Result): Tool.Result {
|
||||
if (result.type === "screenshot")
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: "Untrusted browser screenshot." },
|
||||
{
|
||||
type: "file",
|
||||
uri: `data:image/png;base64,${Encoding.encodeBase64(result.data)}`,
|
||||
mime: "image/png",
|
||||
name: "browser-screenshot.png",
|
||||
},
|
||||
],
|
||||
metadata: { url: result.state.url },
|
||||
}
|
||||
const content = JSON.stringify(result)
|
||||
.replaceAll("<", "\\u003c")
|
||||
.replaceAll(">", "\\u003e")
|
||||
.replaceAll("&", "\\u0026")
|
||||
return {
|
||||
content: `<untrusted_browser_content encoding="json">\n${content}\n</untrusted_browser_content>`,
|
||||
metadata: { url: result.state.url },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": false,
|
||||
"noEmit": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig.json",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
export * as Browser from "./browser.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Rpc } from "./rpc.js"
|
||||
import { Session } from "./session.js"
|
||||
|
||||
export const Ref = Schema.String.check(Schema.isPattern(/^@?e[1-9][0-9]*$/))
|
||||
.pipe(Schema.brand("Browser.Ref"))
|
||||
.annotate({ identifier: "Browser.Ref" })
|
||||
export type Ref = typeof Ref.Type
|
||||
|
||||
export interface State extends Schema.Schema.Type<typeof State> {}
|
||||
export const State = Schema.Struct({
|
||||
url: Schema.String.check(Schema.isMaxLength(16_384)),
|
||||
title: Schema.String.check(Schema.isMaxLength(1_024)),
|
||||
loading: Schema.Boolean,
|
||||
canGoBack: Schema.Boolean,
|
||||
canGoForward: Schema.Boolean,
|
||||
generation: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
}).annotate({ identifier: "Browser.State" })
|
||||
|
||||
export const Key = Schema.Literals([
|
||||
"Enter",
|
||||
"Tab",
|
||||
"Escape",
|
||||
"Backspace",
|
||||
"Delete",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"Home",
|
||||
"End",
|
||||
"Space",
|
||||
]).annotate({ identifier: "Browser.Key" })
|
||||
export type Key = typeof Key.Type
|
||||
export const Direction = Schema.Literals(["up", "down", "left", "right"]).annotate({ identifier: "Browser.Direction" })
|
||||
export type Direction = typeof Direction.Type
|
||||
|
||||
export const Action = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literals(["open", "snapshot", "screenshot", "back", "forward", "reload", "stop"]) }),
|
||||
Schema.Struct({ type: Schema.Literal("navigate"), url: Schema.String.check(Schema.isMaxLength(16_384)) }),
|
||||
Schema.Struct({ type: Schema.Literal("click"), ref: Ref }),
|
||||
Schema.Struct({ type: Schema.Literal("fill"), ref: Ref, text: Schema.String.check(Schema.isMaxLength(10_000)) }),
|
||||
Schema.Struct({ type: Schema.Literal("press"), key: Key }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("evaluate"),
|
||||
script: Schema.String.check(Schema.isMaxLength(100_000)).annotate({
|
||||
description: "JavaScript to evaluate in the page. The result is JSON-serialized.",
|
||||
}),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("scroll"),
|
||||
direction: Direction,
|
||||
pixels: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(2000)),
|
||||
}),
|
||||
]).annotate({ identifier: "Browser.Action" })
|
||||
export type Action = typeof Action.Type
|
||||
|
||||
export interface Command extends Schema.Schema.Type<typeof Command> {}
|
||||
export const Command = Schema.Struct({ action: Action, generation: State.fields.generation }).annotate({
|
||||
identifier: "Browser.Command",
|
||||
})
|
||||
export const Result = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("state"), state: State }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("snapshot"),
|
||||
state: State,
|
||||
content: Schema.String.check(Schema.isMaxLength(100_000)),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("evaluate"),
|
||||
state: State,
|
||||
content: Schema.String.check(Schema.isMaxLength(100_000)),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("screenshot"),
|
||||
state: State,
|
||||
data: Schema.Uint8ArrayFromBase64.check(Schema.isMaxLength(5 * 1_024 * 1_024)),
|
||||
}),
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Browser.Result" })
|
||||
export type Result = typeof Result.Type
|
||||
export const Outcome = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("success"), result: Result }),
|
||||
Schema.Struct({ type: Schema.Literal("failure"), message: Schema.String.check(Schema.isMaxLength(1_024)) }),
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Browser.Outcome" })
|
||||
export type Outcome = typeof Outcome.Type
|
||||
|
||||
const attachment = { sessionID: Session.ID, connectionID: Schema.String }
|
||||
const errors = { unavailable: Schema.Struct({}) }
|
||||
export const Control = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("attached"), connectionID: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("command"),
|
||||
connectionID: Schema.String,
|
||||
requestID: Schema.String,
|
||||
command: Command,
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("cancel"), connectionID: Schema.String, requestID: Schema.String }),
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Browser.Control" })
|
||||
export type Control = typeof Control.Type
|
||||
|
||||
export const Definition = Rpc.define({
|
||||
id: "experimental.browser",
|
||||
methods: {
|
||||
attach: { input: Schema.Struct(attachment), output: Schema.Void, errors },
|
||||
state: { input: Schema.Struct({ ...attachment, state: Schema.NullOr(State) }), output: Schema.Void, errors },
|
||||
result: {
|
||||
input: Schema.Struct({ ...attachment, requestID: Schema.String, outcome: Outcome }),
|
||||
output: Schema.Void,
|
||||
errors,
|
||||
},
|
||||
},
|
||||
events: { control: { schema: Control } },
|
||||
})
|
||||
@@ -39,6 +39,7 @@
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
|
||||
@@ -15,6 +15,7 @@ const names = [
|
||||
"protocol",
|
||||
"client",
|
||||
"plugin",
|
||||
"plugin-browser",
|
||||
"core",
|
||||
"simulation",
|
||||
"server",
|
||||
@@ -163,12 +164,13 @@ export default {
|
||||
Bun.write(
|
||||
join(consumer, "boot.mjs"),
|
||||
`import { Miniflare } from "miniflare"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const miniflare = new Miniflare({
|
||||
compatibilityDate: "2026-07-15",
|
||||
compatibilityFlags: ["nodejs_compat"],
|
||||
modules: true,
|
||||
scriptPath: new URL("./dist/worker.js", import.meta.url).pathname,
|
||||
scriptPath: fileURLToPath(new URL("./dist/worker.js", import.meta.url)),
|
||||
durableObjects: { OPENCODE: { className: "OpenCodeDO", useSQLite: true } },
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import plugin from "@opencode-ai/plugin-browser"
|
||||
import { Browser } from "@opencode-ai/schema/browser"
|
||||
import { Agent, Rpc } from "@opencode-ai/plugin/effect"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { AbsolutePath, OpenCode, SessionMessage } from "@opencode-ai/sdk/effect"
|
||||
import { Effect, Fiber, Queue, Stream } from "effect"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
|
||||
const state: Browser.State = {
|
||||
url: "https://example.com/",
|
||||
title: "Example",
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
generation: 7,
|
||||
}
|
||||
|
||||
const fixture = Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped("opencode-browser-")
|
||||
const config = path.join(directory.path, "config")
|
||||
yield* Effect.promise(() => mkdir(config))
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
|
||||
const opencode = yield* OpenCode.create({
|
||||
database: { path: ":memory:" },
|
||||
config: {
|
||||
directory: config,
|
||||
project: false,
|
||||
content: JSON.stringify({
|
||||
plugins: ["-opencode.browser"],
|
||||
}),
|
||||
},
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false, fff: false },
|
||||
})
|
||||
const captured = Promise.withResolvers<Info>()
|
||||
yield* opencode.plugin({ ...plugin, id: "browser-test" })
|
||||
yield* opencode.plugin({
|
||||
id: "browser-test-observer",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
// Inspect the real tool through the public draft, without replacing its executor.
|
||||
yield* ctx.tool.transform((draft) => {
|
||||
const tool = draft.get("browser")
|
||||
if (tool && ctx.location.directory === location.directory) captured.resolve(tool)
|
||||
})
|
||||
}).pipe(Effect.orDie),
|
||||
})
|
||||
yield* opencode.plugin.list({ location })
|
||||
const tool = yield* Effect.promise(() => captured.promise)
|
||||
const session = yield* opencode.sessions.create({ location })
|
||||
const rpc = opencode.rpc(Browser.Definition)
|
||||
const events = yield* Queue.unbounded<Rpc.EventPayload<typeof Browser.Definition, "control">>()
|
||||
yield* rpc.events.subscribe("control").pipe(
|
||||
Stream.runForEach((event) => Queue.offer(events, event)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
// RPC and native subscriptions share one stream; connected is the readiness barrier.
|
||||
yield* opencode.events.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "server.connected"),
|
||||
Stream.runHead,
|
||||
Effect.timeout("5 seconds"),
|
||||
)
|
||||
const next = Queue.take(events).pipe(Effect.timeout("5 seconds"))
|
||||
const execute = (action: Browser.Action) =>
|
||||
tool.execute(action, {
|
||||
sessionID: session.id,
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.create(),
|
||||
id: Tool.CallID.make(crypto.randomUUID()),
|
||||
progress: () => Effect.void,
|
||||
})
|
||||
return {
|
||||
tool,
|
||||
opencode,
|
||||
location,
|
||||
rpc,
|
||||
execute,
|
||||
next,
|
||||
attach: Effect.fn(function* (connectionID: string) {
|
||||
const input = { sessionID: session.id, connectionID }
|
||||
const lifetime = yield* rpc.attach(input, { location }).pipe(Effect.forkScoped)
|
||||
expect(yield* next).toMatchObject({
|
||||
type: "rpc.experimental.browser.control",
|
||||
location,
|
||||
data: { type: "attached", connectionID },
|
||||
})
|
||||
expect(lifetime.pollUnsafe()).toBeUndefined()
|
||||
return { input, lifetime }
|
||||
}),
|
||||
command: Effect.fn(function* (action: Browser.Action) {
|
||||
const pending = yield* execute(action).pipe(Effect.forkScoped)
|
||||
const event = yield* next.pipe(
|
||||
Effect.raceFirst(
|
||||
Fiber.join(pending).pipe(Effect.andThen(Effect.die("Tool completed without a browser command"))),
|
||||
),
|
||||
)
|
||||
expect(event.location).toEqual(location)
|
||||
if (event.data.type !== "command") throw new Error(`Expected command, received ${event.data.type}`)
|
||||
expect(event.data.command.action).toEqual(action)
|
||||
return { ...event.data, pending }
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
test(
|
||||
"attachment ownership, cancellation, and plugin unload release pending browser work",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const host = yield* fixture
|
||||
const options = { location: host.location }
|
||||
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
|
||||
message: "No desktop browser is connected.",
|
||||
})
|
||||
const stale = yield* host.attach("stale")
|
||||
// A newer attachment for the same session replaces the previous one.
|
||||
const attached = yield* host.attach("first")
|
||||
yield* Fiber.join(stale.lifetime).pipe(Effect.timeout("5 seconds"))
|
||||
expect(yield* host.rpc.state({ ...stale.input, state }, options).pipe(Effect.flip)).toMatchObject({
|
||||
type: "unavailable",
|
||||
})
|
||||
const other = Location.Ref.make({ directory: AbsolutePath.make(path.join(host.location.directory, "other")) })
|
||||
yield* Effect.promise(() => mkdir(other.directory))
|
||||
yield* host.opencode.plugin.list({ location: other })
|
||||
expect(yield* host.rpc.attach(attached.input, { location: other }).pipe(Effect.flip)).toMatchObject({
|
||||
type: "unavailable",
|
||||
message: "Session belongs to another location.",
|
||||
})
|
||||
expect(
|
||||
yield* host.rpc.state({ ...attached.input, connectionID: "wrong", state }, options).pipe(Effect.flip),
|
||||
).toMatchObject({ type: "unavailable" })
|
||||
yield* host.rpc.state({ ...attached.input, state }, options)
|
||||
yield* host.rpc.state({ ...attached.input, state: null }, options)
|
||||
expect(yield* host.execute({ type: "snapshot" }).pipe(Effect.flip)).toMatchObject({
|
||||
message: "Open the browser first.",
|
||||
})
|
||||
|
||||
const cancelled = yield* host.command({ type: "open" })
|
||||
expect(cancelled.command.generation).toBe(0)
|
||||
yield* Fiber.interrupt(cancelled.pending)
|
||||
expect((yield* host.next).data).toEqual({
|
||||
type: "cancel",
|
||||
connectionID: attached.input.connectionID,
|
||||
requestID: cancelled.requestID,
|
||||
})
|
||||
// A reply to an interrupted request is harmless while its connection is still attached.
|
||||
yield* host.rpc.result(
|
||||
{ ...attached.input, requestID: cancelled.requestID, outcome: { type: "failure", message: "late" } },
|
||||
options,
|
||||
)
|
||||
const closing = yield* host.command({ type: "open" })
|
||||
yield* Fiber.interrupt(attached.lifetime)
|
||||
expect(yield* Fiber.join(closing.pending).pipe(Effect.flip)).toMatchObject({
|
||||
message: "Browser connection closed.",
|
||||
})
|
||||
expect(yield* host.rpc.state({ ...attached.input, state }, options).pipe(Effect.flip)).toMatchObject({
|
||||
type: "unavailable",
|
||||
})
|
||||
|
||||
const replacement = yield* host.attach("replacement")
|
||||
const pending = yield* host.command({ type: "open" })
|
||||
expect(pending.connectionID).toBe("replacement")
|
||||
expect(pending.command.generation).toBe(0)
|
||||
expect(
|
||||
yield* host.rpc
|
||||
.result(
|
||||
{
|
||||
...attached.input,
|
||||
requestID: pending.requestID,
|
||||
outcome: { type: "success", result: { type: "state", state } },
|
||||
},
|
||||
options,
|
||||
)
|
||||
.pipe(Effect.flip),
|
||||
).toMatchObject({ type: "unavailable" })
|
||||
expect(pending.pending.pollUnsafe()).toBeUndefined()
|
||||
|
||||
// Replacing the SDK registration unloads the production plugin through its normal lifecycle.
|
||||
yield* host.opencode.plugin({ id: "browser-test", effect: () => Effect.void })
|
||||
yield* host.opencode.plugin.list(options)
|
||||
expect(yield* Fiber.join(pending.pending).pipe(Effect.flip)).toMatchObject({
|
||||
message: "Browser connection closed.",
|
||||
})
|
||||
yield* Fiber.join(replacement.lifetime).pipe(Effect.timeout("5 seconds"))
|
||||
expect(yield* host.rpc.state({ ...replacement.input, state }, options).pipe(Effect.flip)).toMatchObject({
|
||||
type: "rpc.unavailable",
|
||||
})
|
||||
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
|
||||
message: "No desktop browser is connected.",
|
||||
})
|
||||
}).pipe(Effect.scoped, Effect.runPromise),
|
||||
15_000,
|
||||
)
|
||||
|
||||
test(
|
||||
"Code Mode discovers and executes the browser without a raw tool and preserves screenshot attachments",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const host = yield* fixture
|
||||
yield* Effect.gen(function* () {
|
||||
const tools = yield* Tool.Service
|
||||
yield* tools.transform((editor) => editor.add(host.tool))
|
||||
const snapshot = yield* tools.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(snapshot.codeModeCatalog?.tools).toMatchObject([{ type: "tool", name: "browser" }])
|
||||
|
||||
const attached = yield* host.attach("codemode")
|
||||
const execute = (code: string) =>
|
||||
snapshot.execute({
|
||||
sessionID: attached.input.sessionID,
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.create(),
|
||||
call: { type: "tool-call", id: crypto.randomUUID(), name: "execute", input: { code } },
|
||||
})
|
||||
const discovery = yield* execute('return search({ query: "browser" })')
|
||||
expect(discovery.content).toMatchObject([{ type: "text", text: expect.stringContaining("tools.browser(") }])
|
||||
|
||||
yield* host.rpc.state({ ...attached.input, state }, { location: host.location })
|
||||
const pending = yield* execute('return await tools.browser({ type: "screenshot" })').pipe(Effect.forkScoped)
|
||||
const event = yield* host.next
|
||||
if (event.data.type !== "command") throw new Error(`Expected command, received ${event.data.type}`)
|
||||
expect(event.data.command).toEqual({ action: { type: "screenshot" }, generation: state.generation })
|
||||
const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
yield* host.rpc.result(
|
||||
{
|
||||
...attached.input,
|
||||
requestID: event.data.requestID,
|
||||
outcome: { type: "success", result: { type: "screenshot", state, data } },
|
||||
},
|
||||
{ location: host.location },
|
||||
)
|
||||
const result = yield* Fiber.join(pending)
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "Untrusted browser screenshot." },
|
||||
{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "browser-screenshot.png" },
|
||||
])
|
||||
expect(result.metadata).toEqual({
|
||||
toolCalls: [{ tool: "browser", status: "completed", input: { type: "screenshot" } }],
|
||||
})
|
||||
|
||||
yield* Fiber.interrupt(attached.lifetime)
|
||||
const disconnected = yield* execute('return await tools.browser({ type: "open" })')
|
||||
expect(disconnected.metadata).toMatchObject({ error: true })
|
||||
expect(disconnected.content).toMatchObject([
|
||||
{ type: "text", text: expect.stringContaining("No desktop browser is connected.") },
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provide(AppNodeBuilder.build(Tool.node, [Location.node.replace(Location.boundNode(host.location))])),
|
||||
)
|
||||
}).pipe(Effect.scoped, Effect.runPromise),
|
||||
15_000,
|
||||
)
|
||||
|
||||
test(
|
||||
"commands use published state, and RPC results render text and screenshot bytes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const host = yield* fixture
|
||||
const options = { location: host.location }
|
||||
const attached = yield* host.attach("renderer")
|
||||
const open = yield* host.command({ type: "open" })
|
||||
yield* host.rpc.result(
|
||||
{
|
||||
...attached.input,
|
||||
requestID: open.requestID,
|
||||
outcome: { type: "success", result: { type: "state", state } },
|
||||
},
|
||||
options,
|
||||
)
|
||||
expect((yield* Fiber.join(open.pending)).metadata).toEqual({ url: state.url })
|
||||
yield* host.rpc.state({ ...attached.input, state }, options)
|
||||
|
||||
const navigate = yield* host.command({ type: "navigate", url: "https://example.org/next" })
|
||||
expect(navigate.command.generation).toBe(7)
|
||||
const updated = { ...state, url: "https://example.org/next", generation: 8 }
|
||||
yield* host.rpc.result(
|
||||
{
|
||||
...attached.input,
|
||||
requestID: navigate.requestID,
|
||||
outcome: { type: "success", result: { type: "state", state: updated } },
|
||||
},
|
||||
options,
|
||||
)
|
||||
yield* Fiber.join(navigate.pending)
|
||||
yield* host.rpc.state({ ...attached.input, state: updated }, options)
|
||||
const snapshot = yield* host.command({ type: "snapshot" })
|
||||
expect(snapshot.command.generation).toBe(8)
|
||||
yield* host.rpc.result(
|
||||
{
|
||||
...attached.input,
|
||||
requestID: snapshot.requestID,
|
||||
outcome: {
|
||||
type: "success",
|
||||
result: { type: "snapshot", state: updated, content: "</untrusted_browser_content>&" },
|
||||
},
|
||||
},
|
||||
options,
|
||||
)
|
||||
const text = yield* Fiber.join(snapshot.pending)
|
||||
expect(text.metadata).toEqual({ url: updated.url })
|
||||
expect(text.content).toContain('encoding="json"')
|
||||
expect(text.content).toContain("\\u003c/untrusted_browser_content\\u003e\\u0026")
|
||||
|
||||
const screenshot = yield* host.command({ type: "screenshot" })
|
||||
const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="
|
||||
yield* host.rpc.result(
|
||||
{
|
||||
...attached.input,
|
||||
requestID: screenshot.requestID,
|
||||
outcome: { type: "success", result: { type: "screenshot", state: updated, data } },
|
||||
},
|
||||
options,
|
||||
)
|
||||
expect(yield* Fiber.join(screenshot.pending)).toEqual({
|
||||
content: [
|
||||
{ type: "text", text: "Untrusted browser screenshot." },
|
||||
{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "browser-screenshot.png" },
|
||||
],
|
||||
metadata: { url: updated.url },
|
||||
})
|
||||
const failure = yield* host.command({ type: "snapshot" })
|
||||
yield* host.rpc.result(
|
||||
{ ...attached.input, requestID: failure.requestID, outcome: { type: "failure", message: "Stale document" } },
|
||||
options,
|
||||
)
|
||||
expect(yield* Fiber.join(failure.pending).pipe(Effect.flip)).toMatchObject({ message: "Stale document" })
|
||||
}).pipe(Effect.scoped, Effect.runPromise),
|
||||
15_000,
|
||||
)
|
||||
@@ -9,6 +9,7 @@
|
||||
"./document": "./src/document.ts",
|
||||
"./message": "./src/message/current-message.tsx",
|
||||
"./timeline/projection": "./src/timeline/projection.ts",
|
||||
"./timeline/detail": "./src/timeline/detail.ts",
|
||||
"./timeline/row": "./src/timeline/session-timeline-row.tsx",
|
||||
"./timeline": "./src/timeline/session-timeline.tsx",
|
||||
"./basic-tool": "./src/components/basic-tool.tsx",
|
||||
|
||||
@@ -3,15 +3,10 @@ import type {
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Match, Switch } from "solid-js"
|
||||
import { Match, Switch, type ComponentProps } from "solid-js"
|
||||
import type { SessionUserActions, SessionUserComment } from "../actions"
|
||||
import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content"
|
||||
import {
|
||||
CurrentContextToolGroup,
|
||||
CurrentFileToolGroup,
|
||||
ToolDisplay,
|
||||
type ContextGroupPart,
|
||||
} from "../tools/tool-renderer"
|
||||
import { CurrentContextToolGroup, CurrentFileToolGroup, ToolDisplay } from "../tools/tool-renderer"
|
||||
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
|
||||
|
||||
export type { SessionUserActions, SessionUserComment } from "../actions"
|
||||
@@ -101,28 +96,8 @@ export function SessionAssistantContent(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionContextToolGroup(props: {
|
||||
parts: ContextGroupPart[]
|
||||
reasoningDefaultOpen?: boolean
|
||||
reasoningOpen?: (id: string) => boolean | undefined
|
||||
onReasoningOpenChange?: (id: string, open: boolean) => void
|
||||
open: boolean
|
||||
busy: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSizeChange?: () => void
|
||||
}) {
|
||||
return (
|
||||
<CurrentContextToolGroup
|
||||
parts={props.parts}
|
||||
reasoningDefaultOpen={props.reasoningDefaultOpen}
|
||||
reasoningOpen={props.reasoningOpen}
|
||||
onReasoningOpenChange={props.onReasoningOpenChange}
|
||||
open={props.open}
|
||||
busy={props.busy}
|
||||
onOpenChange={props.onOpenChange}
|
||||
onSizeChange={props.onSizeChange}
|
||||
/>
|
||||
)
|
||||
export function SessionContextToolGroup(props: ComponentProps<typeof CurrentContextToolGroup>) {
|
||||
return <CurrentContextToolGroup {...props} />
|
||||
}
|
||||
|
||||
export function SessionFileToolGroup(props: {
|
||||
|
||||
@@ -29,6 +29,42 @@ export function currentToolError(tool: SessionMessageAssistantTool) {
|
||||
return tool.state.error.message
|
||||
}
|
||||
|
||||
export function currentToolFailed(tool: SessionMessageAssistantTool) {
|
||||
return (
|
||||
tool.state.status === "error" ||
|
||||
(tool.name === "execute" && executeToolFailed(currentToolMetadata(tool))) ||
|
||||
(tool.name === "shell" && tool.state.status === "completed" && shellResultFailed(currentToolMetadata(tool)))
|
||||
)
|
||||
}
|
||||
|
||||
export function shellResultFailed(metadata: Record<string, unknown>) {
|
||||
// Shell completion reports the process outcome in metadata, not the tool status.
|
||||
return metadata.timeout === true || (typeof metadata.exit === "number" && metadata.exit !== 0)
|
||||
}
|
||||
|
||||
export function executeToolFailed(metadata: Record<string, unknown>) {
|
||||
// Code Mode can report failed nested calls in a completed tool result.
|
||||
const calls = metadata.toolCalls
|
||||
return (
|
||||
metadata.error === true ||
|
||||
(Array.isArray(calls) &&
|
||||
calls.some(
|
||||
(call) =>
|
||||
call !== null &&
|
||||
typeof call === "object" &&
|
||||
!Array.isArray(call) &&
|
||||
"status" in call &&
|
||||
call.status === "error",
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
export function currentToolHasLoadedFiles(tool: SessionMessageAssistantTool) {
|
||||
if (tool.name !== "read" || tool.state.status !== "completed") return false
|
||||
const loaded = tool.state.metadata?.loaded
|
||||
return Array.isArray(loaded) && loaded.some((path) => typeof path === "string")
|
||||
}
|
||||
|
||||
export function currentContentDefaultOpen(
|
||||
content: SessionMessageAssistant["content"][number],
|
||||
shellExpanded: boolean,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { shellResultFailed } from "../message/current-tool-state"
|
||||
|
||||
export const timelineCategories = ["shell", "edit", "thinking", "subagents", "notices", "tools"] as const
|
||||
export type TimelineCategory = (typeof timelineCategories)[number]
|
||||
export type TimelinePlacement = "separate" | "grouped" | "hidden"
|
||||
export type TimelineExpansion = "collapsed" | "expanded"
|
||||
export type TimelineDetail = {
|
||||
shell: { placement: TimelinePlacement; details: TimelineExpansion }
|
||||
edit: { placement: TimelinePlacement; details: TimelineExpansion }
|
||||
thinking: { placement: TimelinePlacement; details: TimelineExpansion }
|
||||
subagents: { placement: TimelinePlacement }
|
||||
notices: { placement: TimelinePlacement }
|
||||
tools: { placement: TimelinePlacement }
|
||||
}
|
||||
|
||||
export const timelinePresets = [
|
||||
{
|
||||
id: "everything",
|
||||
value: {
|
||||
shell: { placement: "separate", details: "expanded" },
|
||||
edit: { placement: "separate", details: "expanded" },
|
||||
thinking: { placement: "separate", details: "expanded" },
|
||||
subagents: { placement: "separate" },
|
||||
notices: { placement: "separate" },
|
||||
tools: { placement: "separate" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "detailed",
|
||||
value: {
|
||||
shell: { placement: "separate", details: "expanded" },
|
||||
edit: { placement: "separate", details: "expanded" },
|
||||
thinking: { placement: "grouped", details: "collapsed" },
|
||||
subagents: { placement: "separate" },
|
||||
notices: { placement: "grouped" },
|
||||
tools: { placement: "grouped" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "compact",
|
||||
value: {
|
||||
shell: { placement: "grouped", details: "collapsed" },
|
||||
edit: { placement: "grouped", details: "collapsed" },
|
||||
thinking: { placement: "grouped", details: "collapsed" },
|
||||
subagents: { placement: "grouped" },
|
||||
notices: { placement: "grouped" },
|
||||
tools: { placement: "grouped" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "quiet",
|
||||
value: {
|
||||
shell: { placement: "hidden", details: "collapsed" },
|
||||
edit: { placement: "grouped", details: "collapsed" },
|
||||
thinking: { placement: "hidden", details: "collapsed" },
|
||||
subagents: { placement: "grouped" },
|
||||
notices: { placement: "hidden" },
|
||||
tools: { placement: "hidden" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "text-only",
|
||||
value: {
|
||||
shell: { placement: "hidden", details: "collapsed" },
|
||||
edit: { placement: "hidden", details: "collapsed" },
|
||||
thinking: { placement: "hidden", details: "collapsed" },
|
||||
subagents: { placement: "hidden" },
|
||||
notices: { placement: "hidden" },
|
||||
tools: { placement: "hidden" },
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly { id: string; value: TimelineDetail }[]
|
||||
|
||||
export function timelinePreset(value: TimelineDetail) {
|
||||
return timelinePresets.find((preset) =>
|
||||
timelineCategories.every((category) => {
|
||||
const current = value[category]
|
||||
const expected = preset.value[category]
|
||||
return (
|
||||
current.placement === expected.placement &&
|
||||
(current.placement === "hidden" ||
|
||||
!("details" in current) ||
|
||||
("details" in expected && current.details === expected.details))
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function timelineCategory(
|
||||
content: SessionMessageAssistant["content"][number],
|
||||
): keyof TimelineDetail | undefined {
|
||||
if (content.type === "text") return
|
||||
if (content.type === "reasoning") return "thinking"
|
||||
if (["shell", "execute", "bash"].includes(content.name)) return "shell"
|
||||
if (["edit", "write", "patch", "apply_patch"].includes(content.name)) return "edit"
|
||||
if (["subagent", "task"].includes(content.name)) return "subagents"
|
||||
return "tools"
|
||||
}
|
||||
|
||||
export function timelineNoticeRequired(message: SessionMessageInfo) {
|
||||
if (message.type === "compaction") return message.status !== "completed"
|
||||
if (message.type !== "synthetic") return false
|
||||
const metadata = message.metadata
|
||||
return (
|
||||
metadata?.state === "error" ||
|
||||
(metadata?.source === "shell" && metadata.state === "completed" && shellResultFailed(metadata))
|
||||
)
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Option, Schema } from "effect"
|
||||
import { createMemo, mapArray, type Accessor } from "solid-js"
|
||||
import { currentContentDefaultOpen } from "../message/current-tool-state"
|
||||
import { currentContentDefaultOpen, currentToolFailed, currentToolHasLoadedFiles } from "../message/current-tool-state"
|
||||
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
|
||||
import { timelineCategory, timelineNoticeRequired, type TimelineDetail } from "./detail"
|
||||
|
||||
export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
|
||||
|
||||
@@ -29,6 +30,7 @@ export type TimelineProjectionInput = {
|
||||
reasoningMode: ReasoningMode
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
timelineDetail?: TimelineDetail
|
||||
pendingUserMessageIDs?: ReadonlySet<string>
|
||||
previousRows?: TimelineRow.TimelineRow[]
|
||||
}
|
||||
@@ -42,6 +44,8 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
|
||||
input.pendingUserMessageIDs,
|
||||
input.shellToolDefaultOpen ?? false,
|
||||
input.editToolDefaultOpen ?? false,
|
||||
undefined,
|
||||
input.timelineDetail,
|
||||
)
|
||||
const rows = reuseTimelineRows(input.previousRows, projection.rows)
|
||||
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row] as const))
|
||||
@@ -75,6 +79,7 @@ export function createReactiveTimelineProjection(input: {
|
||||
reasoningMode: Accessor<ReasoningMode>
|
||||
shellToolDefaultOpen?: Accessor<boolean>
|
||||
editToolDefaultOpen?: Accessor<boolean>
|
||||
timelineDetail?: Accessor<TimelineDetail>
|
||||
pendingUserMessageIDs?: Accessor<ReadonlySet<string>>
|
||||
}) {
|
||||
const sessionMessageByID = createMemo(
|
||||
@@ -102,10 +107,12 @@ export function createReactiveTimelineProjection(input: {
|
||||
input.pendingUserMessageIDs?.(),
|
||||
input.shellToolDefaultOpen?.() ?? false,
|
||||
input.editToolDefaultOpen?.() ?? false,
|
||||
(content, showReasoning) =>
|
||||
(content, showReasoning, detail) =>
|
||||
content.type === "tool"
|
||||
? renderable(content, showReasoning)
|
||||
: (content.type === "text" || showReasoning) && textVisible().get(content)!(),
|
||||
? renderable(content, showReasoning, detail)
|
||||
: (content.type === "text" || (detail ? detail.thinking.placement !== "hidden" : showReasoning)) &&
|
||||
textVisible().get(content)!(),
|
||||
input.timelineDetail?.(),
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||
@@ -157,6 +164,7 @@ export namespace Timeline {
|
||||
shellToolDefaultOpen = false,
|
||||
editToolDefaultOpen = false,
|
||||
isRenderable = renderable,
|
||||
detail?: TimelineDetail,
|
||||
) {
|
||||
type Turn = {
|
||||
id: string
|
||||
@@ -208,37 +216,68 @@ export namespace Timeline {
|
||||
})
|
||||
|
||||
const activeMessageID = turns.findLast((turn) => !pendingUserMessageIDs?.has(turn.id))?.id ?? turns.at(-1)?.id
|
||||
const visibleNotice = (message: Notice) =>
|
||||
!detail || detail.notices.placement !== "hidden" || timelineNoticeRequired(message)
|
||||
const visibleTurns = detail
|
||||
? turns.filter((turn) => {
|
||||
if (turn.user) return true
|
||||
if (turn.shell && (detail.shell.placement !== "hidden" || shellFailed(turn.shell))) return true
|
||||
return turn.entries.some((entry) =>
|
||||
entry.type === "notice"
|
||||
? visibleNotice(entry.message)
|
||||
: !!entry.message.error ||
|
||||
!!entry.message.retry ||
|
||||
entry.message.content.some((content) => isRenderable(content, showReasoning, detail)),
|
||||
)
|
||||
})
|
||||
: turns
|
||||
const rows: TimelineRow.TimelineRow[] = [
|
||||
...leading
|
||||
.filter(visibleNotice)
|
||||
.map((message) => new TimelineRow.Notice({ userMessageID: turns[0]?.id ?? message.id, messageID: message.id })),
|
||||
...visibleTurns.flatMap((turn, index) => {
|
||||
if (turn.shell)
|
||||
return [
|
||||
...(index > 0 ? [new TimelineRow.TurnGap({ userMessageID: turn.id })] : []),
|
||||
...(!detail || detail.shell.placement !== "hidden" || shellFailed(turn.shell)
|
||||
? [new TimelineRow.Shell({ userMessageID: turn.id, messageID: turn.shell.id })]
|
||||
: []),
|
||||
...turn.entries.flatMap((entry) =>
|
||||
entry.type === "notice" && visibleNotice(entry.message)
|
||||
? [new TimelineRow.Notice({ userMessageID: turn.id, messageID: entry.message.id })]
|
||||
: [],
|
||||
),
|
||||
]
|
||||
return constructMessageRows(
|
||||
turn.user,
|
||||
turn.id,
|
||||
turn.entries,
|
||||
index,
|
||||
showReasoning,
|
||||
status,
|
||||
turn.id === activeMessageID,
|
||||
shellToolDefaultOpen,
|
||||
editToolDefaultOpen,
|
||||
isRenderable,
|
||||
detail,
|
||||
)
|
||||
}),
|
||||
]
|
||||
return {
|
||||
activeMessageID,
|
||||
rows: [
|
||||
...leading.map(
|
||||
(message) => new TimelineRow.Notice({ userMessageID: turns[0]?.id ?? message.id, messageID: message.id }),
|
||||
),
|
||||
...turns.flatMap((turn, index) => {
|
||||
if (turn.shell)
|
||||
return [
|
||||
...(index > 0 ? [new TimelineRow.TurnGap({ userMessageID: turn.id })] : []),
|
||||
new TimelineRow.Shell({ userMessageID: turn.id, messageID: turn.shell.id }),
|
||||
...turn.entries.flatMap((entry) =>
|
||||
entry.type === "notice"
|
||||
? [new TimelineRow.Notice({ userMessageID: turn.id, messageID: entry.message.id })]
|
||||
: [],
|
||||
),
|
||||
]
|
||||
return constructMessageRows(
|
||||
turn.user,
|
||||
turn.id,
|
||||
turn.entries,
|
||||
index,
|
||||
showReasoning,
|
||||
status,
|
||||
turn.id === activeMessageID,
|
||||
shellToolDefaultOpen,
|
||||
editToolDefaultOpen,
|
||||
isRenderable,
|
||||
rows: detail
|
||||
? groupMessages(
|
||||
rows,
|
||||
detail,
|
||||
new Set(
|
||||
messages
|
||||
.filter(
|
||||
(message) => timelineNoticeRequired(message) || (message.type === "shell" && shellFailed(message)),
|
||||
)
|
||||
.map((message) => message.id),
|
||||
),
|
||||
)
|
||||
}),
|
||||
],
|
||||
: rows,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +292,7 @@ export namespace Timeline {
|
||||
shellToolDefaultOpen = false,
|
||||
editToolDefaultOpen = false,
|
||||
isRenderable = renderable,
|
||||
detail?: TimelineDetail,
|
||||
) {
|
||||
const rows: TimelineRow.TimelineRow[] = []
|
||||
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
|
||||
@@ -261,7 +301,7 @@ export namespace Timeline {
|
||||
const compaction = entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
|
||||
const lastContent = lastAssistant?.content.at(-1)
|
||||
const thinking =
|
||||
showReasoning &&
|
||||
(detail ? detail.thinking.placement === "separate" : showReasoning) &&
|
||||
isActive &&
|
||||
status.type === "busy" &&
|
||||
lastAssistant?.time.completed === undefined &&
|
||||
@@ -280,7 +320,9 @@ export namespace Timeline {
|
||||
const appendAssistantSegment = (messages: SessionMessageAssistant[]) => {
|
||||
const refs = messages.flatMap((message, messageIndex) =>
|
||||
contentEntries(message)
|
||||
.filter((entry) => isRenderable(entry.content, showReasoning) && !(thinking && entry.content === lastContent))
|
||||
.filter(
|
||||
(entry) => isRenderable(entry.content, showReasoning, detail) && !(thinking && entry.content === lastContent),
|
||||
)
|
||||
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
|
||||
)
|
||||
const interruptedAt = messages.findIndex((message) => isInterrupted(message.error))
|
||||
@@ -288,7 +330,7 @@ export namespace Timeline {
|
||||
const after = interruptedAt < 0 ? [] : refs.filter((ref) => ref.messageIndex > interruptedAt)
|
||||
const appendGroups = (items: typeof refs) => {
|
||||
let offset = 0
|
||||
groupContent(items, shellToolDefaultOpen, editToolDefaultOpen).forEach((group) => {
|
||||
groupContent(items, shellToolDefaultOpen, editToolDefaultOpen, detail).forEach((group) => {
|
||||
const tool = group.type !== "part" || items[offset]?.content.type !== "text"
|
||||
offset += group.type === "part" ? 1 : group.refs.length
|
||||
rows.push(
|
||||
@@ -306,7 +348,8 @@ export namespace Timeline {
|
||||
|
||||
appendGroups(before)
|
||||
if (interruptedAt >= 0) {
|
||||
if (!compaction) rows.push(new TimelineRow.TurnDivider({ userMessageID: turnID }))
|
||||
if (!compaction && detail?.notices.placement !== "hidden")
|
||||
rows.push(new TimelineRow.TurnDivider({ userMessageID: turnID }))
|
||||
appendGroups(after)
|
||||
}
|
||||
|
||||
@@ -325,6 +368,7 @@ export namespace Timeline {
|
||||
assistantSegment.push(entry.message)
|
||||
return
|
||||
case "notice":
|
||||
if (detail?.notices.placement === "hidden" && !timelineNoticeRequired(entry.message)) return
|
||||
appendAssistantSegment(assistantSegment)
|
||||
assistantSegment = []
|
||||
rows.push(new TimelineRow.Notice({ userMessageID: turnID, messageID: entry.message.id }))
|
||||
@@ -366,6 +410,48 @@ function isInterrupted(error: SessionMessageAssistant["error"]) {
|
||||
return error?.type.toLowerCase().includes("abort") || error?.type.toLowerCase().includes("interrupt")
|
||||
}
|
||||
|
||||
function shellFailed(message: SessionMessageShell) {
|
||||
return (
|
||||
message.status === "timeout" || (message.status === "exited" && message.exit !== undefined && message.exit !== 0)
|
||||
)
|
||||
}
|
||||
|
||||
function groupMessages(rows: TimelineRow.TimelineRow[], detail: TimelineDetail, required: ReadonlySet<string>) {
|
||||
return rows.reduce<TimelineRow.TimelineRow[]>((result, row) => {
|
||||
const previous = result.at(-1)
|
||||
const current =
|
||||
((row._tag === "Notice" && detail.notices.placement === "grouped") ||
|
||||
(row._tag === "Shell" && detail.shell.placement === "grouped")) &&
|
||||
!required.has(row.messageID)
|
||||
? new TimelineRow.AssistantPart({
|
||||
userMessageID: row.userMessageID,
|
||||
previousAssistantPart: previous?._tag === "AssistantPart",
|
||||
spacing: previous?._tag === "AssistantPart" ? "tool" : undefined,
|
||||
group: {
|
||||
type: "context",
|
||||
key: `message:${row.messageID}`,
|
||||
refs: [{ messageID: row.messageID, partID: row.messageID }],
|
||||
},
|
||||
})
|
||||
: row
|
||||
if (
|
||||
previous?._tag === "AssistantPart" &&
|
||||
previous.group.type === "context" &&
|
||||
current._tag === "AssistantPart" &&
|
||||
current.group.type === "context" &&
|
||||
previous.userMessageID === current.userMessageID
|
||||
) {
|
||||
result[result.length - 1] = new TimelineRow.AssistantPart({
|
||||
...previous,
|
||||
group: { ...previous.group, refs: [...previous.group.refs, ...current.group.refs] },
|
||||
})
|
||||
return result
|
||||
}
|
||||
result.push(current)
|
||||
return result
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
||||
if (!previous?.length) return rows
|
||||
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
||||
@@ -494,11 +580,14 @@ function groupPartKey(ref: PartRef) {
|
||||
return `${ref.messageID}:${ref.partID}`
|
||||
}
|
||||
|
||||
function renderable(content: Content, showReasoning: boolean) {
|
||||
function renderable(content: Content, showReasoning: boolean, detail?: TimelineDetail) {
|
||||
if (content.type === "text") return !!content.text.trim()
|
||||
if (content.type === "reasoning") return showReasoning && !!content.text.trim()
|
||||
if (content.type === "reasoning")
|
||||
return (detail ? detail.thinking.placement !== "hidden" : showReasoning) && !!content.text.trim()
|
||||
if (detail && currentToolFailed(content)) return true
|
||||
if (content.name === "todowrite") return false
|
||||
if (content.name === "question") return content.state.status !== "streaming" && content.state.status !== "running"
|
||||
if (detail && detail[timelineCategory(content)!].placement === "hidden") return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -506,6 +595,7 @@ function groupContent(
|
||||
items: { messageID: string; partID: string; content: Content }[],
|
||||
shellToolDefaultOpen: boolean,
|
||||
editToolDefaultOpen: boolean,
|
||||
detail?: TimelineDetail,
|
||||
): PartGroup[] {
|
||||
const groups: PartGroup[] = []
|
||||
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined
|
||||
@@ -513,7 +603,7 @@ function groupContent(
|
||||
const current = adjacent
|
||||
const first = current?.refs[0]
|
||||
if (!first) return
|
||||
if (!current.tools) {
|
||||
if (!current.tools && !detail) {
|
||||
groups.push(
|
||||
...current.refs.map((ref) => ({ type: "part" as const, key: `part:${ref.messageID}:${ref.partID}`, ref })),
|
||||
)
|
||||
@@ -539,9 +629,12 @@ function groupContent(
|
||||
shellToolDefaultOpen,
|
||||
editToolDefaultOpen,
|
||||
adjacent?.type === "context" && adjacent.tools,
|
||||
detail,
|
||||
)
|
||||
: item.content.type === "reasoning"
|
||||
? "context"
|
||||
? detail && detail.thinking.placement !== "grouped"
|
||||
? undefined
|
||||
: "context"
|
||||
: undefined
|
||||
if (type) {
|
||||
if (adjacent?.type !== type) flush()
|
||||
@@ -566,8 +659,17 @@ function toolGroupType(
|
||||
shellExpanded: boolean,
|
||||
editExpanded: boolean,
|
||||
hasContextGroup: boolean,
|
||||
detail?: TimelineDetail,
|
||||
) {
|
||||
if (content.name === "question" || hasLoadedFiles(content)) return undefined
|
||||
if (detail) {
|
||||
if (currentToolFailed(content) || content.name === "question") return undefined
|
||||
const category = timelineCategory(content)!
|
||||
if (detail[category].placement === "grouped") return "context"
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
return undefined
|
||||
}
|
||||
if (content.name === "question" || currentToolHasLoadedFiles(content)) return undefined
|
||||
if (content.state.status === "error") {
|
||||
if ((content.name === "shell" || content.name === "execute") && shellExpanded) return undefined
|
||||
if ((content.name === "edit" || content.name === "write" || content.name === "patch") && editExpanded)
|
||||
@@ -587,12 +689,6 @@ function toolGroupType(
|
||||
return undefined
|
||||
}
|
||||
|
||||
function hasLoadedFiles(content: Extract<Content, { type: "tool" }>) {
|
||||
if (content.name !== "read" || content.state.status !== "completed") return false
|
||||
const loaded = content.state.metadata?.loaded
|
||||
return Array.isArray(loaded) && loaded.some((path) => typeof path === "string")
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -671,5 +767,5 @@ function record(value: unknown): value is Record<string, unknown> {
|
||||
function isNotice(message: SessionMessageInfo): message is Notice {
|
||||
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
|
||||
if (message.type !== "synthetic") return true
|
||||
return !!message.description?.trim()
|
||||
return !!message.description?.trim() || timelineNoticeRequired(message)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { AssistantReasoningContent, SessionCompactionMessage } from "../message/message-content"
|
||||
import type { ContextGroupPart } from "../tools/tool-renderer"
|
||||
import { SessionRetry } from "../components/session-retry"
|
||||
import { timelineCategory, type TimelineDetail } from "./detail"
|
||||
import { currentToolFailed } from "../message/current-tool-state"
|
||||
import {
|
||||
createReactiveTimelineProjection,
|
||||
Timeline,
|
||||
@@ -49,9 +51,11 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
reasoningMode: Accessor<ReasoningMode>
|
||||
shellToolDefaultOpen: Accessor<boolean>
|
||||
editToolDefaultOpen: Accessor<boolean>
|
||||
timelineDetail?: Accessor<TimelineDetail>
|
||||
disclosure: {
|
||||
value: (key: string) => boolean | undefined
|
||||
set: (key: string, open: boolean) => void
|
||||
patchGroupKeys?: Map<string, string>
|
||||
}
|
||||
centered?: Accessor<boolean>
|
||||
padding?: Accessor<string>
|
||||
@@ -59,6 +63,21 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const data = useData()
|
||||
// Cached timelines retain subgroup identities alongside their disclosure choices.
|
||||
const patchGroupKeys = input.disclosure.patchGroupKeys ?? new Map<string, string>()
|
||||
const patchPartKeys = new WeakMap<SessionMessageAssistant["content"][number], string>()
|
||||
const patchOwners = createMemo(() => {
|
||||
const owners = new Map<string, string>()
|
||||
input.projection.rows().forEach((row) => {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
|
||||
row.group.refs.forEach((ref) => {
|
||||
const part = `${ref.messageID}:${ref.partID}`
|
||||
const key = patchGroupKeys.get(part)
|
||||
if (key && !owners.has(key)) owners.set(key, part)
|
||||
})
|
||||
})
|
||||
return owners
|
||||
})
|
||||
const workingTurn = (messageID: string) =>
|
||||
input.status().type !== "idle" && input.projection.activeMessageID() === messageID
|
||||
const duration = (messageID: string) => {
|
||||
@@ -104,10 +123,35 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
const group = row().group
|
||||
if (group.type !== "context") return []
|
||||
const contents = indexGroupContents(group.refs)
|
||||
const lastAssistant = input.projection.assistantMessagesByParent().get(row().userMessageID)?.at(-1)
|
||||
return group.refs.flatMap<ContextGroupPart>((ref) => {
|
||||
const content = contents.get(ref.messageID)?.get(ref.partID)
|
||||
if (content?.type === "tool") return [content]
|
||||
if (content?.type === "reasoning") return [{ ...content, id: ref.partID }]
|
||||
if (content?.type === "tool") {
|
||||
patchPartKeys.set(content, `${ref.messageID}:${ref.partID}`)
|
||||
return [content]
|
||||
}
|
||||
if (content?.type === "reasoning")
|
||||
return [
|
||||
{
|
||||
...content,
|
||||
id: ref.partID,
|
||||
streaming:
|
||||
workingTurn(row().userMessageID) &&
|
||||
input.status().type === "busy" &&
|
||||
lastAssistant?.id === ref.messageID &&
|
||||
lastAssistant.time.completed === undefined &&
|
||||
!lastAssistant.error &&
|
||||
!lastAssistant.retry &&
|
||||
lastAssistant.content.at(-1) === content &&
|
||||
content.time?.completed === undefined,
|
||||
},
|
||||
]
|
||||
const message = input.projection.messageByID().get(ref.messageID)
|
||||
if (ref.messageID !== ref.partID || !message) return []
|
||||
if (message.type === "shell")
|
||||
return [{ type: "shell", id: ref.partID, render: () => <Shell messageID={ref.messageID} grouped /> }]
|
||||
if (message.type !== "assistant" && message.type !== "user")
|
||||
return [{ type: "notice", id: ref.partID, render: () => <Notice messageID={ref.messageID} grouped /> }]
|
||||
return []
|
||||
})
|
||||
})
|
||||
@@ -115,9 +159,30 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
return (
|
||||
<SessionContextToolGroup
|
||||
parts={parts()}
|
||||
reasoningDefaultOpen={input.reasoningMode() === "full"}
|
||||
patchGroupKey={(tools) => {
|
||||
const parts = tools.map((tool) => patchPartKeys.get(tool)!)
|
||||
// After a split, only the subgroup with the earliest surviving member keeps the old anchor.
|
||||
const key =
|
||||
parts
|
||||
.map((part) => patchGroupKeys.get(part))
|
||||
.find((key) => key !== undefined && parts.includes(patchOwners().get(key)!)) ?? parts[0]!
|
||||
parts.forEach((part) => patchGroupKeys.set(part, key))
|
||||
return key
|
||||
}}
|
||||
reasoningDefaultOpen={
|
||||
input.timelineDetail
|
||||
? input.timelineDetail().thinking.details === "expanded"
|
||||
: input.reasoningMode() === "full"
|
||||
}
|
||||
reasoningOpen={(id) => input.disclosure.value(id)}
|
||||
onReasoningOpenChange={(id, open) => input.disclosure.set(id, open)}
|
||||
toolDefaultOpen={(tool) => (input.timelineDetail ? contentDefaultOpen(tool) : false)}
|
||||
toolOpen={(id) => input.disclosure.value(`${row().group.key}:tool:${id}`)}
|
||||
onToolOpenChange={(id, open) => input.disclosure.set(`${row().group.key}:tool:${id}`, open)}
|
||||
fileOpen={(path) =>
|
||||
input.disclosure.value(`patch:${path}`) ?? input.timelineDetail?.().edit.details === "expanded"
|
||||
}
|
||||
onFileOpenChange={(path, open) => input.disclosure.set(`patch:${path}`, open)}
|
||||
open={input.disclosure.value(key()) === true}
|
||||
busy={
|
||||
workingTurn(row().userMessageID) &&
|
||||
@@ -155,6 +220,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
fileOpen={(path) => {
|
||||
const open = input.disclosure.value(`${row().group.key}:file:${path}`)
|
||||
if (open !== undefined) return open
|
||||
if (input.timelineDetail) return input.timelineDetail().edit.details === "expanded"
|
||||
if (tools()[0]?.name !== "edit" || path !== firstPath()) return false
|
||||
return input.disclosure.value(row().group.key) ?? input.editToolDefaultOpen()
|
||||
}}
|
||||
@@ -179,9 +245,7 @@ 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())
|
||||
return item ? contentDefaultOpen(item) : undefined
|
||||
})
|
||||
const disclosureKey = () => (content()?.type === "reasoning" ? ref()!.partID : row().group.key)
|
||||
return (
|
||||
@@ -207,6 +271,17 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
)
|
||||
}
|
||||
|
||||
function contentDefaultOpen(item: SessionMessageAssistant["content"][number]) {
|
||||
if (input.timelineDetail) {
|
||||
if (item.type === "tool" && currentToolFailed(item)) return true
|
||||
const category = timelineCategory(item)
|
||||
if (category === "shell" || category === "edit" || category === "thinking")
|
||||
return input.timelineDetail()[category].details === "expanded"
|
||||
}
|
||||
if (item.type === "reasoning") return input.reasoningMode() === "full"
|
||||
return currentContentDefaultOpen(item, input.shellToolDefaultOpen(), input.editToolDefaultOpen())
|
||||
}
|
||||
|
||||
const notice = (message: SessionMessageInfo) => {
|
||||
if (message.type === "agent-switched")
|
||||
return {
|
||||
@@ -271,6 +346,168 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
</div>
|
||||
)
|
||||
|
||||
function Notice(props: { messageID: string; grouped?: boolean }) {
|
||||
const inset = () => (props.grouped ? "" : padding())
|
||||
const message = createMemo(() => input.projection.messageByID().get(props.messageID))
|
||||
const compaction = createMemo(() => {
|
||||
const value = message()
|
||||
return value?.type === "compaction" ? value : undefined
|
||||
})
|
||||
const compactionError = createMemo(() => {
|
||||
const value = compaction()
|
||||
if (value?.status !== "failed") return ""
|
||||
return unwrapErrorMessage(value.error.message)
|
||||
})
|
||||
const moved = createMemo(() => {
|
||||
const value = message()
|
||||
return value?.type === "location-switched" ? value : undefined
|
||||
})
|
||||
const model = createMemo(() => {
|
||||
const value = message()
|
||||
if (value?.type !== "model-switched") return undefined
|
||||
const match = data.store.provider?.all?.get(value.model.providerID)
|
||||
return {
|
||||
providerID: value.model.providerID,
|
||||
variant: value.model.variant,
|
||||
label: i18n.t("ui.sessionTimeline.notice.modelSwitched", {
|
||||
model: match?.models?.[value.model.id]?.name ?? value.model.id,
|
||||
}),
|
||||
}
|
||||
})
|
||||
const content = createMemo(() => {
|
||||
const value = message()
|
||||
return value ? notice(value) : undefined
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<Show when={compaction()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${inset()}`}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<SessionCompactionMessage message={message()} error={compactionError()} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={moved()}
|
||||
fallback={
|
||||
<Show
|
||||
when={model()}
|
||||
fallback={
|
||||
<Show when={content()}>
|
||||
{(content) => (
|
||||
<Show
|
||||
when={content().items?.length}
|
||||
fallback={
|
||||
<div
|
||||
data-slot="session-timeline-notice"
|
||||
class={`w-full truncate ${props.grouped ? "py-1" : "pt-3 pb-1"} text-13-regular leading-text-compact text-text-weak ${inset()}`}
|
||||
>
|
||||
<bdi dir="auto" class="text-13-medium">
|
||||
{content().label}
|
||||
</bdi>
|
||||
<Show when={content().data}>
|
||||
{(data) => (
|
||||
<span>
|
||||
{" "}
|
||||
· <bdi dir="auto">{data()}</bdi>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-slot="session-timeline-notice" class={`w-full py-1 ${inset()}`}>
|
||||
<div class="flex min-h-5 min-w-0 items-center gap-2 overflow-hidden">
|
||||
<bdi
|
||||
dir="auto"
|
||||
class="shrink-0 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
|
||||
>
|
||||
{content().label}
|
||||
</bdi>
|
||||
<For each={content().items}>
|
||||
{(item) => (
|
||||
<bdi
|
||||
dir="auto"
|
||||
class="min-w-0 truncate text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
|
||||
>
|
||||
{item}
|
||||
</bdi>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(model) => (
|
||||
<div data-slot="session-timeline-notice" data-type="model-switched" class={`w-full py-2 ${inset()}`}>
|
||||
<TimelineSeparator label={model().label} providerID={model().providerID} variant={model().variant} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(message) => (
|
||||
<div
|
||||
data-slot="session-timeline-notice"
|
||||
data-type="location-switched"
|
||||
class={`flex h-7 w-full min-w-0 items-center gap-2 py-1 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint ${inset()}`}
|
||||
>
|
||||
<Tooltip
|
||||
appearance="compact"
|
||||
placement="top"
|
||||
value={i18n.t("ui.sessionTimeline.notice.movedTooltip")}
|
||||
class="shrink-0"
|
||||
triggerTabIndex={0}
|
||||
>
|
||||
<bdi data-slot="session-timeline-notice-label" dir="auto" class="font-[530]">
|
||||
{i18n.t("ui.sessionTimeline.notice.movedTo")}
|
||||
</bdi>
|
||||
</Tooltip>{" "}
|
||||
<bdi data-slot="session-timeline-notice-value" dir="ltr" class="min-w-0 truncate font-[440]">
|
||||
{message().location.directory}
|
||||
</bdi>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Shell(props: { messageID: string; grouped?: boolean }) {
|
||||
const message = createMemo(() => {
|
||||
const value = input.projection.messageByID().get(props.messageID)
|
||||
return value?.type === "shell" ? value : undefined
|
||||
})
|
||||
const defaultOpen = createMemo(() => {
|
||||
if (!input.timelineDetail) return input.shellToolDefaultOpen()
|
||||
const value = message()
|
||||
return (
|
||||
input.timelineDetail().shell.details === "expanded" ||
|
||||
value?.status === "timeout" ||
|
||||
(value?.status === "exited" && value.exit !== undefined && value.exit !== 0)
|
||||
)
|
||||
})
|
||||
return (
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${props.grouped ? "" : padding()}`}>
|
||||
<SessionShellMessage
|
||||
message={message()}
|
||||
defaultOpen={defaultOpen()}
|
||||
open={input.disclosure.value(message().id) ?? (input.timelineDetail ? defaultOpen() : undefined)}
|
||||
onOpenChange={(open) => input.disclosure.set(message().id, open)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const render = (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => {
|
||||
if (row()._tag === "TurnGap") return <div data-timeline-row="TurnGap" aria-hidden="true" class="h-6" />
|
||||
if (row()._tag === "UserMessage") {
|
||||
@@ -315,24 +552,9 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
if (value._tag !== "Shell") throw new Error("Expected a shell timeline row")
|
||||
return value
|
||||
}
|
||||
const message = createMemo(() => {
|
||||
const value = input.projection.messageByID().get(current().messageID)
|
||||
return value?.type === "shell" ? value : undefined
|
||||
})
|
||||
return (
|
||||
<Frame row={current()}>
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
|
||||
<SessionShellMessage
|
||||
message={message()}
|
||||
defaultOpen={input.shellToolDefaultOpen()}
|
||||
open={input.disclosure.value(message().id)}
|
||||
onOpenChange={(open) => input.disclosure.set(message().id, open)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Shell messageID={current().messageID} />
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -342,140 +564,9 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
if (value._tag !== "Notice") throw new Error("Expected a notice timeline row")
|
||||
return value
|
||||
}
|
||||
const message = createMemo(() => input.projection.messageByID().get(current().messageID))
|
||||
const compaction = createMemo(() => {
|
||||
const value = message()
|
||||
return value?.type === "compaction" ? value : undefined
|
||||
})
|
||||
const compactionError = createMemo(() => {
|
||||
const value = compaction()
|
||||
if (value?.status !== "failed") return ""
|
||||
return unwrapErrorMessage(value.error.message)
|
||||
})
|
||||
const moved = createMemo(() => {
|
||||
const value = message()
|
||||
return value?.type === "location-switched" ? value : undefined
|
||||
})
|
||||
const model = createMemo(() => {
|
||||
const value = message()
|
||||
if (value?.type !== "model-switched") return undefined
|
||||
const match = data.store.provider?.all?.get(value.model.providerID)
|
||||
return {
|
||||
providerID: value.model.providerID,
|
||||
variant: value.model.variant,
|
||||
label: i18n.t("ui.sessionTimeline.notice.modelSwitched", {
|
||||
model: match?.models?.[value.model.id]?.name ?? value.model.id,
|
||||
}),
|
||||
}
|
||||
})
|
||||
const content = createMemo(() => {
|
||||
const value = message()
|
||||
return value ? notice(value) : undefined
|
||||
})
|
||||
return (
|
||||
<Frame row={current()}>
|
||||
<Show when={compaction()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<SessionCompactionMessage message={message()} error={compactionError()} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={moved()}
|
||||
fallback={
|
||||
<Show
|
||||
when={model()}
|
||||
fallback={
|
||||
<Show when={content()}>
|
||||
{(content) => (
|
||||
<Show
|
||||
when={content().items?.length}
|
||||
fallback={
|
||||
<div
|
||||
data-slot="session-timeline-notice"
|
||||
class={`w-full truncate pt-3 pb-1 text-13-regular text-text-weak ${padding()}`}
|
||||
>
|
||||
<bdi dir="auto" class="text-13-medium">
|
||||
{content().label}
|
||||
</bdi>
|
||||
<Show when={content().data}>
|
||||
{(data) => (
|
||||
<span>
|
||||
{" "}
|
||||
· <bdi dir="auto">{data()}</bdi>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-slot="session-timeline-notice" class={`w-full py-1 ${padding()}`}>
|
||||
<div class="flex min-h-5 min-w-0 items-center gap-2 overflow-hidden">
|
||||
<bdi
|
||||
dir="auto"
|
||||
class="shrink-0 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
|
||||
>
|
||||
{content().label}
|
||||
</bdi>
|
||||
<For each={content().items}>
|
||||
{(item) => (
|
||||
<bdi
|
||||
dir="auto"
|
||||
class="min-w-0 truncate text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
|
||||
>
|
||||
{item}
|
||||
</bdi>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(model) => (
|
||||
<div
|
||||
data-slot="session-timeline-notice"
|
||||
data-type="model-switched"
|
||||
class={`w-full py-2 ${padding()}`}
|
||||
>
|
||||
<TimelineSeparator
|
||||
label={model().label}
|
||||
providerID={model().providerID}
|
||||
variant={model().variant}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(message) => (
|
||||
<div
|
||||
data-slot="session-timeline-notice"
|
||||
data-type="location-switched"
|
||||
class={`flex h-7 w-full min-w-0 items-center gap-2 py-1 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint ${padding()}`}
|
||||
>
|
||||
<Tooltip
|
||||
appearance="compact"
|
||||
placement="top"
|
||||
value={i18n.t("ui.sessionTimeline.notice.movedTooltip")}
|
||||
class="shrink-0"
|
||||
triggerTabIndex={0}
|
||||
>
|
||||
<bdi data-slot="session-timeline-notice-label" dir="auto" class="font-[530]">
|
||||
{i18n.t("ui.sessionTimeline.notice.movedTo")}
|
||||
</bdi>
|
||||
</Tooltip>{" "}
|
||||
<bdi data-slot="session-timeline-notice-value" dir="ltr" class="min-w-0 truncate font-[440]">
|
||||
{message().location.directory}
|
||||
</bdi>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Notice messageID={current().messageID} />
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -536,8 +627,15 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
id={current().ref.partID}
|
||||
content={content()}
|
||||
streaming
|
||||
defaultOpen={input.reasoningMode() === "full"}
|
||||
open={input.disclosure.value(current().ref.partID)}
|
||||
defaultOpen={
|
||||
input.timelineDetail
|
||||
? input.timelineDetail().thinking.details === "expanded"
|
||||
: input.reasoningMode() === "full"
|
||||
}
|
||||
open={
|
||||
input.disclosure.value(current().ref.partID) ??
|
||||
(input.timelineDetail ? input.timelineDetail().thinking.details === "expanded" : undefined)
|
||||
}
|
||||
onOpenChange={(open) => input.disclosure.set(current().ref.partID, open)}
|
||||
onContentRendered={onSizeChange}
|
||||
/>
|
||||
|
||||
@@ -44,9 +44,11 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import {
|
||||
currentToolError,
|
||||
currentToolHasLoadedFiles,
|
||||
currentToolInput,
|
||||
currentToolMetadata,
|
||||
currentToolOutput,
|
||||
executeToolFailed,
|
||||
} from "../message/current-tool-state"
|
||||
import { AssistantReasoningContent, writeClipboard } from "../message/message-content"
|
||||
|
||||
@@ -480,7 +482,10 @@ function ExaOutput(props: { output?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextGroupPart = SessionMessageAssistantTool | (SessionMessageAssistantReasoning & { id: string })
|
||||
export type ContextGroupPart =
|
||||
| SessionMessageAssistantTool
|
||||
| (SessionMessageAssistantReasoning & { id: string; streaming?: boolean })
|
||||
| { type: "notice" | "shell"; id: string; render: () => JSX.Element }
|
||||
|
||||
export function CurrentContextToolGroup(props: {
|
||||
parts: ContextGroupPart[]
|
||||
@@ -491,6 +496,12 @@ export function CurrentContextToolGroup(props: {
|
||||
reasoningDefaultOpen?: boolean
|
||||
reasoningOpen?: (id: string) => boolean | undefined
|
||||
onReasoningOpenChange?: (id: string, open: boolean) => void
|
||||
toolDefaultOpen?: (tool: SessionMessageAssistantTool) => boolean | undefined
|
||||
toolOpen?: (id: string) => boolean | undefined
|
||||
onToolOpenChange?: (id: string, open: boolean) => void
|
||||
fileOpen?: (path: string) => boolean | undefined
|
||||
onFileOpenChange?: (path: string, open: boolean) => void
|
||||
patchGroupKey?: (tools: SessionMessageAssistantTool[]) => string
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const tools = createMemo(() => props.parts.filter((part) => part.type === "tool"))
|
||||
@@ -499,14 +510,16 @@ export function CurrentContextToolGroup(props: {
|
||||
)
|
||||
const names = createMemo(() =>
|
||||
[
|
||||
...tools().reduce((counts, tool) => {
|
||||
const input = currentToolInput(tool)
|
||||
...props.parts.reduce((counts, part) => {
|
||||
if (part.type !== "tool" && part.type !== "shell") return counts
|
||||
const name =
|
||||
tool.name === "skill"
|
||||
? i18n.t("ui.tool.skill")
|
||||
: tool.name === "subagent"
|
||||
? i18n.t("ui.tool.agent.default")
|
||||
: getToolInfo(tool.name, input, currentToolMetadata(tool)).title
|
||||
part.type !== "tool"
|
||||
? i18n.t("ui.tool.shell")
|
||||
: part.name === "skill"
|
||||
? i18n.t("ui.tool.skill")
|
||||
: part.name === "subagent"
|
||||
? i18n.t("ui.tool.agent.default")
|
||||
: getToolInfo(part.name, currentToolInput(part), currentToolMetadata(part)).title
|
||||
counts.set(name, (counts.get(name) ?? 0) + 1)
|
||||
return counts
|
||||
}, new Map<string, number>()),
|
||||
@@ -515,15 +528,22 @@ export function CurrentContextToolGroup(props: {
|
||||
.join(", "),
|
||||
)
|
||||
const label = createMemo(() => {
|
||||
const title = names()
|
||||
const notices = props.parts.filter((part) => part.type === "notice").length
|
||||
if (!names() && !notices) {
|
||||
const title = i18n.t("ui.messagePart.context.reasoning")
|
||||
return { text: title, title, before: "", after: "" }
|
||||
}
|
||||
const title = [names(), notices ? i18n.plural("ui.messagePart.context.notice", notices) : undefined]
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
const text = i18n.t("ui.messagePart.tools.used", { tools: title })
|
||||
const index = text.indexOf(title)
|
||||
return { text, title, before: text.slice(0, index).trim(), after: text.slice(index + title.length).trim() }
|
||||
})
|
||||
const items = createMemo(() =>
|
||||
props.parts.reduce<(SessionMessageAssistantTool[] | (SessionMessageAssistantReasoning & { id: string }))[]>(
|
||||
props.parts.reduce<(SessionMessageAssistantTool[] | Exclude<ContextGroupPart, SessionMessageAssistantTool>)[]>(
|
||||
(groups, tool) => {
|
||||
if (tool.type === "reasoning") {
|
||||
if (tool.type !== "tool") {
|
||||
groups.push(tool)
|
||||
return groups
|
||||
}
|
||||
@@ -556,6 +576,15 @@ export function CurrentContextToolGroup(props: {
|
||||
[],
|
||||
),
|
||||
)
|
||||
const patchKeys = createMemo(() => {
|
||||
const keys = new Map<SessionMessageAssistantTool, string>()
|
||||
items().forEach((item) => {
|
||||
if (!Array.isArray(item) || item[0]?.name !== "patch" || item[0].state.status === "error") return
|
||||
const key = props.patchGroupKey?.(item) ?? item[0].id
|
||||
item.forEach((tool) => keys.set(tool, key))
|
||||
})
|
||||
return keys
|
||||
})
|
||||
const change = (open: boolean) => {
|
||||
props.onOpenChange(open)
|
||||
props.onSizeChange?.()
|
||||
@@ -594,19 +623,30 @@ export function CurrentContextToolGroup(props: {
|
||||
})
|
||||
const reasoning = createMemo(() => {
|
||||
const value = item()
|
||||
return Array.isArray(value) ? undefined : value
|
||||
return !Array.isArray(value) && value.type === "reasoning" ? value : undefined
|
||||
})
|
||||
const callback = createMemo(() => {
|
||||
const value = item()
|
||||
return !Array.isArray(value) && (value.type === "notice" || value.type === "shell") ? value : undefined
|
||||
})
|
||||
return (
|
||||
<Show
|
||||
when={group()}
|
||||
fallback={
|
||||
<Show when={reasoning()}>
|
||||
<Show
|
||||
when={reasoning()}
|
||||
fallback={
|
||||
<Show when={callback()}>
|
||||
{(part) => <div data-slot="context-tool-group-item">{part().render()}</div>}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(part) => (
|
||||
<div data-slot="context-tool-group-item">
|
||||
<AssistantReasoningContent
|
||||
id={part().id}
|
||||
content={part()}
|
||||
streaming={false}
|
||||
streaming={part().streaming ?? false}
|
||||
defaultOpen={props.reasoningDefaultOpen}
|
||||
open={props.reasoningOpen?.(part().id)}
|
||||
onOpenChange={(open) => props.onReasoningOpenChange?.(part().id, open)}
|
||||
@@ -636,7 +676,8 @@ export function CurrentContextToolGroup(props: {
|
||||
when={
|
||||
tool().state.status !== "error" &&
|
||||
["read", "glob", "grep", "list"].includes(tool().name) &&
|
||||
!(tool().name === "read" && readImagePath(currentToolInput(tool())))
|
||||
!(tool().name === "read" && readImagePath(currentToolInput(tool()))) &&
|
||||
!currentToolHasLoadedFiles(tool())
|
||||
}
|
||||
fallback={
|
||||
<Show
|
||||
@@ -653,14 +694,28 @@ export function CurrentContextToolGroup(props: {
|
||||
output={currentToolOutput(tool())}
|
||||
error={currentToolError(tool())}
|
||||
status={tool().state.status}
|
||||
defaultOpen={false}
|
||||
defaultOpen={props.toolDefaultOpen?.(tool()) ?? false}
|
||||
open={props.toolOpen?.(tool().id) ?? props.toolDefaultOpen?.(tool())}
|
||||
onOpenChange={(open) => props.onToolOpenChange?.(tool().id, open)}
|
||||
deferContent
|
||||
virtualizeDiff={false}
|
||||
onContentRendered={props.onSizeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CurrentFileToolGroup tools={group()} onSizeChange={props.onSizeChange} />
|
||||
<CurrentFileToolGroup
|
||||
tools={group()}
|
||||
fileOpen={
|
||||
props.fileOpen &&
|
||||
((path) => props.fileOpen?.(`${patchKeys().get(tool())}:${path}`))
|
||||
}
|
||||
onFileOpenChange={
|
||||
props.onFileOpenChange &&
|
||||
((path, open) =>
|
||||
props.onFileOpenChange?.(`${patchKeys().get(tool())}:${path}`, open))
|
||||
}
|
||||
onSizeChange={props.onSizeChange}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
@@ -1044,19 +1099,7 @@ function toolErrorSubtitle(props: ToolProps, i18n: UiI18n) {
|
||||
function toolDisplayError(props: ToolProps & { error?: string }, fallback: string) {
|
||||
if (props.status === "error") return props.error
|
||||
if (props.tool !== "execute") return undefined
|
||||
const calls = props.metadata.toolCalls
|
||||
const failed =
|
||||
props.metadata.error === true ||
|
||||
(Array.isArray(calls) &&
|
||||
calls.some(
|
||||
(call) =>
|
||||
call !== null &&
|
||||
typeof call === "object" &&
|
||||
!Array.isArray(call) &&
|
||||
"status" in call &&
|
||||
call.status === "error",
|
||||
))
|
||||
if (!failed) return undefined
|
||||
if (!executeToolFailed(props.metadata)) return undefined
|
||||
if (typeof props.output === "string" && props.output) return props.output
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -181,6 +181,8 @@ export function Select<T>(props: SelectProps<T>) {
|
||||
>
|
||||
<Trigger
|
||||
as="div"
|
||||
aria-label={props["aria-label"]}
|
||||
aria-labelledby={props["aria-labelledby"]}
|
||||
data-component="select-v2"
|
||||
data-appearance="inline"
|
||||
data-invalid={local.invalid ? "" : undefined}
|
||||
|
||||
@@ -108,6 +108,9 @@ const source = {
|
||||
"ui.messagePart.context.search.other": "{{count}} searches",
|
||||
"ui.messagePart.context.list.one": "{{count}} list",
|
||||
"ui.messagePart.context.list.other": "{{count}} lists",
|
||||
"ui.messagePart.context.notice.one": "{{count}} notice",
|
||||
"ui.messagePart.context.notice.other": "{{count}} notices",
|
||||
"ui.messagePart.context.reasoning": "Reasoning",
|
||||
"ui.messagePart.context.match.one": "({{count}} match)",
|
||||
"ui.messagePart.context.match.other": "({{count}} matches)",
|
||||
"ui.messagePart.tools.used": "Used {{tools}}",
|
||||
|
||||
@@ -62,6 +62,9 @@ await $`bun ./packages/cli/script/publish.ts`
|
||||
console.log("\n=== plugin ===\n")
|
||||
await $`bun ./packages/plugin/script/publish.ts`
|
||||
|
||||
console.log("\n=== plugin-browser ===\n")
|
||||
await $`bun ./packages/plugin-browser/script/publish.ts`
|
||||
|
||||
console.log("\n=== core ===\n")
|
||||
await $`bun ./packages/core/script/publish.ts`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user