mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 07:51:59 +00:00
fix(app): align thinking states and reasoning settings (#45515)
This commit is contained in:
parent
23506b5fb4
commit
7036294543
29 changed files with 1114 additions and 505 deletions
|
|
@ -261,7 +261,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
|||
const transcript = page.locator("[data-timeline-virtual-content]")
|
||||
const thinking = transcript.locator('[data-timeline-row="Thinking"]')
|
||||
await expect(transcript.getByText("A1: I will inspect the current implementation.", { exact: true })).toBeVisible()
|
||||
await expect(thinking).toBeVisible()
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(view.input).toBeEditable()
|
||||
await view.input.fill(followUp)
|
||||
await view.input.press("Enter")
|
||||
|
|
@ -274,12 +274,14 @@ for (const delivery of ["steer", "queue"] as const) {
|
|||
const queued = view.rows.filter({ hasText: followUp })
|
||||
await expect(queued).toBeVisible()
|
||||
await expect(pending).toHaveCount(0)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await queued.hover()
|
||||
await queued.getByRole("button", { name: "Steer", exact: true }).click()
|
||||
await expect.poll(() => mock.changes).toEqual([{ inboxID, action: "steer" }])
|
||||
}
|
||||
await expect(view.rows).toHaveCount(0)
|
||||
await expect(pending).toContainText(followUp)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
|
||||
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
|
||||
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
|
||||
|
|
@ -308,7 +310,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
|||
await expect(tools).toBeVisible()
|
||||
await expect(tools).toContainText(/Used\s*Read, Grep/)
|
||||
await expect(tools.locator('[data-component="tag"]')).toHaveText("2")
|
||||
await expect(thinking).toBeVisible()
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(pending).toBeVisible()
|
||||
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
|
||||
{ id: inboxID, delivery: "steer" },
|
||||
|
|
@ -316,27 +318,21 @@ for (const delivery of ["steer", "queue"] as const) {
|
|||
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
|
||||
|
||||
// Soft assertions let delivery run too, even when the pending ordering regresses.
|
||||
await expect
|
||||
.soft(tools.or(thinking).or(pending))
|
||||
.toHaveText([/Used\s*Read, Grep/, /Thinking/, /U2: Also check the retry path\./])
|
||||
await expect.soft(tools.or(pending)).toHaveText([/Used\s*Read, Grep/, /U2: Also check the retry path\./])
|
||||
await expect
|
||||
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
|
||||
.toHaveAttribute("data-message-id", userID)
|
||||
await expect
|
||||
.configure({ soft: true })
|
||||
.poll(async () => {
|
||||
const boxes = await Promise.all([tools.boundingBox(), thinking.boundingBox(), pending.boundingBox()])
|
||||
return (
|
||||
boxes.every((box) => box !== null) &&
|
||||
boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y &&
|
||||
boxes[1]!.y + boxes[1]!.height <= boxes[2]!.y
|
||||
)
|
||||
const boxes = await Promise.all([tools.boundingBox(), pending.boundingBox()])
|
||||
return boxes.every((box) => box !== null) && boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
mock.rows.splice(0, 1)
|
||||
mock.emit("session.inbox.delivered", { sessionID, inboxID })
|
||||
await expect(thinking).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(pending).toHaveCount(1)
|
||||
await expect(transcript.locator('[data-timeline-row="UserMessage"]')).toHaveCount(2)
|
||||
await expect(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })).toHaveAttribute(
|
||||
|
|
@ -352,11 +348,11 @@ for (const delivery of ["steer", "queue"] as const) {
|
|||
.locator('[data-timeline-row="AssistantPart"]')
|
||||
.filter({ hasText: "A3: Now checking the retry path for U2." })
|
||||
await expect(response).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(tools.or(pending).or(response).or(thinking)).toHaveText([
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(tools.or(pending).or(response)).toHaveText([
|
||||
/Used\s*Read, Grep/,
|
||||
/U2: Also check the retry path\./,
|
||||
/A3: Now checking the retry path for U2\./,
|
||||
/Thinking/,
|
||||
])
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,31 +109,85 @@ test("shimmers and expands a running shell command", async ({ page }) => {
|
|||
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
|
||||
})
|
||||
|
||||
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
||||
const reasoningID = "prt_reasoning_hidden"
|
||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { showReasoningSummaries: false },
|
||||
cpuRate: 4,
|
||||
for (const open of [false, true]) {
|
||||
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const reasoningID = `prt_reasoning_hidden_${open}`
|
||||
const shellID = `prt_reasoning_shell_${open}`
|
||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { showReasoningSummaries: false },
|
||||
cpuRate: 4,
|
||||
})
|
||||
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
const thought = reasoning.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(thought).toHaveAttribute("aria-expanded", "false")
|
||||
await thought.click()
|
||||
await expect(thought).toHaveAttribute("aria-expanded", "true")
|
||||
if (!open) await thought.click()
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
await timeline.send(partUpdated(shell(shellID, "running")))
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
|
||||
await expect(group).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(thought).toContainText("Thought")
|
||||
await expect(thought).not.toContainText("Inspecting stability")
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
|
||||
await timeline.send(status("idle"))
|
||||
const used = group.getByRole("button", { name: "Used Shell", exact: true })
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
|
||||
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(open),
|
||||
)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
if (!open) await thought.click()
|
||||
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
|
||||
await timeline.send(status("idle"), 300)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
|
||||
})
|
||||
for (const transition of ["reasoning-end", "idle", "retry"] as const) {
|
||||
test(`stops active Thinking on ${transition} without a following tool`, async ({ page }) => {
|
||||
const id = `prt_reasoning_stop_${transition}`
|
||||
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
|
||||
const trigger = part.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(transition === "reasoning-end" ? partUpdated(reasoningPart(id, text)) : status(transition))
|
||||
await expect(trigger).toContainText("Thought")
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(transition === "retry" ? 1 : 0)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(part.getByText("The timeline is ready for the next step.", { exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
|
||||
test("does not infer Thinking from busy, retry, or recovery without reasoning", async ({ page }) => {
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
|
|
@ -153,18 +207,17 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
|
|||
assistant,
|
||||
],
|
||||
})
|
||||
await timeline.send(status("busy"), 140)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
await timeline.send(status("retry"), 180)
|
||||
await timeline.send(status("retry"))
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(stepStarted(assistant), 180)
|
||||
await timeline.send(stepStarted(assistant))
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
|
||||
await timeline.send(status("idle"), 350)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")))
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
|
||||
await timeline.send(status("idle"))
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText(
|
||||
"Recovered response",
|
||||
|
|
|
|||
|
|
@ -4,89 +4,144 @@ import {
|
|||
assistantMessage,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
const profiles = [
|
||||
{ name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries off reasoning heading",
|
||||
summaries: false,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{
|
||||
name: "summaries off with visible tool",
|
||||
summaries: false,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: true,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{ name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries on blank reasoning",
|
||||
summaries: true,
|
||||
reasoning: " ",
|
||||
other: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible reasoning",
|
||||
summaries: true,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: false,
|
||||
thinking: false,
|
||||
body: true,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible tool no reasoning",
|
||||
summaries: true,
|
||||
reasoning: "",
|
||||
other: true,
|
||||
thinking: false,
|
||||
body: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => {
|
||||
const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}`
|
||||
const parts = [
|
||||
...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []),
|
||||
...(profile.other
|
||||
? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })]
|
||||
: []),
|
||||
]
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage(parts, { completed: false })],
|
||||
settings: { showReasoningSummaries: profile.summaries },
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0)
|
||||
if (!profile.summaries && profile.reasoning.trim()) {
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
}
|
||||
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
reasoningPart(
|
||||
"prt_reasoning_settings",
|
||||
"## Inspecting stability\n\nThe selected mode controls these details.",
|
||||
),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const select = settings.locator('[data-action="settings-reasoning-mode"] [data-component="select-v2"]')
|
||||
for (const label of ["Full", "Hidden", "Compact"] as const) {
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(settings.getByText("Model reasoning", { exact: true })).toBeVisible()
|
||||
await expect(select).toHaveAttribute("aria-expanded", "false")
|
||||
await select.click()
|
||||
await expect(page.getByRole("listbox").getByRole("option")).toHaveText(["Hidden", "Compact", "Full"])
|
||||
await page.getByRole("option", { name: label, exact: true }).click()
|
||||
await expect(select).toHaveText(label)
|
||||
await expect(select).toHaveAttribute("aria-expanded", "false")
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.reasoningMode))
|
||||
.toBe(label.toLowerCase())
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(label === "Hidden" ? 0 : 1)
|
||||
await expect(part).toHaveCount(label === "Hidden" ? 0 : 1)
|
||||
if (label === "Hidden") {
|
||||
await expect(page.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
|
||||
continue
|
||||
}
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", String(label === "Full"))
|
||||
if (label === "Full")
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
|
||||
if (label === "Compact") {
|
||||
await expect(part.getByRole("button")).toContainText("Inspecting stability")
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
|
||||
}
|
||||
}
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(select).toHaveText("Compact")
|
||||
})
|
||||
|
||||
// The persisted boolean migrates to compact (false) or full (true).
|
||||
for (const summaries of [false, true]) {
|
||||
for (const profile of ["none", "blank", "heading", "tool", "text"] as const) {
|
||||
test(`projects legacy ${summaries ? "full" : "compact"} reasoning with ${profile}`, async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
...(profile === "none"
|
||||
? []
|
||||
: [
|
||||
reasoningPart(
|
||||
`prt_reasoning_${summaries}_${profile}`,
|
||||
profile === "blank"
|
||||
? " "
|
||||
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
|
||||
),
|
||||
]),
|
||||
...(profile === "tool"
|
||||
? [toolPart(`prt_reasoning_tool_${summaries}`, "skill", "running", { name: "inspect" })]
|
||||
: []),
|
||||
...(profile === "text" ? [textPart(`prt_reasoning_text_${summaries}`, "The timeline is stable.")] : []),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
settings: { showReasoningSummaries: summaries },
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(
|
||||
profile === "blank" || profile === "heading" ? 1 : 0,
|
||||
)
|
||||
if (profile === "none") {
|
||||
await expect(part).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
if (profile === "blank") {
|
||||
await expect(part).toContainText("Thinking")
|
||||
await expect(part.getByRole("heading")).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
if (profile === "tool") {
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(used).toContainText("UsedSkill")
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
|
||||
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(1)
|
||||
}
|
||||
if (profile === "text") await expect(page.getByText("The timeline is stable.", { exact: true })).toBeVisible()
|
||||
const trigger = part.locator('[data-slot="collapsible-trigger"]')
|
||||
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
|
||||
await expect(trigger).toContainText(profile === "heading" ? "Thinking" : "Thought")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(summaries))
|
||||
if (!summaries) {
|
||||
await expect(body).toBeHidden()
|
||||
if (profile === "heading") await expect(trigger).toContainText("Inspecting stability")
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
await expect(body).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(body).toBeHidden()
|
||||
if (profile !== "heading") await expect(trigger).not.toContainText("Inspecting stability")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }),
|
||||
],
|
||||
settings: { showReasoningSummaries: true },
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
|
||||
|
|
|
|||
|
|
@ -983,6 +983,11 @@ export const dict = {
|
|||
"settings.general.row.followUpBehavior.steer": "Steer",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.reasoningMode.title": "Model reasoning",
|
||||
"settings.general.row.reasoningMode.description": "Choose how model reasoning is displayed in the timeline",
|
||||
"settings.general.row.reasoningMode.hidden": "Hidden",
|
||||
"settings.general.row.reasoningMode.compact": "Compact",
|
||||
"settings.general.row.reasoningMode.full": "Full",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Show shell tool parts expanded by default in the timeline",
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ describe("visibleTimelineMessages", () => {
|
|||
time: { created: 5, completed: 6 },
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
test("keeps work and thinking above an undelivered steer", () => {
|
||||
test("keeps work above an undelivered steer without adding a thinking row", () => {
|
||||
const source = [...messages.slice(0, 3), work]
|
||||
const visible = visibleTimelineMessages(source, [steer])
|
||||
expect(visible.map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_5", "msg_3"])
|
||||
|
|
@ -60,7 +60,7 @@ describe("visibleTimelineMessages", () => {
|
|||
const projection = createTimelineProjection({
|
||||
sessionMessages: () => visible,
|
||||
status: () => ({ type: "busy" }),
|
||||
showReasoningSummaries: () => false,
|
||||
reasoningMode: () => "compact",
|
||||
shellToolDefaultOpen: () => false,
|
||||
editToolDefaultOpen: () => false,
|
||||
pendingUserMessageIDs: () => new Set([steer.id]),
|
||||
|
|
@ -69,7 +69,6 @@ describe("visibleTimelineMessages", () => {
|
|||
expect(projection.rows().map((row) => [row._tag, row.userMessageID])).toEqual([
|
||||
["UserMessage", "msg_1"],
|
||||
["AssistantPart", "msg_1"],
|
||||
["Thinking", "msg_1"],
|
||||
["TurnGap", "msg_3"],
|
||||
["UserMessage", "msg_3"],
|
||||
])
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
|||
const projection = createTimelineProjection({
|
||||
sessionMessages: projectedMessages,
|
||||
status: input.session.data.status,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
reasoningMode: settings.general.reasoningMode,
|
||||
shellToolDefaultOpen: settings.general.shellToolPartsExpanded,
|
||||
editToolDefaultOpen: settings.general.editToolPartsExpanded,
|
||||
pendingUserMessageIDs,
|
||||
|
|
@ -235,7 +235,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
|||
childTitle,
|
||||
showHeader,
|
||||
projection,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
reasoningMode: settings.general.reasoningMode,
|
||||
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
|
||||
editToolPartsExpanded: settings.general.editToolPartsExpanded,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -458,7 +458,7 @@ function MessageTimelineView(
|
|||
}
|
||||
},
|
||||
actions: props.actions,
|
||||
showReasoningSummaries: props.data.showReasoningSummaries,
|
||||
reasoningMode: props.data.reasoningMode,
|
||||
shellToolDefaultOpen: props.data.shellToolPartsExpanded,
|
||||
editToolDefaultOpen: props.data.editToolPartsExpanded,
|
||||
disclosure: virtualized.disclosure,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import type { ModelRef, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import { reuseTimelineRows, Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import {
|
||||
reuseTimelineRows,
|
||||
Timeline,
|
||||
TimelineRow,
|
||||
type ReasoningMode,
|
||||
} from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
|
||||
export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection"
|
||||
|
|
@ -7,7 +12,7 @@ export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection"
|
|||
export function createTimelineProjection(input: {
|
||||
sessionMessages: Accessor<SessionMessageInfo[]>
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
reasoningMode: Accessor<ReasoningMode>
|
||||
shellToolDefaultOpen: Accessor<boolean>
|
||||
editToolDefaultOpen: Accessor<boolean>
|
||||
pendingUserMessageIDs: Accessor<ReadonlySet<string>>
|
||||
|
|
@ -80,7 +85,7 @@ export function createTimelineProjection(input: {
|
|||
const projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages(),
|
||||
input.showReasoningSummaries(),
|
||||
input.reasoningMode() !== "hidden",
|
||||
input.status(),
|
||||
input.pendingUserMessageIDs(),
|
||||
input.shellToolDefaultOpen(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button } from "@opencode-ai/ui/button"
|
|||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useUpdaterAction } from "@/shell/updates/action"
|
||||
|
|
@ -183,6 +184,34 @@ const FollowUpBehaviorSetting: Component = () => {
|
|||
)
|
||||
}
|
||||
|
||||
const ReasoningModeSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const options = createMemo((): { value: ReasoningMode; label: string }[] => [
|
||||
{ value: "hidden", label: language.t("settings.general.row.reasoningMode.hidden") },
|
||||
{ value: "compact", label: language.t("settings.general.row.reasoningMode.compact") },
|
||||
{ value: "full", label: language.t("settings.general.row.reasoningMode.full") },
|
||||
])
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningMode.title")}
|
||||
description={language.t("settings.general.row.reasoningMode.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-reasoning-mode"
|
||||
options={options()}
|
||||
current={options().find((option) => option.value === settings.general.reasoningMode())}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && settings.general.setReasoningMode(option.value)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
|
|
@ -331,17 +360,7 @@ export const SettingsGeneral: Component<{
|
|||
<TerminalPlacementSetting />
|
||||
<FollowUpBehaviorSetting />
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||
>
|
||||
<div data-action="settings-feed-reasoning-summaries">
|
||||
<Switch
|
||||
checked={settings.general.showReasoningSummaries()}
|
||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<ReasoningModeSetting />
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,33 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
|
||||
import { migrateSettings, monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
|
||||
|
||||
describe("settings reasoning mode migration", () => {
|
||||
test.each([
|
||||
[true, "full"],
|
||||
[false, "compact"],
|
||||
])("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
|
||||
const value = { general: { showReasoningSummaries, showTerminal: true }, appearance: { fontSize: 16 } }
|
||||
expect(migrateSettings(value)).toEqual({
|
||||
...value,
|
||||
general: { ...value.general, reasoningMode },
|
||||
})
|
||||
expect(value.general).not.toHaveProperty("reasoningMode")
|
||||
})
|
||||
|
||||
test.each(["hidden", "compact", "full"])(
|
||||
"preserves an explicit %s mode over either legacy value",
|
||||
(reasoningMode) => {
|
||||
;[true, false].forEach((showReasoningSummaries) => {
|
||||
const value = { general: { reasoningMode, showReasoningSummaries } }
|
||||
expect(migrateSettings(value)).toBe(value)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test.each([undefined, null, {}, { general: {} }])("leaves missing legacy settings to the defaults: %j", (value) => {
|
||||
expect(migrateSettings(value)).toBe(value)
|
||||
})
|
||||
})
|
||||
|
||||
describe("settings font families", () => {
|
||||
test("defaults normal text to Inter", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { persisted } from "@/runtime/persistence/storage"
|
||||
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ export interface Settings {
|
|||
showStatus: boolean
|
||||
showProjectIcon: boolean
|
||||
showTerminal: boolean
|
||||
showReasoningSummaries: boolean
|
||||
reasoningMode: ReasoningMode
|
||||
shellToolPartsExpanded: boolean
|
||||
editToolPartsExpanded: boolean
|
||||
showCustomAgents: boolean
|
||||
|
|
@ -124,7 +125,7 @@ const defaultSettings: Settings = {
|
|||
showStatus: false,
|
||||
showProjectIcon: false,
|
||||
showTerminal: false,
|
||||
showReasoningSummaries: false,
|
||||
reasoningMode: "compact",
|
||||
shellToolPartsExpanded: false,
|
||||
editToolPartsExpanded: false,
|
||||
showCustomAgents: false,
|
||||
|
|
@ -166,11 +167,26 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
|
|||
return createMemo(() => read() ?? fallback)
|
||||
}
|
||||
|
||||
export function migrateSettings(value: unknown) {
|
||||
if (!value || typeof value !== "object" || !("general" in value)) return value
|
||||
const general = value.general
|
||||
if (!general || typeof general !== "object") return value
|
||||
if ("reasoningMode" in general && general.reasoningMode !== undefined) return value
|
||||
if (!("showReasoningSummaries" in general) || typeof general.showReasoningSummaries !== "boolean") return value
|
||||
return {
|
||||
...value,
|
||||
general: { ...general, reasoningMode: general.showReasoningSummaries ? "full" : "compact" },
|
||||
}
|
||||
}
|
||||
|
||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||
name: "Settings",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const [store, setStore, , ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
const [store, setStore, , ready] = persisted(
|
||||
{ key: "settings.v3", migrate: migrateSettings },
|
||||
createStore<Settings>(defaultSettings),
|
||||
)
|
||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||
|
|
@ -223,12 +239,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||
setShowTerminal(value: boolean) {
|
||||
setStore("general", "showTerminal", value)
|
||||
},
|
||||
showReasoningSummaries: withFallback(
|
||||
() => store.general?.showReasoningSummaries,
|
||||
defaultSettings.general.showReasoningSummaries,
|
||||
),
|
||||
setShowReasoningSummaries(value: boolean) {
|
||||
setStore("general", "showReasoningSummaries", value)
|
||||
reasoningMode: withFallback(() => store.general?.reasoningMode, defaultSettings.general.reasoningMode),
|
||||
setReasoningMode(value: ReasoningMode) {
|
||||
setStore("general", "reasoningMode", value)
|
||||
},
|
||||
shellToolPartsExpanded: withFallback(
|
||||
() => store.general?.shellToolPartsExpanded,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ story("merges follow-up patches into one stack with a distinct file count", asyn
|
|||
await group.screenshot({ path: info.outputPath("merged.png") })
|
||||
})
|
||||
|
||||
for (const separator of ["shell", "error"]) {
|
||||
for (const separator of ["shell", "error", "reasoning"]) {
|
||||
story(`does not merge patches across an intervening ${separator}`, async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator } })
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
|
|
@ -32,3 +32,15 @@ for (const separator of ["shell", "error"]) {
|
|||
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
story("does not retain patch files in the wrong batch when thoughts are shown", async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator: "reasoning" } })
|
||||
await root.getByRole("button", { name: "Hide thoughts", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
await root.getByRole("button", { name: "Show thoughts", exact: true }).click()
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -105,31 +105,67 @@ story("shimmers and expands a running shell command", async ({ mount }) => {
|
|||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
|
||||
story("transitions thinking and hidden reasoning through busy to idle", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "hidden" } })
|
||||
const reasoning = timeline.locator('[data-timeline-part-id="msg_hidden_reasoning_lifecycle:reasoning:0"]')
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
await expect(reasoning).toHaveCount(0)
|
||||
await timeline.getByRole("button", { name: "Start shell" }).click()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(timeline.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible()
|
||||
await timeline.getByRole("button", { name: "Finish session" }).click()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(reasoning).toHaveCount(0)
|
||||
})
|
||||
for (const open of [false, true]) {
|
||||
story(
|
||||
`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`,
|
||||
async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "hidden" } })
|
||||
const reasoning = timeline.locator('[data-timeline-part-id="msg_hidden_reasoning_lifecycle:reasoning:0"]')
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
const thought = reasoning.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(thought).toHaveAttribute("aria-expanded", "false")
|
||||
await thought.click()
|
||||
await expect(thought).toHaveAttribute("aria-expanded", "true")
|
||||
if (!open) await thought.click()
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
await timeline.getByRole("button", { name: "Start shell" }).click()
|
||||
const group = timeline.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(timeline.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible()
|
||||
await expect(group).toHaveCount(0)
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(thought).toContainText("Thought")
|
||||
await expect(thought).not.toContainText("Inspecting stability")
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
await timeline.getByRole("button", { name: "Finish session" }).click()
|
||||
const used = group.getByRole("button", { name: "Used Shell", exact: true })
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible()
|
||||
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(open),
|
||||
)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
if (!open) await thought.click()
|
||||
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
|
||||
story("moves busy through retry and recovery to final idle content", async ({ mount }) => {
|
||||
story("does not infer Thinking from busy, retry, or recovery without reasoning", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "retry" } })
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(timeline.locator('[data-timeline-row="UserMessage"]')).toBeVisible()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(timeline.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
await timeline.getByRole("button", { name: "Retry request" }).click()
|
||||
await expect(timeline.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.getByRole("button", { name: "Recover request" }).click()
|
||||
await expect(timeline.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.getByRole("button", { name: "Finish response" }).click()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(timeline.locator('[data-timeline-part-id="msg_retry_recovery_lifecycle:text:0"]')).toContainText(
|
||||
|
|
|
|||
|
|
@ -1,66 +1,81 @@
|
|||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const profiles = [
|
||||
{ name: "summaries off no reasoning", summaries: false, reasoning: "none", tool: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries off reasoning heading",
|
||||
summaries: false,
|
||||
reasoning: "heading",
|
||||
tool: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
heading: true,
|
||||
},
|
||||
{
|
||||
name: "summaries off with visible tool",
|
||||
summaries: false,
|
||||
reasoning: "heading",
|
||||
tool: true,
|
||||
thinking: true,
|
||||
body: false,
|
||||
heading: true,
|
||||
},
|
||||
{ name: "summaries on no content", summaries: true, reasoning: "none", tool: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries on blank reasoning",
|
||||
summaries: true,
|
||||
reasoning: "blank",
|
||||
tool: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible reasoning",
|
||||
summaries: true,
|
||||
reasoning: "heading",
|
||||
tool: false,
|
||||
thinking: false,
|
||||
body: true,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible tool no reasoning",
|
||||
summaries: true,
|
||||
reasoning: "none",
|
||||
tool: true,
|
||||
thinking: false,
|
||||
body: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
|
||||
story(`projects busy reasoning profile ${profile.name}`, async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", {
|
||||
args: { scenario: "reasoning", summaries: profile.summaries, reasoning: profile.reasoning, tool: profile.tool },
|
||||
for (const mode of ["hidden", "compact", "full"] as const) {
|
||||
for (const reasoning of ["none", "blank", "heading"] as const) {
|
||||
story(`projects ${mode} mode with ${reasoning} active reasoning`, async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", {
|
||||
args: { scenario: "reasoning", mode, reasoning },
|
||||
})
|
||||
await expect(timeline.locator('[data-timeline-row="UserMessage"]')).toContainText(
|
||||
"Find why the Session header shifts after the first streamed response.",
|
||||
)
|
||||
const active = mode !== "hidden" && reasoning !== "none"
|
||||
const part = timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(active ? 1 : 0)
|
||||
await expect(part).toHaveCount(active ? 1 : 0)
|
||||
if (!active || reasoning !== "heading") {
|
||||
await expect(timeline.getByText("Inspecting stability", { exact: true })).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
const trigger = part.getByRole("button")
|
||||
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(mode === "full"))
|
||||
await expect(part.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
if (mode === "compact") {
|
||||
await expect(trigger).toContainText("Inspecting stability")
|
||||
await expect(body).toBeHidden()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
await expect(body).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(body).toBeHidden()
|
||||
await expect(trigger).toContainText("Inspecting stability")
|
||||
})
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
|
||||
await expect(timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')).toHaveCount(
|
||||
profile.body ? 1 : 0,
|
||||
)
|
||||
if ("heading" in profile) {
|
||||
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const following of ["tool", "text"] as const) {
|
||||
story(`stops Thinking before ${following} in ${mode} mode`, async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", {
|
||||
args: {
|
||||
scenario: "reasoning",
|
||||
mode,
|
||||
reasoning: "heading",
|
||||
tool: following === "tool",
|
||||
text: following === "text" ? "The timeline is stable" : "",
|
||||
},
|
||||
})
|
||||
const part = timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')
|
||||
if (following === "tool") {
|
||||
const group = timeline.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toContainText("UsedSkill")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
|
||||
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeHidden()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator('[data-timeline-part-id="tool_reasoning_projection_skill"]')).toBeVisible()
|
||||
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(mode === "hidden" ? 0 : 1)
|
||||
}
|
||||
if (following === "text")
|
||||
await expect(timeline.getByText("The timeline is stable", { exact: true })).toBeVisible()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(part).toHaveCount(mode === "hidden" ? 0 : 1)
|
||||
if (mode === "hidden") return
|
||||
const thought = part.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(thought.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Thought")
|
||||
await expect(thought.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("7s")
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(mode === "full"))
|
||||
await expect(thought).not.toContainText("Inspecting stability")
|
||||
await expect(part.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "false")
|
||||
if (mode === "compact") await thought.click()
|
||||
await expect(
|
||||
part.getByText("I will inspect the timeline before changing its state.", { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { expect, story } from "../../storybook/playwright/story"
|
|||
story("renders streamed reasoning without starting the app", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--streaming-reasoning-and-text")
|
||||
await expect(timeline.locator('[data-component="session-timeline"]')).toBeVisible()
|
||||
await timeline.locator('[data-component="reasoning-part"] [data-slot="collapsible-trigger"]').click()
|
||||
await expect(timeline.getByText("Checking the current contract", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,66 @@
|
|||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const reasoningDefaultOpen of [false, true]) {
|
||||
story(
|
||||
`keeps ordered thoughts and tool-only counts with reasoning ${reasoningDefaultOpen ? "expanded" : "collapsed"}`,
|
||||
async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-reasoning", { args: { reasoningDefaultOpen } })
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.getByRole("button", { name: "Used Read, Skill", exact: true })
|
||||
const first = group.locator('[data-timeline-part-id="reasoning_first"]')
|
||||
const second = group.locator('[data-timeline-part-id="reasoning_second"]')
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
|
||||
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([
|
||||
/Read.*group\.ts/,
|
||||
/Thought/,
|
||||
/Loaded.*opencode.*frontend-design.*skills/,
|
||||
/Thought/,
|
||||
/Loaded.*rtl-aware-development.*skill/,
|
||||
])
|
||||
await expect(
|
||||
group.locator('[data-timeline-part-ids="reasoning_skill_first,reasoning_skill_second"]'),
|
||||
).toBeVisible()
|
||||
await expect(first.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(reasoningDefaultOpen),
|
||||
)
|
||||
await expect(second.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(reasoningDefaultOpen),
|
||||
)
|
||||
await first.getByRole("button", { name: "Thought", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Append follow-up read", exact: true }).click()
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("5")
|
||||
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([
|
||||
/Read.*group\.ts/,
|
||||
/Thought/,
|
||||
/Loaded.*opencode.*frontend-design.*skills/,
|
||||
/Thought/,
|
||||
/Loaded.*rtl-aware-development.*skill/,
|
||||
/Read.*group\.test\.ts/,
|
||||
])
|
||||
await expect(first.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(!reasoningDefaultOpen),
|
||||
)
|
||||
await expect(second.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(reasoningDefaultOpen),
|
||||
)
|
||||
if (reasoningDefaultOpen) {
|
||||
await expect(
|
||||
first.getByText("The renderer groups adjacent tools. Check the relevant skills before changing it."),
|
||||
).toBeHidden()
|
||||
return
|
||||
}
|
||||
await expect(
|
||||
first.getByText("The renderer groups adjacent tools. Check the relevant skills before changing it."),
|
||||
).toBeVisible()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
story("summarizes subagents as Agent while retaining their card titles", async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
|
|
|
|||
|
|
@ -273,11 +273,23 @@
|
|||
line-height: var(--line-height-normal);
|
||||
|
||||
[data-component="markdown"] {
|
||||
margin-top: 16px;
|
||||
margin-top: 0;
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
strong,
|
||||
b {
|
||||
color: var(--v2-text-text-muted);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import type {
|
|||
import { Match, Switch } from "solid-js"
|
||||
import type { SessionUserActions, SessionUserComment } from "../actions"
|
||||
import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content"
|
||||
import { CurrentContextToolGroup, CurrentFileToolGroup, ToolDisplay } from "../tools/tool-renderer"
|
||||
import {
|
||||
CurrentContextToolGroup,
|
||||
CurrentFileToolGroup,
|
||||
ToolDisplay,
|
||||
type ContextGroupPart,
|
||||
} from "../tools/tool-renderer"
|
||||
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
|
||||
|
||||
export type { SessionUserActions, SessionUserComment } from "../actions"
|
||||
|
|
@ -42,6 +47,7 @@ export function SessionAssistantContent(props: {
|
|||
showAssistantCopyPartID?: string | null
|
||||
turnDurationMs?: number | null
|
||||
defaultOpen?: boolean
|
||||
reasoningDefaultOpen?: boolean
|
||||
toolOpen?: boolean
|
||||
onToolOpenChange?: (open: boolean) => void
|
||||
onContentRendered?: () => void
|
||||
|
|
@ -63,8 +69,12 @@ export function SessionAssistantContent(props: {
|
|||
{(content) => (
|
||||
<AssistantReasoningContent
|
||||
id={props.contentID}
|
||||
text={content().text}
|
||||
streaming={typeof props.message.time.completed !== "number"}
|
||||
content={content()}
|
||||
streaming={false}
|
||||
defaultOpen={props.reasoningDefaultOpen}
|
||||
open={props.toolOpen}
|
||||
onOpenChange={props.onToolOpenChange}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
|
|
@ -92,7 +102,10 @@ export function SessionAssistantContent(props: {
|
|||
}
|
||||
|
||||
export function SessionContextToolGroup(props: {
|
||||
tools: SessionMessageAssistantTool[]
|
||||
parts: ContextGroupPart[]
|
||||
reasoningDefaultOpen?: boolean
|
||||
reasoningOpen?: (id: string) => boolean | undefined
|
||||
onReasoningOpenChange?: (id: string, open: boolean) => void
|
||||
open: boolean
|
||||
busy: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
|
|
@ -100,7 +113,10 @@ export function SessionContextToolGroup(props: {
|
|||
}) {
|
||||
return (
|
||||
<CurrentContextToolGroup
|
||||
tools={props.tools}
|
||||
parts={props.parts}
|
||||
reasoningDefaultOpen={props.reasoningDefaultOpen}
|
||||
reasoningOpen={props.reasoningOpen}
|
||||
onReasoningOpenChange={props.onReasoningOpenChange}
|
||||
open={props.open}
|
||||
busy={props.busy}
|
||||
onOpenChange={props.onOpenChange}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,16 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { BasicTool } from "../components/basic-tool"
|
||||
import { reasoningHeading } from "../timeline/projection"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
import type {
|
||||
PromptAgentAttachment,
|
||||
PromptFileAttachment,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageCompaction,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
|
|
@ -490,12 +495,71 @@ export function AssistantTextContent(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export function AssistantReasoningContent(props: { id: string; text: string; streaming: boolean }) {
|
||||
export function AssistantReasoningContent(props: {
|
||||
id: string
|
||||
content: SessionMessageAssistantReasoning
|
||||
streaming: boolean
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onContentRendered?: () => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const [state, setState] = createStore<{ open?: boolean }>({})
|
||||
const open = () => props.open ?? state.open ?? props.defaultOpen ?? false
|
||||
const heading = createMemo(() => reasoningHeading(props.content.text))
|
||||
const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
|
||||
const duration = createMemo(() => {
|
||||
const time = props.content.time
|
||||
if (time?.completed === undefined) return undefined
|
||||
const total = Math.max(0, Math.round((time.completed - time.created) / 1000))
|
||||
if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) })
|
||||
return i18n.t("ui.message.duration.minutesSeconds", {
|
||||
minutes: numfmt().format(Math.floor(total / 60)),
|
||||
seconds: numfmt().format(total % 60),
|
||||
})
|
||||
})
|
||||
return (
|
||||
<Show when={props.text}>
|
||||
<div data-component="reasoning-part" data-timeline-part-id={props.id}>
|
||||
<PacedMarkdown text={props.text} cacheKey={props.id} streaming={props.streaming} />
|
||||
</div>
|
||||
</Show>
|
||||
<div data-component="reasoning-part" data-timeline-part-id={props.id}>
|
||||
<BasicTool
|
||||
icon="mcp"
|
||||
status={props.streaming ? "running" : "completed"}
|
||||
compact
|
||||
allowOpenWhilePending
|
||||
hideDetails={!props.content.text.trim()}
|
||||
open={open()}
|
||||
onOpenChange={(value) => {
|
||||
setState("open", value)
|
||||
props.onOpenChange?.(value)
|
||||
props.onContentRendered?.()
|
||||
}}
|
||||
trigger={
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer
|
||||
text={i18n.t(props.streaming ? "ui.sessionTurn.status.thinking" : "ui.message.thought")}
|
||||
active={props.streaming}
|
||||
/>
|
||||
</span>
|
||||
<Show
|
||||
when={props.streaming && !open()}
|
||||
fallback={
|
||||
<Show when={!props.streaming && duration()}>
|
||||
{(value) => <span data-slot="basic-tool-tool-subtitle">{value()}</span>}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<span data-slot="basic-tool-tool-subtitle">
|
||||
<TextReveal text={heading()} />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PacedMarkdown text={props.content.text} cacheKey={props.id} streaming={props.streaming} />
|
||||
</BasicTool>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,7 +130,11 @@ describe("reuseTimelineRows", () => {
|
|||
name: "does not create accidental key collisions",
|
||||
previous: [context("context:a", ["a", "b", "c"])],
|
||||
rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
|
||||
expected: ["assistant-part:context:context:b", "assistant-part:context:context:a", "assistant-part:context:context:c"],
|
||||
expected: [
|
||||
"assistant-part:context:context:b",
|
||||
"assistant-part:context:context:a",
|
||||
"assistant-part:context:context:c",
|
||||
],
|
||||
reused: [],
|
||||
},
|
||||
])("$name", ({ previous, rows, expected, reused }) => {
|
||||
|
|
@ -173,7 +177,7 @@ describe("createTimelineProjection", () => {
|
|||
const result = createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
status: { type: "busy" },
|
||||
showReasoningSummaries: true,
|
||||
reasoningMode: "full",
|
||||
})
|
||||
|
||||
expect(result.activeMessageID).toBe("user-2")
|
||||
|
|
@ -207,12 +211,12 @@ describe("createTimelineProjection", () => {
|
|||
const first = createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
status: { type: "idle" },
|
||||
showReasoningSummaries: true,
|
||||
reasoningMode: "full",
|
||||
})
|
||||
const second = createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
status: { type: "idle" },
|
||||
showReasoningSummaries: true,
|
||||
reasoningMode: "full",
|
||||
previousRows: first.rows,
|
||||
})
|
||||
|
||||
|
|
@ -244,7 +248,7 @@ describe("createTimelineProjection", () => {
|
|||
const result = createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
status: { type: "idle" },
|
||||
showReasoningSummaries: true,
|
||||
reasoningMode: "full",
|
||||
})
|
||||
|
||||
expect(result.assistantMessagesByParent.get("assistant-1")?.map((message) => message.id)).toEqual([
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "
|
|||
|
||||
export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
|
||||
|
||||
export type ReasoningMode = "hidden" | "compact" | "full"
|
||||
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
|
||||
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
|
||||
type Content = SessionMessageAssistant["content"][number]
|
||||
|
|
@ -24,7 +26,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unkno
|
|||
export type TimelineProjectionInput = {
|
||||
sessionMessages: SessionMessageInfo[]
|
||||
status: SessionStatus
|
||||
showReasoningSummaries: boolean
|
||||
reasoningMode: ReasoningMode
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
pendingUserMessageIDs?: ReadonlySet<string>
|
||||
|
|
@ -35,7 +37,7 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
|
|||
const sessionMessageByID = new Map(input.sessionMessages.map((message) => [message.id, message] as const))
|
||||
const projection = Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages,
|
||||
input.showReasoningSummaries,
|
||||
input.reasoningMode !== "hidden",
|
||||
input.status,
|
||||
input.pendingUserMessageIDs,
|
||||
input.shellToolDefaultOpen ?? false,
|
||||
|
|
@ -70,7 +72,7 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
|
|||
export function createReactiveTimelineProjection(input: {
|
||||
sessionMessages: Accessor<SessionMessageInfo[]>
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
reasoningMode: Accessor<ReasoningMode>
|
||||
shellToolDefaultOpen?: Accessor<boolean>
|
||||
editToolDefaultOpen?: Accessor<boolean>
|
||||
pendingUserMessageIDs?: Accessor<ReadonlySet<string>>
|
||||
|
|
@ -83,7 +85,7 @@ export function createReactiveTimelineProjection(input: {
|
|||
const projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages(),
|
||||
input.showReasoningSummaries(),
|
||||
input.reasoningMode() !== "hidden",
|
||||
input.status(),
|
||||
input.pendingUserMessageIDs?.(),
|
||||
input.shellToolDefaultOpen?.() ?? false,
|
||||
|
|
@ -238,14 +240,16 @@ export namespace Timeline {
|
|||
const lastAssistant = assistantMessages.at(-1)
|
||||
const previousUserMessage = index > 0
|
||||
const compaction = entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
|
||||
const delegating = assistantMessages.some((message) =>
|
||||
message.content.some(
|
||||
(content) =>
|
||||
content.type === "tool" &&
|
||||
content.name === "subagent" &&
|
||||
(content.state.status === "streaming" || content.state.status === "running"),
|
||||
),
|
||||
)
|
||||
const lastContent = lastAssistant?.content.at(-1)
|
||||
const thinking =
|
||||
showReasoning &&
|
||||
isActive &&
|
||||
status.type === "busy" &&
|
||||
lastAssistant?.time.completed === undefined &&
|
||||
!lastAssistant?.error &&
|
||||
!lastAssistant?.retry &&
|
||||
lastContent?.type === "reasoning" &&
|
||||
lastContent.time?.completed === undefined
|
||||
|
||||
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: turnID }))
|
||||
if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID }))
|
||||
|
|
@ -257,7 +261,7 @@ export namespace Timeline {
|
|||
const appendAssistantSegment = (messages: SessionMessageAssistant[]) => {
|
||||
const refs = messages.flatMap((message, messageIndex) =>
|
||||
contentEntries(message)
|
||||
.filter((entry) => renderable(entry.content, showReasoning))
|
||||
.filter((entry) => renderable(entry.content, showReasoning) && !(thinking && entry.content === lastContent))
|
||||
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
|
||||
)
|
||||
const interruptedAt = messages.findIndex((message) => isInterrupted(message.error))
|
||||
|
|
@ -266,7 +270,7 @@ export namespace Timeline {
|
|||
const appendGroups = (items: typeof refs) => {
|
||||
let offset = 0
|
||||
groupContent(items, shellToolDefaultOpen, editToolDefaultOpen).forEach((group) => {
|
||||
const tool = group.type !== "part" || items[offset]?.content.type === "tool"
|
||||
const tool = group.type !== "part" || items[offset]?.content.type !== "text"
|
||||
offset += group.type === "part" ? 1 : group.refs.length
|
||||
rows.push(
|
||||
new TimelineRow.AssistantPart({
|
||||
|
|
@ -309,22 +313,13 @@ export namespace Timeline {
|
|||
})
|
||||
appendAssistantSegment(assistantSegment)
|
||||
|
||||
if (
|
||||
isActive &&
|
||||
status.type === "busy" &&
|
||||
!lastAssistant?.error &&
|
||||
!lastAssistant?.retry &&
|
||||
!delegating &&
|
||||
(showReasoning
|
||||
? !assistantMessages.some((message) => message.content.some((content) => renderable(content, true)))
|
||||
: true)
|
||||
) {
|
||||
const heading = assistantMessages
|
||||
.flatMap((message) => message.content)
|
||||
.map((content) => (content.type === "reasoning" && content.text ? reasoningHeading(content.text) : undefined))
|
||||
.find((value): value is string => !!value)
|
||||
|
||||
rows.push(new TimelineRow.Thinking({ userMessageID: turnID, reasoningHeading: heading }))
|
||||
if (thinking && lastAssistant) {
|
||||
rows.push(
|
||||
new TimelineRow.Thinking({
|
||||
userMessageID: turnID,
|
||||
ref: { messageID: lastAssistant.id, partID: contentEntries(lastAssistant).at(-1)!.id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return rows
|
||||
|
|
@ -490,11 +485,18 @@ function groupContent(
|
|||
editToolDefaultOpen: boolean,
|
||||
): PartGroup[] {
|
||||
const groups: PartGroup[] = []
|
||||
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[] } | undefined
|
||||
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined
|
||||
const flush = () => {
|
||||
const current = adjacent
|
||||
const first = current?.refs[0]
|
||||
if (!first) return
|
||||
if (!current.tools) {
|
||||
groups.push(
|
||||
...current.refs.map((ref) => ({ type: "part" as const, key: `part:${ref.messageID}:${ref.partID}`, ref })),
|
||||
)
|
||||
adjacent = undefined
|
||||
return
|
||||
}
|
||||
groups.push({
|
||||
type: current.type === "context" ? "context" : "file",
|
||||
key:
|
||||
|
|
@ -509,11 +511,19 @@ function groupContent(
|
|||
items.forEach((item) => {
|
||||
const type =
|
||||
item.content.type === "tool"
|
||||
? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen, adjacent?.type === "context")
|
||||
: undefined
|
||||
? toolGroupType(
|
||||
item.content,
|
||||
shellToolDefaultOpen,
|
||||
editToolDefaultOpen,
|
||||
adjacent?.type === "context" && adjacent.tools,
|
||||
)
|
||||
: item.content.type === "reasoning"
|
||||
? "context"
|
||||
: undefined
|
||||
if (type) {
|
||||
if (adjacent?.type !== type) flush()
|
||||
adjacent ??= { type, refs: [] }
|
||||
adjacent ??= { type, refs: [], tools: false }
|
||||
adjacent.tools ||= item.content.type === "tool"
|
||||
adjacent.refs.push({ messageID: item.messageID, partID: item.partID })
|
||||
return
|
||||
}
|
||||
|
|
@ -560,7 +570,7 @@ function hasLoadedFiles(content: Extract<Content, { type: "tool" }>) {
|
|||
return Array.isArray(loaded) && loaded.some((path) => typeof path === "string")
|
||||
}
|
||||
|
||||
function reasoningHeading(text: string): string | undefined {
|
||||
export function reasoningHeading(text: string): string | undefined {
|
||||
const markdown = text.replace(/\r\n?/g, "\n")
|
||||
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
|
||||
if (html?.[1]) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
|
||||
|
||||
|
|
@ -33,7 +37,7 @@ describe("current session timeline rows", () => {
|
|||
"assistant-part:part:part:msg_2:msg_2:text:0",
|
||||
"turn-gap:msg_3",
|
||||
"user-message:msg_3",
|
||||
"assistant-part:part:part:msg_4:msg_4:reasoning:0",
|
||||
"thinking:msg_3",
|
||||
])
|
||||
})
|
||||
|
||||
|
|
@ -151,7 +155,7 @@ describe("current session timeline rows", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
|
||||
test("does not infer thinking from an optimistic busy turn", () => {
|
||||
const source = [
|
||||
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
|
||||
{ id: "msg_a", type: "user", text: "pending", time: { created: 2 } },
|
||||
|
|
@ -159,15 +163,10 @@ describe("current session timeline rows", () => {
|
|||
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_a")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_z",
|
||||
"turn-gap:msg_a",
|
||||
"user-message:msg_a",
|
||||
"thinking:msg_a",
|
||||
])
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual(["user-message:msg_z", "turn-gap:msg_a", "user-message:msg_a"])
|
||||
})
|
||||
|
||||
test("renders thinking above a queued user message", () => {
|
||||
test("does not infer thinking above a queued user message", () => {
|
||||
const source = [
|
||||
{ id: "msg_active", type: "user", text: "active", time: { created: 1 } },
|
||||
{ id: "msg_queued", type: "user", text: "queued", time: { created: 2 } },
|
||||
|
|
@ -177,7 +176,6 @@ describe("current session timeline rows", () => {
|
|||
expect(result.activeMessageID).toBe("msg_active")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_active",
|
||||
"thinking:msg_active",
|
||||
"turn-gap:msg_queued",
|
||||
"user-message:msg_queued",
|
||||
])
|
||||
|
|
@ -209,9 +207,10 @@ describe("current session timeline rows", () => {
|
|||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
expect(Timeline.constructSessionMessageRows(source, false, { type: "busy" }).rows.map((row) => row._tag)).toEqual(
|
||||
["UserMessage", "AssistantPart"],
|
||||
)
|
||||
expect(Timeline.constructSessionMessageRows(source, true, { type: "busy" }).rows.map((row) => row._tag)).toEqual([
|
||||
"UserMessage",
|
||||
"AssistantPart",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -234,6 +233,98 @@ describe("current session timeline rows", () => {
|
|||
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry"])
|
||||
})
|
||||
|
||||
test.each(["hidden", "compact", "full"] as const)("only shows active reasoning in %s mode", (reasoningMode) => {
|
||||
const active = { type: "reasoning", text: "## Current thought", time: { created: 2 } } as const
|
||||
const cases: { content: SessionMessageAssistant["content"]; thinking: boolean }[] = [
|
||||
{ content: [], thinking: false },
|
||||
{ content: [active], thinking: true },
|
||||
{ content: [{ ...active, text: "" }], thinking: true },
|
||||
{ content: [{ ...active, time: { created: 2, completed: 3 } }], thinking: false },
|
||||
{ content: [active, { type: "text", text: "Answer" }], thinking: false },
|
||||
...(["streaming", "running", "completed", "error"] as const).flatMap((status) =>
|
||||
["shell", "read", "subagent", "question"].map((name) => ({
|
||||
content: [active, storyTool("tool", name, status, {})],
|
||||
thinking: false,
|
||||
})),
|
||||
),
|
||||
]
|
||||
cases.forEach((profile) => {
|
||||
const document = storyDocument(profile.content, true)
|
||||
const result = createTimelineProjection({
|
||||
sessionMessages: document.messages,
|
||||
status: document.status,
|
||||
reasoningMode,
|
||||
})
|
||||
expect(result.rows.some((row) => row._tag === "Thinking")).toBe(reasoningMode !== "hidden" && profile.thinking)
|
||||
})
|
||||
})
|
||||
|
||||
test("stops thinking on idle, message completion, errors and retries", () => {
|
||||
const document = storyDocument([{ type: "reasoning", text: "Current thought" }], true)
|
||||
expect(
|
||||
Timeline.constructSessionMessageRows(document.messages, true, { type: "idle" }).rows.map((row) => row._tag),
|
||||
).toEqual(["UserMessage", "AssistantPart"])
|
||||
const endings = [
|
||||
{ time: { created: 1, completed: 2 } },
|
||||
{ error: { type: "Interrupted", message: "Stopped" } },
|
||||
{ retry: { attempt: 1, at: 10, error: { type: "ProviderError", message: "Retry" } } },
|
||||
]
|
||||
endings.forEach((ending) => {
|
||||
const messages = document.messages.map((message) =>
|
||||
message.type === "assistant" ? { ...message, ...ending } : message,
|
||||
)
|
||||
expect(
|
||||
Timeline.constructSessionMessageRows(messages, true, { type: "busy" }).rows.some(
|
||||
(row) => row._tag === "Thinking",
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("uses the latest reasoning part and groups earlier thoughts with tools", () => {
|
||||
const document = storyDocument(
|
||||
[
|
||||
{ type: "reasoning", text: "Old thought", time: { created: 1, completed: 2 } },
|
||||
storyTool("read", "read", "completed", {}),
|
||||
{ type: "reasoning", text: "New thought", time: { created: 3 } },
|
||||
],
|
||||
true,
|
||||
)
|
||||
const result = Timeline.constructSessionMessageRows(document.messages, true, { type: "busy" })
|
||||
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart", "Thinking"])
|
||||
expect(result.rows[1]).toMatchObject({
|
||||
group: { type: "context", refs: [{ partID: "msg_tool_projection_assistant:reasoning:0" }, { partID: "read" }] },
|
||||
})
|
||||
expect(result.rows[2]).toMatchObject({ ref: { partID: "msg_tool_projection_assistant:reasoning:1" } })
|
||||
})
|
||||
|
||||
test("keeps actual thinking with the active prompt above an undelivered prompt", () => {
|
||||
const document = storyDocument([{ type: "reasoning", text: "Active thought" }], true)
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
[...document.messages, { type: "user", id: "queued", text: "Next task", time: { created: 10 } }],
|
||||
true,
|
||||
document.status,
|
||||
new Set(["queued"]),
|
||||
)
|
||||
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Thinking", "TurnGap", "UserMessage"])
|
||||
expect(result.rows[1].userMessageID).toBe(document.messages[0].id)
|
||||
})
|
||||
|
||||
test.each(["shell", "execute", "subagent"])("does not hide active %s behind a preceding thought", (name) => {
|
||||
const document = storyDocument(
|
||||
[
|
||||
{ type: "reasoning", text: "Finished thought", time: { created: 1, completed: 2 } },
|
||||
storyTool("active", name, "running", {}),
|
||||
],
|
||||
true,
|
||||
)
|
||||
const result = Timeline.constructSessionMessageRows(document.messages, true, document.status)
|
||||
expect(result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([
|
||||
"part",
|
||||
"part",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps assistant errors and retries before later notices", () => {
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
[
|
||||
|
|
@ -729,7 +820,7 @@ describe("current session timeline rows", () => {
|
|||
const initial = createTimelineProjection({
|
||||
sessionMessages: storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
|
||||
status: { type: "busy" },
|
||||
showReasoningSummaries: false,
|
||||
reasoningMode: "hidden",
|
||||
})
|
||||
const phases = [
|
||||
{ status: "streaming" },
|
||||
|
|
@ -749,7 +840,7 @@ describe("current session timeline rows", () => {
|
|||
.map((message) => ({ ...message, id: "next-step" })),
|
||||
],
|
||||
status: { type: "busy" },
|
||||
showReasoningSummaries: false,
|
||||
reasoningMode: "hidden",
|
||||
previousRows,
|
||||
})
|
||||
const groups = result.rows.filter((row) => row._tag === "AssistantPart")
|
||||
|
|
@ -772,7 +863,7 @@ describe("current session timeline rows", () => {
|
|||
{ name: "execute", expanded: true, types: ["context", "part"] },
|
||||
{ name: "subagent", expanded: true, types: ["context"] },
|
||||
{ name: "shell", separator: "text", types: ["context", "part", "part"] },
|
||||
{ name: "shell", separator: "reasoning", showReasoning: true, types: ["context", "part", "part"] },
|
||||
{ name: "shell", separator: "reasoning", showReasoning: true, types: ["context"] },
|
||||
{ name: "shell", separator: "reasoning", showReasoning: false, types: ["context"] },
|
||||
] as const)("respects active tool grouping boundaries: %j", (profile) => {
|
||||
const content = [
|
||||
|
|
|
|||
|
|
@ -6,12 +6,9 @@ import type {
|
|||
} from "@opencode-ai/client/promise"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
import { useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
|
||||
import type { SessionUserActions, SessionUserComment } from "../actions"
|
||||
import { BasicTool } from "../components/basic-tool"
|
||||
import { useData } from "../context"
|
||||
import { TimelineSeparator } from "../components/timeline-separator"
|
||||
import {
|
||||
|
|
@ -22,9 +19,16 @@ import {
|
|||
SessionUserMessage,
|
||||
currentContentDefaultOpen,
|
||||
} from "../message/current-message"
|
||||
import { SessionCompactionMessage } from "../message/message-content"
|
||||
import { AssistantReasoningContent, SessionCompactionMessage } from "../message/message-content"
|
||||
import type { ContextGroupPart } from "../tools/tool-renderer"
|
||||
import { SessionRetry } from "../components/session-retry"
|
||||
import { createReactiveTimelineProjection, Timeline, TimelineRow, unwrapErrorMessage } from "./projection"
|
||||
import {
|
||||
createReactiveTimelineProjection,
|
||||
Timeline,
|
||||
TimelineRow,
|
||||
unwrapErrorMessage,
|
||||
type ReasoningMode,
|
||||
} from "./projection"
|
||||
|
||||
const emptyAssistantMessages: SessionMessageAssistant[] = []
|
||||
type Projection = ReturnType<typeof createReactiveTimelineProjection>
|
||||
|
|
@ -41,7 +45,7 @@ export function createSessionTimelineRowRenderer(input: {
|
|||
projection: Projection
|
||||
presentation: (message: SessionMessageUser) => SessionUserPresentation | undefined
|
||||
actions?: SessionUserActions
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
reasoningMode: Accessor<ReasoningMode>
|
||||
shellToolDefaultOpen: Accessor<boolean>
|
||||
editToolDefaultOpen: Accessor<boolean>
|
||||
disclosure: {
|
||||
|
|
@ -79,19 +83,24 @@ export function createSessionTimelineRowRenderer(input: {
|
|||
|
||||
const renderAssistant = (row: Accessor<TimelineRow.AssistantPart>, onSizeChange?: () => void) => {
|
||||
if (row().group.type === "context") {
|
||||
const tools = createMemo(() => {
|
||||
const parts = createMemo(() => {
|
||||
const group = row().group
|
||||
if (group.type !== "context") return []
|
||||
return group.refs.flatMap((ref) => {
|
||||
return group.refs.flatMap<ContextGroupPart>((ref) => {
|
||||
const message = input.projection.messageByID().get(ref.messageID)
|
||||
const content = Timeline.resolveContent(message, ref.partID)
|
||||
return message?.type === "assistant" && content?.type === "tool" ? [content] : []
|
||||
if (content?.type === "tool") return [content]
|
||||
if (content?.type === "reasoning") return [{ ...content, id: ref.partID }]
|
||||
return []
|
||||
})
|
||||
})
|
||||
const key = () => `context:${row().group.key}`
|
||||
return (
|
||||
<SessionContextToolGroup
|
||||
tools={tools()}
|
||||
parts={parts()}
|
||||
reasoningDefaultOpen={input.reasoningMode() === "full"}
|
||||
reasoningOpen={(id) => input.disclosure.value(id)}
|
||||
onReasoningOpenChange={(id, open) => input.disclosure.set(id, open)}
|
||||
open={input.disclosure.value(key()) === true}
|
||||
busy={
|
||||
workingTurn(row().userMessageID) &&
|
||||
|
|
@ -154,8 +163,10 @@ export function createSessionTimelineRowRenderer(input: {
|
|||
const defaultOpen = createMemo(() => {
|
||||
const item = content()
|
||||
if (!item) return undefined
|
||||
if (item.type === "reasoning") return input.reasoningMode() === "full"
|
||||
return currentContentDefaultOpen(item, input.shellToolDefaultOpen(), input.editToolDefaultOpen())
|
||||
})
|
||||
const disclosureKey = () => (content()?.type === "reasoning" ? ref()!.partID : row().group.key)
|
||||
return (
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
|
|
@ -168,8 +179,8 @@ export function createSessionTimelineRowRenderer(input: {
|
|||
showAssistantCopyPartID={copyContentID(row().userMessageID)}
|
||||
turnDurationMs={duration(row().userMessageID)}
|
||||
defaultOpen={defaultOpen()}
|
||||
toolOpen={input.disclosure.value(row().group.key) ?? defaultOpen()}
|
||||
onToolOpenChange={(open) => input.disclosure.set(row().group.key, open)}
|
||||
toolOpen={input.disclosure.value(disclosureKey()) ?? defaultOpen()}
|
||||
onToolOpenChange={(open) => input.disclosure.set(disclosureKey(), open)}
|
||||
onContentRendered={onSizeChange}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -493,39 +504,28 @@ export function createSessionTimelineRowRenderer(input: {
|
|||
if (value._tag !== "Thinking") throw new Error("Expected a thinking timeline row")
|
||||
return value
|
||||
}
|
||||
const animateHeading = createMemo<boolean>((previous) => previous ?? !current().reasoningHeading)
|
||||
const content = createMemo(() => {
|
||||
const ref = current().ref
|
||||
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
|
||||
return content?.type === "reasoning" ? content : undefined
|
||||
})
|
||||
return (
|
||||
<Frame row={current()}>
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
|
||||
<div data-slot="session-turn-thinking-row">
|
||||
<BasicTool
|
||||
icon="mcp"
|
||||
status="running"
|
||||
compact
|
||||
locked
|
||||
hideDetails
|
||||
trigger={
|
||||
<div data-slot="session-turn-thinking">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||
</span>
|
||||
<Show when={!input.showReasoningSummaries()}>
|
||||
<span data-slot="basic-tool-tool-subtitle">
|
||||
<TextReveal
|
||||
text={current().reasoningHeading}
|
||||
class="session-turn-thinking-heading"
|
||||
travel={animateHeading() ? 25 : 0}
|
||||
duration={animateHeading() ? 700 : 0}
|
||||
/>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Show when={content()}>
|
||||
{(content) => (
|
||||
<AssistantReasoningContent
|
||||
id={current().ref.partID}
|
||||
content={content()}
|
||||
streaming
|
||||
defaultOpen={input.reasoningMode() === "full"}
|
||||
open={input.disclosure.value(current().ref.partID)}
|
||||
onOpenChange={(open) => input.disclosure.set(current().ref.partID, open)}
|
||||
onContentRendered={onSizeChange}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Frame>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { For, createMemo } from "solid-js"
|
|||
import { createStore } from "solid-js/store"
|
||||
import type { SessionDocument } from "../document"
|
||||
import type { SessionUserActions } from "../actions"
|
||||
import { createReactiveTimelineProjection, TimelineRow } from "./projection"
|
||||
import { createReactiveTimelineProjection, TimelineRow, type ReasoningMode } from "./projection"
|
||||
import { createSessionTimelineRowRenderer, type SessionUserPresentation } from "./session-timeline-row"
|
||||
|
||||
export type { SessionUserPresentation } from "./session-timeline-row"
|
||||
|
|
@ -11,7 +11,7 @@ export type SessionTimelineProps = {
|
|||
document: SessionDocument
|
||||
presentation?: Record<string, SessionUserPresentation | undefined>
|
||||
actions?: SessionUserActions
|
||||
showReasoningSummaries?: boolean
|
||||
reasoningMode?: ReasoningMode
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
class?: string
|
||||
|
|
@ -21,7 +21,7 @@ export function SessionTimeline(props: SessionTimelineProps) {
|
|||
const projection = createReactiveTimelineProjection({
|
||||
sessionMessages: () => props.document.messages,
|
||||
status: () => props.document.status,
|
||||
showReasoningSummaries: () => props.showReasoningSummaries ?? true,
|
||||
reasoningMode: () => props.reasoningMode ?? "compact",
|
||||
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
|
||||
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
|
||||
})
|
||||
|
|
@ -32,7 +32,7 @@ export function SessionTimeline(props: SessionTimelineProps) {
|
|||
projection,
|
||||
presentation: (message) => props.presentation?.[message.id],
|
||||
actions: props.actions,
|
||||
showReasoningSummaries: () => props.showReasoningSummaries ?? true,
|
||||
reasoningMode: () => props.reasoningMode ?? "compact",
|
||||
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
|
||||
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
|
||||
disclosure: {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { createMemo } from "solid-js"
|
|||
import { createStore } from "solid-js/store"
|
||||
import type { SessionDocument } from "../document"
|
||||
import { SessionTimeline } from "./session-timeline"
|
||||
import type { ReasoningMode } from "./projection"
|
||||
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
|
||||
import {
|
||||
CURRENT_SESSION_ID,
|
||||
|
|
@ -39,14 +40,7 @@ export default {
|
|||
}
|
||||
|
||||
export const AgentThinking = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
title="Agent thinking"
|
||||
description="The prompt is admitted and the active turn is waiting for its first visible content."
|
||||
document={thinkingDocument}
|
||||
width="560px"
|
||||
/>
|
||||
),
|
||||
render: () => <AgentReasoningStory mode="compact" reasoning="heading" tool={false} text="" />,
|
||||
}
|
||||
|
||||
export const StreamingReasoningAndText = {
|
||||
|
|
@ -60,15 +54,18 @@ export const StreamingReasoningAndText = {
|
|||
),
|
||||
}
|
||||
|
||||
function AgentReasoningStory(props: { summaries: boolean; reasoning: string; tool: boolean; text: string }) {
|
||||
function AgentReasoningStory(props: { mode: ReasoningMode; reasoning: string; tool: boolean; text: string }) {
|
||||
const content = [
|
||||
...(props.reasoning === "none"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "reasoning" as const,
|
||||
text: props.reasoning === "blank" ? " " : "## Inspecting stability",
|
||||
time: { created: STORY_TIME + 100 },
|
||||
text:
|
||||
props.reasoning === "blank"
|
||||
? " "
|
||||
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
|
||||
time: { created: STORY_TIME + 100, ...(props.tool || props.text ? { completed: STORY_TIME + 7100 } : {}) },
|
||||
},
|
||||
]),
|
||||
...(props.tool
|
||||
|
|
@ -78,7 +75,7 @@ function AgentReasoningStory(props: { summaries: boolean; reasoning: string; too
|
|||
id: "tool_reasoning_projection_skill",
|
||||
name: "skill",
|
||||
state: { status: "running" as const, input: { name: "inspect" }, metadata: {} },
|
||||
time: { created: STORY_TIME + 200, ran: STORY_TIME + 250 },
|
||||
time: { created: STORY_TIME + 7200, ran: STORY_TIME + 7250 },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
|
@ -103,16 +100,16 @@ function AgentReasoningStory(props: { summaries: boolean; reasoning: string; too
|
|||
return (
|
||||
<section class="mx-auto w-full max-w-[720px] p-6">
|
||||
<CurrentSessionProviders document={document}>
|
||||
<SessionTimeline document={document} showReasoningSummaries={props.summaries} />
|
||||
<SessionTimeline document={document} reasoningMode={props.mode} />
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const AgentReasoning = {
|
||||
args: { summaries: true, reasoning: "heading", tool: false, text: "" },
|
||||
args: { mode: "compact", reasoning: "heading", tool: false, text: "" },
|
||||
argTypes: { reasoning: { control: "select", options: ["none", "blank", "heading"] } },
|
||||
render: (args: { summaries: boolean; reasoning: string; tool: boolean; text: string }) => (
|
||||
render: (args: { mode: ReasoningMode; reasoning: string; tool: boolean; text: string }) => (
|
||||
<AgentReasoningStory {...args} />
|
||||
),
|
||||
}
|
||||
|
|
@ -174,7 +171,7 @@ function HiddenReasoningStory() {
|
|||
</button>
|
||||
</div>
|
||||
<CurrentSessionProviders document={document()}>
|
||||
<SessionTimeline document={document()} showReasoningSummaries={false} />
|
||||
<SessionTimeline document={document()} reasoningMode="compact" />
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -592,12 +589,13 @@ const conversationScenarios = {
|
|||
}
|
||||
|
||||
export const Conversation = {
|
||||
args: { scenario: "notices", summaries: true, reasoning: "heading", tool: false, text: "" },
|
||||
args: { scenario: "notices", mode: "compact", reasoning: "heading", tool: false, text: "" },
|
||||
argTypes: {
|
||||
scenario: { control: "select", options: Object.keys(conversationScenarios) },
|
||||
reasoning: { control: "select", options: ["none", "blank", "heading"] },
|
||||
mode: { control: "select", options: ["hidden", "compact", "full"] },
|
||||
},
|
||||
render: (args: { scenario: string; summaries: boolean; reasoning: string; tool: boolean; text: string }) => {
|
||||
render: (args: { scenario: string; mode: ReasoningMode; reasoning: string; tool: boolean; text: string }) => {
|
||||
if (args.scenario === "reasoning") return <AgentReasoningStory {...args} />
|
||||
return conversationScenarios[args.scenario as Exclude<keyof typeof conversationScenarios, "reasoning">].render()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export namespace TimelineRow {
|
|||
|
||||
export class Thinking extends Data.TaggedClass("Thinking")<{
|
||||
userMessageID: string
|
||||
reasoningHeading?: string
|
||||
ref: PartRef
|
||||
}> {}
|
||||
|
||||
export class Error extends Data.TaggedClass("Error")<{
|
||||
|
|
@ -121,7 +121,7 @@ export type TimelineRowMap = {
|
|||
previousAssistantPart: boolean
|
||||
spacing?: "tool" | "content"
|
||||
}
|
||||
Thinking: { userMessageID: string; reasoningHeading?: string }
|
||||
Thinking: { userMessageID: string; ref: PartRef }
|
||||
Retry: { userMessageID: string }
|
||||
Error: { userMessageID: string; text: string }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { createMemo, createSignal } from "solid-js"
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { CurrentSessionProviders } from "../storybook/current-session-story"
|
||||
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { CurrentContextToolGroup } from "./tool-renderer"
|
||||
import { type ContextGroupPart, CurrentContextToolGroup } from "./tool-renderer"
|
||||
|
||||
export default {
|
||||
title: "OpenCode/Work/Tool group",
|
||||
|
|
@ -29,7 +29,48 @@ export const MixedTools = {
|
|||
return (
|
||||
<section style={{ width: "100%", "max-width": "720px", padding: "24px" }}>
|
||||
<CurrentSessionProviders document={storyDocument(tools)}>
|
||||
<CurrentContextToolGroup tools={tools} busy={false} open={open()} onOpenChange={setOpen} />
|
||||
<CurrentContextToolGroup parts={tools} busy={false} open={open()} onOpenChange={setOpen} />
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const MixedReasoning = {
|
||||
args: { reasoningDefaultOpen: false },
|
||||
render: (args: { reasoningDefaultOpen: boolean }) => {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const [appended, setAppended] = createSignal(false)
|
||||
const parts = createMemo<ContextGroupPart[]>(() => [
|
||||
storyTool("reasoning_read", "read", "completed", { path: "src/group.ts" }),
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "reasoning_first",
|
||||
text: "The renderer groups adjacent tools. Check the relevant skills before changing it.",
|
||||
},
|
||||
storyTool("reasoning_skill_first", "skill", "completed", { id: "opencode" }),
|
||||
storyTool("reasoning_skill_second", "skill", "completed", { id: "frontend-design" }),
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "reasoning_second",
|
||||
text: "Keep these skill groups separate so the reasoning stays in chronological order.",
|
||||
},
|
||||
storyTool("reasoning_skill_third", "skill", "completed", { id: "rtl-aware-development" }),
|
||||
...(appended() ? [storyTool("reasoning_read_next", "read", "completed", { path: "src/group.test.ts" })] : []),
|
||||
])
|
||||
return (
|
||||
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
|
||||
<button type="button" onClick={() => setAppended((value) => !value)}>
|
||||
{appended() ? "Remove follow-up read" : "Append follow-up read"}
|
||||
</button>
|
||||
<CurrentSessionProviders document={storyDocument(parts())}>
|
||||
<CurrentContextToolGroup
|
||||
parts={parts()}
|
||||
busy={false}
|
||||
open={open()}
|
||||
onOpenChange={setOpen}
|
||||
reasoningDefaultOpen={args.reasoningDefaultOpen}
|
||||
/>
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -38,9 +79,9 @@ export const MixedTools = {
|
|||
|
||||
export const PatchFollowUps = {
|
||||
args: { separator: "none" },
|
||||
argTypes: { separator: { control: "select", options: ["none", "shell", "error"] } },
|
||||
argTypes: { separator: { control: "select", options: ["none", "shell", "error", "reasoning"] } },
|
||||
render: (args: { separator: string }) => {
|
||||
const [state, setState] = createStore({ phase: "initial", open: true })
|
||||
const [state, setState] = createStore({ phase: "initial", open: true, reasoning: true })
|
||||
const file = (path: string, before: number, after: number) => ({
|
||||
file: path,
|
||||
status: "modified",
|
||||
|
|
@ -56,7 +97,7 @@ export const PatchFollowUps = {
|
|||
{ context: Infinity },
|
||||
),
|
||||
})
|
||||
const tools = createMemo(() => [
|
||||
const parts = createMemo<ContextGroupPart[]>(() => [
|
||||
storyTool("patch_shell", "shell", "completed", { command: "printf checked" }, { output: "checked" }),
|
||||
storyTool(
|
||||
"patch_first",
|
||||
|
|
@ -76,6 +117,15 @@ export const PatchFollowUps = {
|
|||
...(args.separator === "error"
|
||||
? [storyTool("patch_error", "patch", "error", {}, { error: "Patch failed" })]
|
||||
: []),
|
||||
...(args.separator === "reasoning" && state.reasoning
|
||||
? [
|
||||
{
|
||||
type: "reasoning" as const,
|
||||
id: "patch_reasoning",
|
||||
text: "The first patch is ready. Now update the remaining files.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
storyTool(
|
||||
"patch_next",
|
||||
"patch",
|
||||
|
|
@ -96,10 +146,15 @@ export const PatchFollowUps = {
|
|||
<button type="button" onClick={() => setState("phase", "completed")}>
|
||||
Finish follow-up patch
|
||||
</button>
|
||||
<Show when={args.separator === "reasoning"}>
|
||||
<button type="button" onClick={() => setState("reasoning", (value) => !value)}>
|
||||
{state.reasoning ? "Hide thoughts" : "Show thoughts"}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<CurrentSessionProviders document={storyDocument(tools())}>
|
||||
<CurrentSessionProviders document={storyDocument(parts())}>
|
||||
<CurrentContextToolGroup
|
||||
tools={tools()}
|
||||
parts={parts()}
|
||||
busy={state.phase === "running"}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
|
|
|
|||
|
|
@ -36,14 +36,18 @@ import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
|||
import { changedFileDiff, patchFileGroups } from "../components/apply-patch-file"
|
||||
import { animate } from "motion"
|
||||
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
|
||||
import type { SessionMessageAssistantTool, SessionMessageShell } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageShell,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import {
|
||||
currentToolError,
|
||||
currentToolInput,
|
||||
currentToolMetadata,
|
||||
currentToolOutput,
|
||||
} from "../message/current-tool-state"
|
||||
import { writeClipboard } from "../message/message-content"
|
||||
import { AssistantReasoningContent, writeClipboard } from "../message/message-content"
|
||||
|
||||
function ShellSubmessage(props: { text: string; animate?: boolean }) {
|
||||
let widthRef: HTMLSpanElement | undefined
|
||||
|
|
@ -469,22 +473,27 @@ function ExaOutput(props: { output?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
export type ContextGroupPart = SessionMessageAssistantTool | (SessionMessageAssistantReasoning & { id: string })
|
||||
|
||||
export function CurrentContextToolGroup(props: {
|
||||
tools: SessionMessageAssistantTool[]
|
||||
parts: ContextGroupPart[]
|
||||
busy: boolean
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSizeChange?: () => void
|
||||
reasoningDefaultOpen?: boolean
|
||||
reasoningOpen?: (id: string) => boolean | undefined
|
||||
onReasoningOpenChange?: (id: string, open: boolean) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const tools = createMemo(() => props.parts.filter((part) => part.type === "tool"))
|
||||
const pending = createMemo(
|
||||
() =>
|
||||
props.busy || props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
|
||||
() => props.busy || tools().some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
|
||||
)
|
||||
const names = createMemo(() =>
|
||||
[
|
||||
...new Set(
|
||||
props.tools.map((tool) => {
|
||||
tools().map((tool) => {
|
||||
const input = currentToolInput(tool)
|
||||
if (tool.name === "skill") return i18n.t("ui.tool.skill")
|
||||
if (tool.name === "subagent") return i18n.t("ui.tool.agent.default")
|
||||
|
|
@ -500,31 +509,40 @@ export function CurrentContextToolGroup(props: {
|
|||
return { text, before: text.slice(0, index).trim(), after: text.slice(index + tools.length).trim() }
|
||||
})
|
||||
const items = createMemo(() =>
|
||||
props.tools.reduce<SessionMessageAssistantTool[][]>((groups, tool) => {
|
||||
const previous = groups.at(-1)
|
||||
if (
|
||||
tool.name === "patch" &&
|
||||
tool.state.status !== "error" &&
|
||||
previous?.[0]?.name === "patch" &&
|
||||
previous[0].state.status !== "error"
|
||||
) {
|
||||
previous.push(tool)
|
||||
props.parts.reduce<(SessionMessageAssistantTool[] | (SessionMessageAssistantReasoning & { id: string }))[]>(
|
||||
(groups, tool) => {
|
||||
if (tool.type === "reasoning") {
|
||||
groups.push(tool)
|
||||
return groups
|
||||
}
|
||||
const previous = groups.at(-1)
|
||||
if (
|
||||
tool.name === "patch" &&
|
||||
tool.state.status !== "error" &&
|
||||
Array.isArray(previous) &&
|
||||
previous?.[0]?.name === "patch" &&
|
||||
previous[0].state.status !== "error"
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
}
|
||||
if (
|
||||
tool.name === "skill" &&
|
||||
tool.state.status !== "error" &&
|
||||
skillToolName(currentToolInput(tool), currentToolMetadata(tool)) &&
|
||||
Array.isArray(previous) &&
|
||||
previous?.[0]?.name === "skill" &&
|
||||
previous[0].state.status !== "error" &&
|
||||
skillToolName(currentToolInput(previous[0]), currentToolMetadata(previous[0]))
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
}
|
||||
groups.push([tool])
|
||||
return groups
|
||||
}
|
||||
if (
|
||||
tool.name === "skill" &&
|
||||
tool.state.status !== "error" &&
|
||||
skillToolName(currentToolInput(tool), currentToolMetadata(tool)) &&
|
||||
previous?.[0]?.name === "skill" &&
|
||||
previous[0].state.status !== "error" &&
|
||||
skillToolName(currentToolInput(previous[0]), currentToolMetadata(previous[0]))
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
}
|
||||
groups.push([tool])
|
||||
return groups
|
||||
}, []),
|
||||
},
|
||||
[],
|
||||
),
|
||||
)
|
||||
const change = (open: boolean) => {
|
||||
props.onOpenChange(open)
|
||||
|
|
@ -532,7 +550,7 @@ export function CurrentContextToolGroup(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<div data-component="collapsed-tool-group" data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}>
|
||||
<div data-component="collapsed-tool-group" data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}>
|
||||
<BasicTool
|
||||
icon="glasses"
|
||||
status={pending() ? "running" : "completed"}
|
||||
|
|
@ -550,123 +568,162 @@ export function CurrentContextToolGroup(props: {
|
|||
<Show when={label().after}>
|
||||
{(after) => <span data-slot="context-tool-group-prefix">{after()}</span>}
|
||||
</Show>
|
||||
<Badge>{props.tools.length}</Badge>
|
||||
<Badge>{tools().length}</Badge>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-component="context-tool-group-list">
|
||||
<Index each={items()}>
|
||||
{(group) => {
|
||||
const tool = createMemo(() => group()[0]!)
|
||||
const trigger = createMemo(() => currentContextToolTrigger(tool(), i18n))
|
||||
const skills = createMemo(() =>
|
||||
group().flatMap((item) => {
|
||||
const name = skillToolName(currentToolInput(item), currentToolMetadata(item))
|
||||
return name ? [name] : []
|
||||
}),
|
||||
)
|
||||
const marker = "__OPENCODE_LOADED_SKILL__"
|
||||
const loaded = createMemo(() => i18n.plural("ui.tool.loadedSkills", skills().length, { name: marker }))
|
||||
{(item) => {
|
||||
const group = createMemo(() => {
|
||||
const value = item()
|
||||
return Array.isArray(value) ? value : undefined
|
||||
})
|
||||
const reasoning = createMemo(() => {
|
||||
const value = item()
|
||||
return Array.isArray(value) ? undefined : value
|
||||
})
|
||||
return (
|
||||
<div data-slot="context-tool-group-item">
|
||||
<Show
|
||||
when={tool().state.status !== "error" && ["read", "glob", "grep", "list"].includes(tool().name)}
|
||||
fallback={
|
||||
<Show
|
||||
when={tool().name === "skill" && group().length > 1 && skills().length === group().length}
|
||||
fallback={
|
||||
<Show
|
||||
when={tool().name === "patch" && tool().state.status !== "error"}
|
||||
fallback={
|
||||
<ToolDisplay
|
||||
id={tool().id}
|
||||
tool={tool().name}
|
||||
input={currentToolInput(tool())}
|
||||
metadata={currentToolMetadata(tool())}
|
||||
output={currentToolOutput(tool())}
|
||||
error={currentToolError(tool())}
|
||||
status={tool().state.status}
|
||||
defaultOpen={false}
|
||||
deferContent
|
||||
virtualizeDiff={false}
|
||||
onContentRendered={props.onSizeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CurrentFileToolGroup tools={group()} onSizeChange={props.onSizeChange} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-component="tool-loaded-item"
|
||||
data-timeline-part-ids={group()
|
||||
.map((item) => item.id)
|
||||
.join(",")}
|
||||
aria-label={i18n.plural("ui.tool.loadedSkills", skills().length, {
|
||||
name: skills().join(", "),
|
||||
})}
|
||||
>
|
||||
<span data-slot="tool-loaded-label" aria-hidden="true">
|
||||
{loaded().split(marker)[0]?.trim()}
|
||||
</span>
|
||||
<span data-slot="tool-loaded-value" aria-hidden="true">
|
||||
<For each={skills()}>
|
||||
{(name, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>, </Show>
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={name}
|
||||
active={["streaming", "running"].includes(group()[index()]!.state.status)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
<Show when={loaded().split(marker)[1]?.trim()}>
|
||||
{(suffix) => (
|
||||
<span data-slot="tool-loaded-kind" aria-hidden="true">
|
||||
{suffix()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={group()}
|
||||
fallback={
|
||||
<Show when={reasoning()}>
|
||||
{(part) => (
|
||||
<div data-slot="context-tool-group-item">
|
||||
<AssistantReasoningContent
|
||||
id={part().id}
|
||||
content={part()}
|
||||
streaming={false}
|
||||
defaultOpen={props.reasoningDefaultOpen}
|
||||
open={props.reasoningOpen?.(part().id)}
|
||||
onOpenChange={(open) => props.onReasoningOpenChange?.(part().id, open)}
|
||||
onContentRendered={props.onSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer
|
||||
text={trigger().title}
|
||||
active={tool().state.status === "streaming" || tool().state.status === "running"}
|
||||
/>
|
||||
</span>
|
||||
<Show when={trigger().subtitle}>
|
||||
{(subtitle) => <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
|
||||
</Show>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={trigger().matches}>
|
||||
{(matches) => (
|
||||
<>
|
||||
<span data-slot="context-tool-group-dot" />
|
||||
<span data-slot="context-tool-group-matches">{matches()}</span>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(group) => {
|
||||
const tool = createMemo(() => group()[0]!)
|
||||
const trigger = createMemo(() => currentContextToolTrigger(tool(), i18n))
|
||||
const skills = createMemo(() =>
|
||||
group().flatMap((item) => {
|
||||
const name = skillToolName(currentToolInput(item), currentToolMetadata(item))
|
||||
return name ? [name] : []
|
||||
}),
|
||||
)
|
||||
const marker = "__OPENCODE_LOADED_SKILL__"
|
||||
const loaded = createMemo(() =>
|
||||
i18n.plural("ui.tool.loadedSkills", skills().length, { name: marker }),
|
||||
)
|
||||
return (
|
||||
<div data-slot="context-tool-group-item">
|
||||
<Show
|
||||
when={
|
||||
tool().state.status !== "error" && ["read", "glob", "grep", "list"].includes(tool().name)
|
||||
}
|
||||
fallback={
|
||||
<Show
|
||||
when={tool().name === "skill" && group().length > 1 && skills().length === group().length}
|
||||
fallback={
|
||||
<Show
|
||||
when={tool().name === "patch" && tool().state.status !== "error"}
|
||||
fallback={
|
||||
<ToolDisplay
|
||||
id={tool().id}
|
||||
tool={tool().name}
|
||||
input={currentToolInput(tool())}
|
||||
metadata={currentToolMetadata(tool())}
|
||||
output={currentToolOutput(tool())}
|
||||
error={currentToolError(tool())}
|
||||
status={tool().state.status}
|
||||
defaultOpen={false}
|
||||
deferContent
|
||||
virtualizeDiff={false}
|
||||
onContentRendered={props.onSizeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CurrentFileToolGroup tools={group()} onSizeChange={props.onSizeChange} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-component="tool-loaded-item"
|
||||
data-timeline-part-ids={group()
|
||||
.map((item) => item.id)
|
||||
.join(",")}
|
||||
aria-label={i18n.plural("ui.tool.loadedSkills", skills().length, {
|
||||
name: skills().join(", "),
|
||||
})}
|
||||
>
|
||||
<span data-slot="tool-loaded-label" aria-hidden="true">
|
||||
{loaded().split(marker)[0]?.trim()}
|
||||
</span>
|
||||
<span data-slot="tool-loaded-value" aria-hidden="true">
|
||||
<For each={skills()}>
|
||||
{(name, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>, </Show>
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={name}
|
||||
active={["streaming", "running"].includes(group()[index()]!.state.status)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
<Show when={loaded().split(marker)[1]?.trim()}>
|
||||
{(suffix) => (
|
||||
<span data-slot="tool-loaded-kind" aria-hidden="true">
|
||||
{suffix()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer
|
||||
text={trigger().title}
|
||||
active={
|
||||
tool().state.status === "streaming" || tool().state.status === "running"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<Show when={trigger().subtitle}>
|
||||
{(subtitle) => <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
|
||||
</Show>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={trigger().matches}>
|
||||
{(matches) => (
|
||||
<>
|
||||
<span data-slot="context-tool-group-dot" />
|
||||
<span data-slot="context-tool-group-matches">{matches()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
|
|
@ -682,20 +739,23 @@ export function CurrentFileToolGroup(props: {
|
|||
onFileOpenChange?: (path: string, open: boolean) => void
|
||||
onSizeChange?: () => void
|
||||
}) {
|
||||
const files = createMemo((previous: { key: string; value: unknown }[]) => {
|
||||
const files = createMemo((previous: { key: string; toolID: string; value: unknown }[]) => {
|
||||
const next = props.tools.flatMap((tool) => {
|
||||
const files = currentToolMetadata(tool).files
|
||||
if (!Array.isArray(files)) return []
|
||||
return files.map((value, index) => ({ key: `${tool.id}:${index}`, value }))
|
||||
return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value }))
|
||||
})
|
||||
const updates = new Map(next.map((entry) => [entry.key, entry.value]))
|
||||
const existing = new Set(previous.map((entry) => entry.key))
|
||||
const owners = new Set(props.tools.map((tool) => tool.id))
|
||||
const result = [
|
||||
...previous.map((entry) => {
|
||||
if (!updates.has(entry.key)) return entry
|
||||
const value = updates.get(entry.key)
|
||||
return samePatchFile(value, entry.value) ? entry : { key: entry.key, value }
|
||||
}),
|
||||
...previous
|
||||
.filter((entry) => owners.has(entry.toolID))
|
||||
.map((entry) => {
|
||||
if (!updates.has(entry.key)) return entry
|
||||
const value = updates.get(entry.key)
|
||||
return samePatchFile(value, entry.value) ? entry : { ...entry, value }
|
||||
}),
|
||||
...next.filter((entry) => !existing.has(entry.key)),
|
||||
]
|
||||
return result.length === previous.length && result.every((entry, index) => entry === previous[index])
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ const source = {
|
|||
"ui.message.revertMessage": "Revert message",
|
||||
"ui.message.copyResponse": "Copy response",
|
||||
"ui.message.copied": "Copied",
|
||||
"ui.message.thought": "Thought",
|
||||
"ui.message.duration.seconds": "{{count}}s",
|
||||
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"ui.message.interrupted": "Interrupted",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue