mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 19:31:39 -04:00
feat(app): stack collapsed tool calls (#45176)
This commit is contained in:
@@ -9,7 +9,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
|
||||
reducedMotion: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
const trigger = page.getByRole("button", { name: "Used Shell" })
|
||||
await trigger.focus()
|
||||
const before = await scroller.evaluate((element) => element.scrollTop)
|
||||
await trigger.press("Space")
|
||||
|
||||
@@ -40,7 +40,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
expect(samples.at(-1)?.expanded).toBe("true")
|
||||
})
|
||||
|
||||
test("paints a stable exploring to explored transition", async ({ page }) => {
|
||||
test("keeps a grouped tool summary stable as its calls complete", async ({ page }) => {
|
||||
const events: OpenCodeEvent[] = []
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockServer(page, events, [
|
||||
@@ -55,13 +55,12 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
|
||||
await expectAppVisible(context)
|
||||
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
|
||||
|
||||
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
status: {
|
||||
selector: `${contextSelector} [data-component="tool-status-title"]`,
|
||||
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
|
||||
selector: `${contextSelector} [data-component="context-tool-group-trigger"]`,
|
||||
},
|
||||
context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
@@ -89,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
|
||||
await page.waitForTimeout(700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const labels = trace.samples
|
||||
@@ -108,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
expect(labels).toEqual(["Exploring", "Explored"])
|
||||
expect(labels).toEqual(["Used Read, Glob, Grep, List"])
|
||||
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -209,13 +208,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
|
||||
const content: SessionMessageAssistant["content"] = target
|
||||
? [
|
||||
toolContent(
|
||||
contextTool(
|
||||
contextIDs[0]!,
|
||||
assistantID,
|
||||
"read",
|
||||
{ path: "src/recent-a.ts", offset: 0, limit: 120 },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[0]!, assistantID, "read", { path: "src/recent-a.ts", offset: 0, limit: 120 }, status),
|
||||
),
|
||||
toolContent(contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status)),
|
||||
toolContent(
|
||||
|
||||
@@ -83,6 +83,7 @@ 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 },
|
||||
messages: [
|
||||
userMessage([userText("Preceding context ".repeat(120))]),
|
||||
assistantMessage([
|
||||
|
||||
@@ -26,7 +26,9 @@ for (const expanded of [false, true]) {
|
||||
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
|
||||
settings: { shellToolPartsExpanded: expanded },
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
|
||||
const trigger = expanded
|
||||
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
|
||||
: page.getByRole("button", { name: "Used Shell" })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const profile of [
|
||||
{ locale: "de", label: "Erkundung abgeschlossen" },
|
||||
{ locale: "ar", label: "تم الاستكشاف" },
|
||||
] as const) {
|
||||
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
|
||||
const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`]
|
||||
for (const locale of ["de", "ar"] as const) {
|
||||
test(`projects localized tool names with an English fallback in ${locale}`, async ({ page }) => {
|
||||
const ids = [`prt_locale_${locale}_01_read`, `prt_locale_${locale}_02_glob`]
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
@@ -15,11 +12,12 @@ for (const profile of [
|
||||
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
]),
|
||||
],
|
||||
locale: profile.locale,
|
||||
locale,
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", profile.locale)
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,9 +53,14 @@ test.describe("session timeline projection", () => {
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'),
|
||||
).toBeVisible()
|
||||
const first = page.locator(
|
||||
'[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list,prt_webfetch,prt_websearch,prt_task,prt_bash,prt_edit,prt_write,prt_patch"]',
|
||||
)
|
||||
const second = page.locator('[data-timeline-part-ids="prt_skill,prt_custom"]')
|
||||
await expect(first).toBeVisible()
|
||||
await expect(second).toBeVisible()
|
||||
await first.getByRole("button").click()
|
||||
await second.getByRole("button").click()
|
||||
for (const id of [
|
||||
"prt_webfetch",
|
||||
"prt_websearch",
|
||||
@@ -78,8 +83,7 @@ test.describe("session timeline projection", () => {
|
||||
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
|
||||
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
|
||||
const edit = page.locator('[data-timeline-part-id="prt_edit"]')
|
||||
await expect(edit.locator('[data-component="apply-patch-tool"]')).toBeVisible()
|
||||
await expect(edit.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
|
||||
await expect(edit).toContainText("Edit")
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -87,6 +91,7 @@ test.describe("session timeline projection", () => {
|
||||
const first = "prt_patch_first"
|
||||
const second = "prt_patch_second"
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { editToolPartsExpanded: true },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
|
||||
test("groups every collapsed tool until visible text separates the stack", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }),
|
||||
textPart("prt_boundary_02_text", "Boundary text"),
|
||||
@@ -25,9 +25,112 @@ test("groups singleton and separated context operations at correct boundaries",
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
|
||||
const group = page.locator(
|
||||
'[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep,prt_boundary_05_shell,prt_boundary_06_list"]',
|
||||
)
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
|
||||
await expect(page.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
|
||||
await expect(page.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
|
||||
})
|
||||
|
||||
test("expands a mixed collapsed tool stack without expanding its individual calls", async ({ page }) => {
|
||||
const parts = [
|
||||
shell("prt_stack_shell_1", "completed", "first"),
|
||||
toolPart("prt_stack_explore", "subagent", "completed", {
|
||||
agent: "explore",
|
||||
description: "Inspect the project",
|
||||
prompt: "Explore the project",
|
||||
}),
|
||||
toolPart("prt_stack_patch", "patch", "completed", { patchText: "Update src/value.ts" }),
|
||||
shell("prt_stack_shell_2", "completed", "second"),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const group = page.locator(
|
||||
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
|
||||
)
|
||||
const summary = group.getByRole("button", { name: "Used Shell, Explore, Patch" })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCSS("height", "28px")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
|
||||
await summary.click()
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveCount(4)
|
||||
await expect(group.locator('[data-timeline-part-id="prt_stack_shell_1"]')).toBeVisible()
|
||||
await expect(group.locator('[data-timeline-part-id="prt_stack_patch"]')).toBeVisible()
|
||||
await expect(group.locator('[data-component="context-tool-group-list"]')).toHaveCSS("row-gap", "8px")
|
||||
const content = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-content"]')
|
||||
await expect(content).toHaveCSS("margin-left", "0px")
|
||||
await expect(content).toHaveCSS("padding-left", "12px")
|
||||
await expect.poll(() => content.evaluate((element) => getComputedStyle(element, "::before").content)).toBe("none")
|
||||
})
|
||||
|
||||
test("leaves tools expanded by settings outside the collapsed stack", async ({ page }) => {
|
||||
const parts = [
|
||||
shell("prt_expanded_shell", "completed", "expanded"),
|
||||
toolPart("prt_collapsed_patch", "patch", "completed", { patchText: "Update src/value.ts" }),
|
||||
toolPart("prt_collapsed_read", "read", "completed", { path: "src/value.ts" }),
|
||||
]
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage(parts)],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
|
||||
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
|
||||
await expect(group.getByRole("button", { name: "Used Patch, Read" })).toBeVisible()
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
|
||||
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
|
||||
})
|
||||
|
||||
test("keeps failed search calls and their error cards inside the collapsed stack", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart(
|
||||
"prt_error_glob",
|
||||
"glob",
|
||||
"error",
|
||||
{ path: "C:/Users", pattern: "*.ts" },
|
||||
{
|
||||
error: "Invalid tool input",
|
||||
},
|
||||
),
|
||||
toolPart(
|
||||
"prt_error_grep",
|
||||
"grep",
|
||||
"error",
|
||||
{ path: "C:/Users", pattern: "value" },
|
||||
{
|
||||
error: "Search timed out after 30 seconds",
|
||||
},
|
||||
),
|
||||
]
|
||||
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 Glob, Grep" })
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
|
||||
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(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(
|
||||
"href",
|
||||
"#opencode-v2-icon-circle-exclamation",
|
||||
)
|
||||
await expect
|
||||
.poll(() =>
|
||||
glob
|
||||
.locator('[data-kind="tool-error-card"]')
|
||||
.evaluate((element) => getComputedStyle(element, "::before").display),
|
||||
)
|
||||
.toBe("none")
|
||||
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
|
||||
"Search timed out after 30 seconds",
|
||||
)
|
||||
})
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
|
||||
@@ -21,6 +21,9 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
|
||||
)
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ordinary.map((_, index) => `prt_error_${index}`).join(",")}"]`)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText(String(ordinary.length))
|
||||
await group.getByRole("button").click()
|
||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
||||
@@ -33,6 +36,7 @@ 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 },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
@@ -44,7 +48,6 @@ test("transitions shell and question through running error outcomes", async ({ p
|
||||
),
|
||||
],
|
||||
})
|
||||
await timeline.waitForPart(shellID)
|
||||
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)
|
||||
@@ -68,6 +71,7 @@ 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 },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
@@ -147,10 +151,12 @@ test("labels all web search provider variants", async ({ page }) => {
|
||||
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
await page.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
|
||||
|
||||
await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
||||
const tools = page.locator('[data-component="context-tool-group-list"]')
|
||||
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
|
||||
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
await expect(tools.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("labels completed searches with result counts", async ({ page }) => {
|
||||
@@ -201,6 +207,11 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${pending},${completed}"]`)
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
|
||||
await group.getByRole("button").click()
|
||||
|
||||
for (const [id, name] of [
|
||||
[pending, "frontend-design"],
|
||||
[completed, "OpenCode"],
|
||||
|
||||
@@ -42,8 +42,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.pathname === `/api/session/${childID}/message` &&
|
||||
url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
url.pathname === `/api/session/${childID}/message` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
async (route) => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
@@ -53,6 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
|
||||
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "Used Explore" }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
|
||||
await Promise.all([
|
||||
@@ -77,6 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
await page.getByRole("button", { name: "Used Explore" }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await requested.promise
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
|
||||
@@ -194,6 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
async function openChildFromParent(page: Page) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "Used Explore" }).click()
|
||||
|
||||
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
@@ -105,6 +105,8 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
sessionMessages: projectedMessages,
|
||||
status: input.session.data.status,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
shellToolDefaultOpen: settings.general.shellToolPartsExpanded,
|
||||
editToolDefaultOpen: settings.general.editToolPartsExpanded,
|
||||
pendingUserMessageIDs,
|
||||
})
|
||||
const [pending, setPending] = createStore({ rename: false })
|
||||
|
||||
@@ -8,6 +8,8 @@ export function createTimelineProjection(input: {
|
||||
sessionMessages: Accessor<SessionMessageInfo[]>
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
shellToolDefaultOpen: Accessor<boolean>
|
||||
editToolDefaultOpen: Accessor<boolean>
|
||||
pendingUserMessageIDs: Accessor<ReadonlySet<string>>
|
||||
}) {
|
||||
const sessionMessageByID = createMemo(
|
||||
@@ -81,6 +83,8 @@ export function createTimelineProjection(input: {
|
||||
input.showReasoningSummaries(),
|
||||
input.status(),
|
||||
input.pendingUserMessageIDs(),
|
||||
input.shellToolDefaultOpen(),
|
||||
input.editToolDefaultOpen(),
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||
|
||||
@@ -284,19 +284,7 @@
|
||||
|
||||
[data-component="collapsible"].tool-collapsible:not([data-rail="false"]) {
|
||||
> [data-slot="collapsible-content"] {
|
||||
position: relative;
|
||||
margin-inline-start: 12px;
|
||||
padding-inline-start: 16px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
top: 0;
|
||||
bottom: 12px;
|
||||
width: 0.5px;
|
||||
background-color: var(--v2-border-border-muted, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
padding-inline-start: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -263,6 +263,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-timeline-row="AssistantPart"][data-timeline-spacing="content"] [data-component="text-part"] {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
[data-component="reasoning-part"] {
|
||||
width: 100%;
|
||||
color: var(--v2-text-text-muted);
|
||||
@@ -663,28 +667,66 @@
|
||||
cursor: default;
|
||||
|
||||
[data-slot="context-tool-group-title"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="context-tool-group-prefix"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-title"] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-component="tag"] {
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
background: var(--v2-background-bg-layer-03);
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
color: var(--icon-weaker);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="collapsed-tool-group"] > [data-component="collapsible"].tool-collapsible {
|
||||
--tool-content-gap: 8px;
|
||||
}
|
||||
|
||||
[data-component="context-tool-group-list"] {
|
||||
/* The 28px compact trigger centers a 16px line box, already leaving 6px above this list. */
|
||||
padding: 4px 0 0 12px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 16px line boxes with 13px gaps reproduce the design's 29px row pitch for 13px solid rows. */
|
||||
gap: 13px;
|
||||
gap: 8px;
|
||||
|
||||
[data-slot="context-tool-group-item"] {
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
opacity: 0.8;
|
||||
|
||||
> [data-component="tool-part-wrapper"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> [data-component="tool-part-wrapper"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"] {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-structured"] {
|
||||
gap: 6px;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
--card-pad-r: 0px;
|
||||
|
||||
&::before {
|
||||
border-radius: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Figma's leading-none would clip Inter descenders at 13px; keep the compact metric. */
|
||||
|
||||
@@ -97,8 +97,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<span data-slot="basic-tool-tool-indicator" data-component="tool-error-card-icon">
|
||||
{/* 20px-viewBox path at 16px: 1.25 renders the 1px stroke Figma specifies. */}
|
||||
<Icon name="circle-ban-sign" style={{ "stroke-width": 1.25 }} />
|
||||
<Icon name="circle-exclamation" />
|
||||
</span>
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
|
||||
@@ -26,7 +26,8 @@ describe("current content default open", () => {
|
||||
test("uses the file-change disclosure preference", () => {
|
||||
expect(currentContentDefaultOpen(tool("edit"), false, true)).toBe(true)
|
||||
expect(currentContentDefaultOpen(tool("write"), false, false)).toBe(false)
|
||||
expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(true)
|
||||
expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(false)
|
||||
expect(currentContentDefaultOpen(tool("patch"), false, true)).toBe(true)
|
||||
})
|
||||
|
||||
test("collapses errored tools regardless of disclosure preferences", () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function currentContentDefaultOpen(
|
||||
// Errored tools render the error card, which starts collapsed.
|
||||
if (content.state.status === "error") return false
|
||||
if (content.name === "shell" || content.name === "execute") return shellExpanded
|
||||
if (content.name === "patch") return true
|
||||
if (content.name === "patch") return editExpanded
|
||||
if (content.name !== "edit" && content.name !== "write") return undefined
|
||||
if (!editExpanded) return false
|
||||
const files = currentToolMetadata(content).files
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Option, Schema } from "effect"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { currentContentDefaultOpen } from "../message/current-tool-state"
|
||||
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
|
||||
|
||||
export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
|
||||
@@ -18,13 +19,14 @@ type Content = SessionMessageAssistant["content"][number]
|
||||
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||
type PriorGroup = { index: number; row: GroupRow }
|
||||
|
||||
const contextTools = new Set(["read", "glob", "grep", "list"])
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
export type TimelineProjectionInput = {
|
||||
sessionMessages: SessionMessageInfo[]
|
||||
status: SessionStatus
|
||||
showReasoningSummaries: boolean
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
pendingUserMessageIDs?: ReadonlySet<string>
|
||||
previousRows?: TimelineRow.TimelineRow[]
|
||||
}
|
||||
@@ -36,6 +38,8 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
|
||||
input.showReasoningSummaries,
|
||||
input.status,
|
||||
input.pendingUserMessageIDs,
|
||||
input.shellToolDefaultOpen ?? false,
|
||||
input.editToolDefaultOpen ?? false,
|
||||
)
|
||||
const rows = reuseTimelineRows(input.previousRows, projection.rows)
|
||||
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row] as const))
|
||||
@@ -67,6 +71,8 @@ export function createReactiveTimelineProjection(input: {
|
||||
sessionMessages: Accessor<SessionMessageInfo[]>
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
shellToolDefaultOpen?: Accessor<boolean>
|
||||
editToolDefaultOpen?: Accessor<boolean>
|
||||
pendingUserMessageIDs?: Accessor<ReadonlySet<string>>
|
||||
}) {
|
||||
const sessionMessageByID = createMemo(
|
||||
@@ -80,6 +86,8 @@ export function createReactiveTimelineProjection(input: {
|
||||
input.showReasoningSummaries(),
|
||||
input.status(),
|
||||
input.pendingUserMessageIDs?.(),
|
||||
input.shellToolDefaultOpen?.() ?? false,
|
||||
input.editToolDefaultOpen?.() ?? false,
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||
@@ -128,6 +136,8 @@ export namespace Timeline {
|
||||
showReasoning: boolean,
|
||||
status: SessionStatus,
|
||||
pendingUserMessageIDs?: ReadonlySet<string>,
|
||||
shellToolDefaultOpen = false,
|
||||
editToolDefaultOpen = false,
|
||||
) {
|
||||
type Turn = {
|
||||
id: string
|
||||
@@ -204,6 +214,8 @@ export namespace Timeline {
|
||||
showReasoning,
|
||||
status,
|
||||
turn.id === activeMessageID,
|
||||
shellToolDefaultOpen,
|
||||
editToolDefaultOpen,
|
||||
)
|
||||
}),
|
||||
],
|
||||
@@ -218,6 +230,8 @@ export namespace Timeline {
|
||||
showReasoning: boolean,
|
||||
status: SessionStatus,
|
||||
isActive: boolean,
|
||||
shellToolDefaultOpen = false,
|
||||
editToolDefaultOpen = false,
|
||||
) {
|
||||
const rows: TimelineRow.TimelineRow[] = []
|
||||
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
|
||||
@@ -237,6 +251,7 @@ export namespace Timeline {
|
||||
if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID }))
|
||||
|
||||
let assistantGroupIndex = 0
|
||||
let previousAssistantTool = false
|
||||
// An assistant message can produce several rows because its content parts are
|
||||
// rendered separately. Notices end a segment so none of those rows cross it.
|
||||
const appendAssistantSegment = (messages: SessionMessageAssistant[]) => {
|
||||
@@ -248,17 +263,23 @@ export namespace Timeline {
|
||||
const interruptedAt = messages.findIndex((message) => isInterrupted(message.error))
|
||||
const before = interruptedAt < 0 ? refs : refs.filter((ref) => ref.messageIndex <= interruptedAt)
|
||||
const after = interruptedAt < 0 ? [] : refs.filter((ref) => ref.messageIndex > interruptedAt)
|
||||
const appendGroups = (items: typeof refs) =>
|
||||
groupContent(items).forEach((group) => {
|
||||
const appendGroups = (items: typeof refs) => {
|
||||
let offset = 0
|
||||
groupContent(items, shellToolDefaultOpen, editToolDefaultOpen).forEach((group) => {
|
||||
const tool = group.type !== "part" || items[offset]?.content.type === "tool"
|
||||
offset += group.type === "part" ? 1 : group.refs.length
|
||||
rows.push(
|
||||
new TimelineRow.AssistantPart({
|
||||
userMessageID: turnID,
|
||||
group,
|
||||
previousAssistantPart: assistantGroupIndex > 0,
|
||||
spacing: assistantGroupIndex > 0 ? (previousAssistantTool && tool ? "tool" : "content") : undefined,
|
||||
}),
|
||||
)
|
||||
assistantGroupIndex += 1
|
||||
previousAssistantTool = tool
|
||||
})
|
||||
}
|
||||
|
||||
appendGroups(before)
|
||||
if (interruptedAt >= 0) {
|
||||
@@ -444,6 +465,7 @@ function stabilizeGroupKey(
|
||||
return new TimelineRow.AssistantPart({
|
||||
userMessageID: row.userMessageID,
|
||||
previousAssistantPart: row.previousAssistantPart,
|
||||
spacing: row.spacing,
|
||||
group: { ...row.group, key: existing.row.group.key },
|
||||
})
|
||||
}
|
||||
@@ -462,7 +484,11 @@ function renderable(content: Content, showReasoning: boolean) {
|
||||
return true
|
||||
}
|
||||
|
||||
function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] {
|
||||
function groupContent(
|
||||
items: { messageID: string; partID: string; content: Content }[],
|
||||
shellToolDefaultOpen: boolean,
|
||||
editToolDefaultOpen: boolean,
|
||||
): PartGroup[] {
|
||||
const groups: PartGroup[] = []
|
||||
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[] } | undefined
|
||||
const flush = () => {
|
||||
@@ -482,13 +508,7 @@ function groupContent(items: { messageID: string; partID: string; content: Conte
|
||||
|
||||
items.forEach((item) => {
|
||||
const type =
|
||||
item.content.type === "tool" && contextTools.has(item.content.name) && !hasLoadedFiles(item.content)
|
||||
? "context"
|
||||
: item.content.type === "tool" && item.content.name === "patch" && item.content.state.status !== "error"
|
||||
? "patch"
|
||||
: item.content.type === "tool" && item.content.name === "edit" && item.content.state.status !== "error"
|
||||
? "edit"
|
||||
: undefined
|
||||
item.content.type === "tool" ? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen) : undefined
|
||||
if (type) {
|
||||
if (adjacent?.type !== type) flush()
|
||||
adjacent ??= { type, refs: [] }
|
||||
@@ -506,6 +526,26 @@ function groupContent(items: { messageID: string; partID: string; content: Conte
|
||||
return groups
|
||||
}
|
||||
|
||||
function toolGroupType(content: Extract<Content, { type: "tool" }>, shellExpanded: boolean, editExpanded: boolean) {
|
||||
if (content.name === "question" || hasLoadedFiles(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)
|
||||
return undefined
|
||||
return "context"
|
||||
}
|
||||
if (
|
||||
(content.state.status !== "completed" ||
|
||||
("metadata" in content.state && content.state.metadata?.status === "running")) &&
|
||||
(content.name === "shell" || content.name === "execute" || content.name === "subagent")
|
||||
)
|
||||
return undefined
|
||||
if (currentContentDefaultOpen(content, shellExpanded, editExpanded) !== true) return "context"
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
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
|
||||
|
||||
@@ -514,7 +514,9 @@ describe("current session timeline rows", () => {
|
||||
assistant("msg_assistant_3", "grep"),
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const keys = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.map(TimelineRow.key)
|
||||
const keys = Timeline.constructSessionMessageRows(source, false, { type: "idle" }, undefined, true).rows.map(
|
||||
TimelineRow.key,
|
||||
)
|
||||
|
||||
expect(keys).toEqual([
|
||||
"user-message:msg_user",
|
||||
@@ -595,7 +597,7 @@ describe("current session timeline rows", () => {
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" })
|
||||
const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" }, undefined, false, true)
|
||||
const groups = result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group] : []))
|
||||
|
||||
expect(groups).toEqual([
|
||||
@@ -628,6 +630,224 @@ describe("current session timeline rows", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("groups every consecutive collapsed tool in chronological order", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
...["shell", "subagent", "patch", "shell", "edit", "write", "grep"].map(
|
||||
(name, index): SessionMessageAssistantTool => ({
|
||||
type: "tool" as const,
|
||||
id: `tool_${index}`,
|
||||
name,
|
||||
state: {
|
||||
status: "completed" as const,
|
||||
input: {},
|
||||
content: [{ type: "text" as const, text: "done" }],
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: index + 2, completed: index + 3 },
|
||||
}),
|
||||
),
|
||||
{ type: "text" as const, text: "finished" },
|
||||
{
|
||||
type: "tool" as const,
|
||||
id: "tool_after_text",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "completed" as const,
|
||||
input: {},
|
||||
content: [{ type: "text" as const, text: "done" }],
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: 10, completed: 11 },
|
||||
},
|
||||
],
|
||||
time: { created: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const groups = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.flatMap((row) =>
|
||||
row._tag === "AssistantPart" ? [row.group] : [],
|
||||
)
|
||||
|
||||
expect(groups.map((group) => group.type)).toEqual(["context", "part", "context"])
|
||||
expect(groups[0]?.type === "context" ? groups[0].refs.map((ref) => ref.partID) : []).toEqual([
|
||||
"tool_0",
|
||||
"tool_1",
|
||||
"tool_2",
|
||||
"tool_3",
|
||||
"tool_4",
|
||||
"tool_5",
|
||||
"tool_6",
|
||||
])
|
||||
expect(groups[2]?.type === "context" ? groups[2].refs.map((ref) => ref.partID) : []).toEqual(["tool_after_text"])
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ shell: false, edit: false, types: ["context"] },
|
||||
{ shell: true, edit: false, types: ["part", "context"] },
|
||||
{ shell: false, edit: true, types: ["context", "file", "part", "file", "context"] },
|
||||
{ shell: true, edit: true, types: ["part", "file", "part", "file", "context"] },
|
||||
])("keeps tools expanded by settings outside collapsed groups ($shell, $edit)", ({ shell, edit, types }) => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: ["shell", "edit", "write", "patch", "read"].map(
|
||||
(name, index): SessionMessageAssistantTool => ({
|
||||
type: "tool" as const,
|
||||
id: `tool_${name}`,
|
||||
name,
|
||||
state: {
|
||||
status: "completed" as const,
|
||||
input: {},
|
||||
content: [{ type: "text" as const, text: "done" }],
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: index + 2, completed: index + 3 },
|
||||
}),
|
||||
),
|
||||
time: { created: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const rows = Timeline.constructSessionMessageRows(source, false, { type: "idle" }, undefined, shell, edit).rows
|
||||
|
||||
expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...types])
|
||||
})
|
||||
|
||||
test("keeps active and background work visible outside collapsed stacks", () => {
|
||||
const source: SessionMessageInfo[] = [
|
||||
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_running_shell",
|
||||
name: "shell",
|
||||
state: { status: "running", input: {}, metadata: {} },
|
||||
time: { created: 2 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_background_agent",
|
||||
name: "subagent",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "running" }],
|
||||
metadata: { status: "running" },
|
||||
},
|
||||
time: { created: 3, completed: 4 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_completed_shell",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "done" }],
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: 5, completed: 6 },
|
||||
},
|
||||
],
|
||||
time: { created: 2 },
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
Timeline.constructSessionMessageRows(source, false, { type: "busy" }).rows.flatMap((row) =>
|
||||
row._tag === "AssistantPart" ? [row.group.type] : [],
|
||||
),
|
||||
).toEqual(["part", "part", "context"])
|
||||
})
|
||||
|
||||
test("keeps failed calls inside a collapsed mixed-tool stack", () => {
|
||||
const source: SessionMessageInfo[] = [
|
||||
{ id: "msg_user", type: "user", text: "search", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_glob_failed",
|
||||
name: "glob",
|
||||
state: {
|
||||
status: "error",
|
||||
input: { pattern: "*.ts" },
|
||||
error: { type: "ToolError", message: "Invalid tool input" },
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_grep_failed",
|
||||
name: "grep",
|
||||
state: {
|
||||
status: "error",
|
||||
input: { pattern: "value" },
|
||||
error: { type: "ToolError", message: "Search timed out" },
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: 4, completed: 5 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_shell_failed",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "error",
|
||||
input: { command: "exit 1" },
|
||||
error: { type: "ToolError", message: "Command failed" },
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: 6, completed: 7 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 8 },
|
||||
},
|
||||
]
|
||||
|
||||
const groups = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.flatMap((row) =>
|
||||
row._tag === "AssistantPart" ? [row.group] : [],
|
||||
)
|
||||
|
||||
expect(groups).toEqual([
|
||||
{
|
||||
type: "context",
|
||||
key: "context:msg_assistant:tool_glob_failed",
|
||||
refs: [
|
||||
{ messageID: "msg_assistant", partID: "tool_glob_failed" },
|
||||
{ messageID: "msg_assistant", partID: "tool_grep_failed" },
|
||||
{ messageID: "msg_assistant", partID: "tool_shell_failed" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(
|
||||
Timeline.constructSessionMessageRows(source, false, { type: "idle" }, undefined, true).rows.flatMap((row) =>
|
||||
row._tag === "AssistantPart" ? [row.group.type] : [],
|
||||
),
|
||||
).toEqual(["context", "part"])
|
||||
})
|
||||
|
||||
test("places a divider after interrupted output unless the turn compacts", () => {
|
||||
const messages = [
|
||||
{ id: "msg_user", type: "user", text: "continue", time: { created: 1 } },
|
||||
|
||||
@@ -229,10 +229,12 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
id={props.row._tag === "UserMessage" ? input.anchor?.(props.row.userMessageID) : undefined}
|
||||
data-message-id={props.row.userMessageID}
|
||||
data-timeline-row={props.row._tag}
|
||||
data-timeline-spacing={props.row._tag === "AssistantPart" ? props.row.spacing : undefined}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-[1000px] md:mx-auto": input.centered?.(),
|
||||
"pt-3": props.row._tag === "AssistantPart" && props.row.previousAssistantPart,
|
||||
"pt-2": props.row._tag === "AssistantPart" && props.row.spacing === "tool",
|
||||
"pt-4": props.row._tag === "AssistantPart" && props.row.spacing === "content",
|
||||
}}
|
||||
>
|
||||
<div data-component="session-turn" class="min-w-0 w-full relative" style={{ height: "auto" }}>
|
||||
|
||||
@@ -22,6 +22,8 @@ export function SessionTimeline(props: SessionTimelineProps) {
|
||||
sessionMessages: () => props.document.messages,
|
||||
status: () => props.document.status,
|
||||
showReasoningSummaries: () => props.showReasoningSummaries ?? true,
|
||||
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
|
||||
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
|
||||
})
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
|
||||
const renderer = createSessionTimelineRowRenderer({
|
||||
|
||||
@@ -49,6 +49,7 @@ export namespace TimelineRow {
|
||||
userMessageID: string
|
||||
group: PartGroup
|
||||
previousAssistantPart: boolean
|
||||
spacing?: "tool" | "content"
|
||||
}> {}
|
||||
|
||||
export class Thinking extends Data.TaggedClass("Thinking")<{
|
||||
@@ -118,6 +119,7 @@ export type TimelineRowMap = {
|
||||
userMessageID: string
|
||||
group: PartGroup
|
||||
previousAssistantPart: boolean
|
||||
spacing?: "tool" | "content"
|
||||
}
|
||||
Thinking: { userMessageID: string; reasoningHeading?: string }
|
||||
Retry: { userMessageID: string }
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { type UiI18n, useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { BasicTool, GenericTool } from "../components/basic-tool"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { Badge } from "@opencode-ai/ui/badge"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
@@ -32,13 +33,16 @@ import { checksum } from "@opencode-ai/util/encode"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { AnimatedCountList } from "../components/tool-count-summary"
|
||||
import { ToolStatusTitle } from "../components/tool-status-title"
|
||||
import { changedFileDiff, patchFileGroups } from "../components/apply-patch-file"
|
||||
import { animate } from "motion"
|
||||
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
|
||||
import type { SessionMessageAssistantTool, SessionMessageShell } from "@opencode-ai/client/promise"
|
||||
import { currentToolInput, currentToolMetadata } from "../message/current-tool-state"
|
||||
import {
|
||||
currentToolError,
|
||||
currentToolInput,
|
||||
currentToolMetadata,
|
||||
currentToolOutput,
|
||||
} from "../message/current-tool-state"
|
||||
import { writeClipboard } from "../message/message-content"
|
||||
|
||||
function ShellSubmessage(props: { text: string; animate?: boolean }) {
|
||||
@@ -477,50 +481,50 @@ export function CurrentContextToolGroup(props: {
|
||||
() =>
|
||||
props.busy || props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
|
||||
)
|
||||
const summary = createMemo(() => ({
|
||||
read: props.tools.filter((tool) => tool.name === "read").length,
|
||||
search: props.tools.filter((tool) => tool.name === "glob" || tool.name === "grep").length,
|
||||
list: props.tools.filter((tool) => tool.name === "list").length,
|
||||
}))
|
||||
const names = createMemo(() =>
|
||||
[
|
||||
...new Set(
|
||||
props.tools.map((tool) => {
|
||||
const input = currentToolInput(tool)
|
||||
if (tool.name === "skill") return i18n.t("ui.tool.skill")
|
||||
if (tool.name === "subagent" && typeof input.agent === "string" && input.agent)
|
||||
return input.agent[0]!.toUpperCase() + input.agent.slice(1)
|
||||
return getToolInfo(tool.name, input, currentToolMetadata(tool)).title
|
||||
}),
|
||||
),
|
||||
].join(", "),
|
||||
)
|
||||
const label = createMemo(() => {
|
||||
const tools = names()
|
||||
const text = i18n.t("ui.messagePart.tools.used", { tools })
|
||||
const index = text.indexOf(tools)
|
||||
return { text, before: text.slice(0, index).trim(), after: text.slice(index + tools.length).trim() }
|
||||
})
|
||||
const change = (open: boolean) => {
|
||||
props.onOpenChange(open)
|
||||
props.onSizeChange?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}>
|
||||
<div data-component="collapsed-tool-group" data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}>
|
||||
<BasicTool
|
||||
icon="glasses"
|
||||
status={pending() ? "running" : "completed"}
|
||||
compact
|
||||
rail={false}
|
||||
allowOpenWhilePending
|
||||
open={props.open}
|
||||
onOpenChange={change}
|
||||
trigger={
|
||||
<div data-component="context-tool-group-trigger">
|
||||
<span data-slot="context-tool-group-title" class="min-w-0 flex items-center gap-2">
|
||||
<span data-slot="basic-tool-tool-title" class="shrink-0">
|
||||
<ToolStatusTitle
|
||||
active={pending()}
|
||||
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
|
||||
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
|
||||
split={false}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<AnimatedCountList
|
||||
items={[
|
||||
{ key: "ui.messagePart.context.read", count: summary().read },
|
||||
{ key: "ui.messagePart.context.search", count: summary().search },
|
||||
{ key: "ui.messagePart.context.list", count: summary().list },
|
||||
]}
|
||||
fallback=""
|
||||
/>
|
||||
</span>
|
||||
<div data-component="context-tool-group-trigger" aria-label={label().text}>
|
||||
<span data-slot="context-tool-group-title">
|
||||
<Show when={label().before}>
|
||||
{(before) => <span data-slot="context-tool-group-prefix">{before()}</span>}
|
||||
</Show>
|
||||
<span data-slot="basic-tool-tool-title">{names()}</span>
|
||||
<Show when={label().after}>
|
||||
{(after) => <span data-slot="context-tool-group-prefix">{after()}</span>}
|
||||
</Show>
|
||||
<Badge>{props.tools.length}</Badge>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -529,32 +533,57 @@ export function CurrentContextToolGroup(props: {
|
||||
<Index each={props.tools}>
|
||||
{(tool) => {
|
||||
const trigger = createMemo(() => currentContextToolTrigger(tool(), i18n))
|
||||
const running = () => tool().state.status === "streaming" || tool().state.status === "running"
|
||||
return (
|
||||
<div data-slot="context-tool-group-item">
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={trigger().title} active={running()} />
|
||||
</span>
|
||||
<Show when={trigger().subtitle}>
|
||||
<span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span>
|
||||
<Show
|
||||
when={tool().state.status !== "error" && ["read", "glob", "grep", "list"].includes(tool().name)}
|
||||
fallback={
|
||||
<ToolDisplay
|
||||
id={tool().id}
|
||||
tool={tool().name}
|
||||
input={currentToolInput(tool())}
|
||||
metadata={currentToolMetadata(tool())}
|
||||
output={currentToolOutput(tool())}
|
||||
error={currentToolError(tool())}
|
||||
status={tool().state.status}
|
||||
defaultOpen={false}
|
||||
deferContent
|
||||
virtualizeDiff={false}
|
||||
onContentRendered={props.onSizeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer
|
||||
text={trigger().title}
|
||||
active={tool().state.status === "streaming" || tool().state.status === "running"}
|
||||
/>
|
||||
</span>
|
||||
<Show when={trigger().subtitle}>
|
||||
{(subtitle) => <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
|
||||
</Show>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={trigger().matches}>
|
||||
{(matches) => (
|
||||
<>
|
||||
<span data-slot="context-tool-group-dot" />
|
||||
<span data-slot="context-tool-group-matches">{matches()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={trigger().matches}>
|
||||
<span data-slot="context-tool-group-dot" />
|
||||
<span data-slot="context-tool-group-matches">{trigger().matches}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -110,6 +110,7 @@ const source = {
|
||||
"ui.messagePart.context.list.other": "{{count}} lists",
|
||||
"ui.messagePart.context.match.one": "({{count}} match)",
|
||||
"ui.messagePart.context.match.other": "({{count}} matches)",
|
||||
"ui.messagePart.tools.used": "Used {{tools}}",
|
||||
|
||||
"ui.list.loading": "Loading",
|
||||
"ui.list.empty": "No results",
|
||||
|
||||
@@ -32,6 +32,10 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M6.33345 6.33349V5.00015H9.66679V7.00015L8.00015 8.00015V9.66679M8.27485 11.6819H7.71897M14.4446 8.00011C14.4446 11.5593 11.5593 14.4446 8.00011 14.4446C4.44094 14.4446 1.55566 11.5593 1.55566 8.00011C1.55566 4.44094 4.44094 1.55566 8.00011 1.55566C11.5593 1.55566 14.4446 4.44094 14.4446 8.00011Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
},
|
||||
"circle-exclamation": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M7.9987 5.50016V8.50016M7.9987 10.5002V10.5068M14.1654 8.00016C14.1654 11.4059 11.4045 14.1668 7.9987 14.1668C6.29582 14.1668 4.75415 13.4766 3.63821 12.3607C2.52226 11.2447 1.83203 9.70304 1.83203 8.00016C1.83203 4.59441 4.59294 1.8335 7.9987 1.8335C9.70158 1.8335 11.2432 2.52372 12.3592 3.63967C13.4751 4.75562 14.1654 6.29728 14.1654 8.00016Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
},
|
||||
"sidebar-right": {
|
||||
viewBox: "0 0 20 20",
|
||||
body: `<path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor"/>`,
|
||||
|
||||
Reference in New Issue
Block a user