diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 3f663c4aaad..6d7b9ec37a5 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -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 - settings?: Record + settings?: Record sessions?: Session[] cpuRate?: number viewport?: { width: number; height: number } diff --git a/packages/app/e2e/regression/session-location-sync.spec.ts b/packages/app/e2e/regression/session-location-sync.spec.ts index f281b263475..84989914c49 100644 --- a/packages/app/e2e/regression/session-location-sync.spec.ts +++ b/packages/app/e2e/regression/session-location-sync.spec.ts @@ -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() const release = Promise.withResolvers() + 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 } diff --git a/packages/app/e2e/regression/session-notice-truncation.spec.ts b/packages/app/e2e/regression/session-notice-truncation.spec.ts index bd5e1a79083..f8e847245f5 100644 --- a/packages/app/e2e/regression/session-notice-truncation.spec.ts +++ b/packages/app/e2e/regression/session-notice-truncation.spec.ts @@ -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: [ diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts index bd14afe5d1b..c6e0c671dba 100644 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -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) => { diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index 1223b7b8e84..90447e05978 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -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"]') diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index 4183fc3c666..fa0039e68fe 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -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( diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index ba0b730f310..cc3dfcc96ea 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -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", diff --git a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts index ab3cfed6d0b..114ae780287 100644 --- a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts @@ -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, { diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts index 548a8777696..283b2d57db8 100644 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -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", ) }) diff --git a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts index 07fc720470d..cc09a4348bb 100644 --- a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts +++ b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts @@ -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('[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) diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index d723c973c42..1a6f323941a 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -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() diff --git a/packages/app/e2e/regression/session-timeline-working.spec.ts b/packages/app/e2e/regression/session-timeline-working.spec.ts index c2e0fa044d2..17e0cd0df67 100644 --- a/packages/app/e2e/regression/session-timeline-working.spec.ts +++ b/packages/app/e2e/regression/session-timeline-working.spec.ts @@ -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`, diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index fe644ac5f13..91f3ddc0975 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/session/timeline/controller-projection.test.ts b/packages/app/src/session/timeline/controller-projection.test.ts index 771683302e2..27f514e74ed 100644 --- a/packages/app/src/session/timeline/controller-projection.test.ts +++ b/packages/app/src/session/timeline/controller-projection.test.ts @@ -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") diff --git a/packages/app/src/session/timeline/controller.tsx b/packages/app/src/session/timeline/controller.tsx index e4bff43cde6..ca9555c6164 100644 --- a/packages/app/src/session/timeline/controller.tsx +++ b/packages/app/src/session/timeline/controller.tsx @@ -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, diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index f0a56539d3c..4af78317b9e 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -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( + + props.data.timelineDetail()[task.type === "subagent" ? "subagents" : "shell"].placement !== + "separate", + ) + } + > +
+ + {language.t("settings.timeline.running")} +
+
} deferred={(row) => { diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index d27bfcd8221..235fba7fdbb 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -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 }>() +const cache = new Map< + string, + { + measurements: VirtualItem[] + toolOpen: Record + patchGroupKeys: Map + presentationKey?: string + } +>() type Projection = Pick< ReturnType, @@ -42,6 +50,7 @@ type Projection = Pick< type Input = { sessionKey: Accessor + presentationKey?: Accessor projection: Projection showHeader: Accessor /** 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() const [toolOpen, setToolOpen] = createStore>(cached?.toolOpen ?? {}) + const patchGroupKeys = cached?.patchGroupKeys ?? new Map() + 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) { }} > {(rowKey) => } - 0}> -
- {props.bottomSpacer} -
-
+
+ {props.bottomSpacer} +
@@ -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), }, diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index b9d5495b505..e291e65c839 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -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 ( - - props.onChange({ ...timelinePresets[event.currentTarget.valueAsNumber].value })} + /> + +

{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}

+ + + {language.t("settings.timeline.advanced")} + + + +
+

{language.t("settings.timeline.advanced.explainer")}

+
+ {language.t("settings.timeline.activity")} + {language.t("settings.timeline.placement.title")} + {language.t("settings.timeline.expansion.title")} +
+ + {(category) => ( +
+ {language.t(`settings.timeline.category.${category}`)} +
+ + language.t(`settings.timeline.expansion.${value}`)} + onSelect={(details) => + details && + props.onChange({ ...props.value, [category]: { ...props.value[category], details } }) + } + /> + + ) : null} +
+
+ )} +
+
+
+
+ + ) +} diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index b7b1d0d8b7b..7b7c8692112 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -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", diff --git a/packages/session-ui/src/message/current-message.tsx b/packages/session-ui/src/message/current-message.tsx index a2df5e6af96..cfe53b02ad9 100644 --- a/packages/session-ui/src/message/current-message.tsx +++ b/packages/session-ui/src/message/current-message.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 ( - - ) +export function SessionContextToolGroup(props: ComponentProps) { + return } export function SessionFileToolGroup(props: { diff --git a/packages/session-ui/src/message/current-tool-state.ts b/packages/session-ui/src/message/current-tool-state.ts index d2da83d8c2a..b4ba572fad9 100644 --- a/packages/session-ui/src/message/current-tool-state.ts +++ b/packages/session-ui/src/message/current-tool-state.ts @@ -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) { + // 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) { + // 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, diff --git a/packages/session-ui/src/timeline/detail.ts b/packages/session-ui/src/timeline/detail.ts new file mode 100644 index 00000000000..7af3a788449 --- /dev/null +++ b/packages/session-ui/src/timeline/detail.ts @@ -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)) + ) +} diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 73ff609a96a..d128dae74aa 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -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 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 shellToolDefaultOpen?: Accessor editToolDefaultOpen?: Accessor + timelineDetail?: Accessor pendingUserMessageIDs?: Accessor> }) { 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) { + return rows.reduce((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) { - 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(/]*>([\s\S]*?)<\/h[1-6]>/i) @@ -671,5 +767,5 @@ function record(value: unknown): value is Record { 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) } diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 7655b4965c4..e5420d8f7f6 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -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 shellToolDefaultOpen: Accessor editToolDefaultOpen: Accessor + timelineDetail?: Accessor disclosure: { value: (key: string) => boolean | undefined set: (key: string, open: boolean) => void + patchGroupKeys?: Map } centered?: Accessor padding?: Accessor @@ -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() + const patchPartKeys = new WeakMap() + const patchOwners = createMemo(() => { + const owners = new Map() + 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((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: () => }] + if (message.type !== "assistant" && message.type !== "user") + return [{ type: "notice", id: ref.partID, render: () => }] return [] }) }) @@ -115,9 +159,30 @@ export function createSessionTimelineRowRenderer(input: { return ( { + 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: { ) + 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 ( + <> + + {(message) => ( +
+
+ +
+
+ )} +
+ + {(content) => ( + + + {content().label} + + + {(data) => ( + + {" "} + · {data()} + + )} + + + } + > +
+
+ + {content().label} + + + {(item) => ( + + {item} + + )} + +
+
+
+ )} +
+ } + > + {(model) => ( +
+ +
+ )} + + } + > + {(message) => ( +
+ + + {i18n.t("ui.sessionTimeline.notice.movedTo")} + + {" "} + + {message().location.directory} + +
+ )} + + + ) + } + + 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 ( + + {(message) => ( +
+ input.disclosure.set(message().id, open)} + /> +
+ )} +
+ ) + } + const render = (row: Accessor, onSizeChange?: () => void) => { if (row()._tag === "TurnGap") return - } - > -
-
- - {content().label} - - - {(item) => ( - - {item} - - )} - -
-
- - )} - - } - > - {(model) => ( -
- -
- )} - - } - > - {(message) => ( -
- - - {i18n.t("ui.sessionTimeline.notice.movedTo")} - - {" "} - - {message().location.directory} - -
- )} - + ) } @@ -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} /> diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index 5f16eb72bde..ea6f4cd9e4f 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -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()), @@ -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)[]>( (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() + 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 ( + + {(part) =>
{part().render()}
} +
+ } + > {(part) => (
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={ props.onToolOpenChange?.(tool().id, open)} deferContent virtualizeDiff={false} onContentRendered={props.onSizeChange} /> } > - + props.fileOpen?.(`${patchKeys().get(tool())}:${path}`)) + } + onFileOpenChange={ + props.onFileOpenChange && + ((path, open) => + props.onFileOpenChange?.(`${patchKeys().get(tool())}:${path}`, open)) + } + onSizeChange={props.onSizeChange} + /> } > @@ -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 } diff --git a/packages/ui/src/forms/select/select.tsx b/packages/ui/src/forms/select/select.tsx index b806975d545..475f0dfead9 100644 --- a/packages/ui/src/forms/select/select.tsx +++ b/packages/ui/src/forms/select/select.tsx @@ -181,6 +181,8 @@ export function Select(props: SelectProps) { >