mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
fix(app): show live Thinking instead of hiding reasoning-only turns (#58299)
This commit is contained in:
committed by
GitHub
Unverified
parent
59ccd1c01d
commit
11133d178b
@@ -681,7 +681,9 @@ test.describe("Working", () => {
|
||||
await expect(working).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("keeps Working instead of Used 1 Thought while reasoning is the only activity", async ({ page }) => {
|
||||
test("shows an expandable live Thinking in place of Working while reasoning is the only activity", async ({
|
||||
page,
|
||||
}) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
@@ -690,15 +692,36 @@ test.describe("Working", () => {
|
||||
settings: { timelineDetail: detailed },
|
||||
})
|
||||
|
||||
// reasoningPart starts at 1700000001000, so the pause lands 8m 12s into the thought.
|
||||
await page.clock.install({ time: 1700000001000 })
|
||||
await page.reload()
|
||||
await timeline.transport.waitForConnection()
|
||||
await page.clock.pauseAt(1700000001000 + 492_000)
|
||||
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(working).toBeVisible()
|
||||
await expect(group).toHaveCount(0)
|
||||
const trigger = group.locator('[data-component="context-tool-group-trigger"]')
|
||||
const elapsed = trigger.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", `${assistantID}:reasoning:0`)
|
||||
await expect(trigger).toHaveAttribute("aria-label", "Thinking")
|
||||
await expect(trigger.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
await expect(elapsed).toHaveText("8m 12s")
|
||||
await expect(working).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Used 1 Thought" })).toHaveCount(0)
|
||||
|
||||
await page.clock.fastForward(1_000)
|
||||
await expect(elapsed).toHaveText("8m 13s")
|
||||
|
||||
await trigger.click()
|
||||
await expect(group.getByText("Still considering the answer.", { exact: true })).toBeVisible()
|
||||
|
||||
// Event delivery schedules timers, so let time run again before streaming the next part.
|
||||
await page.clock.resume()
|
||||
|
||||
await timeline.send(partUpdated(toolPart("prt_after_thought", "read", "running", { filePath: "package.json" })))
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", `${assistantID}:reasoning:0,prt_after_thought`)
|
||||
await expect(trigger).toHaveAttribute("aria-label", "Used 1 Read")
|
||||
await expect(elapsed).toHaveCount(0)
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -419,9 +419,16 @@ function MessageTimelineView(
|
||||
return message?.type === "compaction" && message.status === "running"
|
||||
}
|
||||
|
||||
if (!Predicate.isTagged(row, "AssistantPart")) return false
|
||||
|
||||
// A live group of only thoughts reads as Thinking and stands in for Working.
|
||||
if (row.group.type === "context" && projection.lastAssistantGroupKey().get(active) === row.group.key)
|
||||
return row.group.refs.every(
|
||||
(ref) => Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID)?.type === "reasoning",
|
||||
)
|
||||
|
||||
// Used and read groups keep the fallback, so each new read does not swap Working out for its short call.
|
||||
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context" || row.group.type === "read")
|
||||
return false
|
||||
if (row.group.type === "context" || row.group.type === "read") return false
|
||||
|
||||
return (row.group.type === "part" ? [row.group.ref] : row.group.refs).some((ref) => {
|
||||
const content = Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID)
|
||||
|
||||
@@ -505,15 +505,6 @@ export namespace Timeline {
|
||||
lastContent?.type === "reasoning" &&
|
||||
lastContent.time?.completed === undefined
|
||||
|
||||
const thoughtOnly =
|
||||
working &&
|
||||
detail?.thinking.placement === "grouped" &&
|
||||
assistantMessages.every((message) =>
|
||||
message.content.every(
|
||||
(content) => content.type === "reasoning" || !isRenderable(content, showReasoning, detail),
|
||||
),
|
||||
)
|
||||
|
||||
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: turnID }))
|
||||
|
||||
if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID }))
|
||||
@@ -524,8 +515,6 @@ export namespace Timeline {
|
||||
// An assistant message can produce several rows because its content parts are
|
||||
// rendered separately. Notices end a segment so none of those rows cross it.
|
||||
const appendAssistantSegment = (messages: SessionMessageAssistant[]) => {
|
||||
if (thoughtOnly) return
|
||||
|
||||
const refs = messages.flatMap((message, messageIndex) =>
|
||||
contentEntries(message)
|
||||
.filter(
|
||||
|
||||
@@ -277,7 +277,7 @@ describe("current session timeline rows", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("omits a grouped reasoning-only row while busy until other content or completion", () => {
|
||||
test("keeps a grouped reasoning-only row while busy", () => {
|
||||
const thought = { type: "reasoning", text: "Thinking", time: { created: 1, completed: 2 } } as const
|
||||
|
||||
const rows = (content: SessionMessageAssistant["content"], busy: boolean) =>
|
||||
@@ -288,9 +288,15 @@ describe("current session timeline rows", () => {
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
}).rows
|
||||
|
||||
expect(rows([{ ...thought, time: { created: 1 } }], true).map((row) => row._tag)).toEqual(["UserMessage"])
|
||||
expect(rows([thought], true).map((row) => row._tag)).toEqual(["UserMessage"])
|
||||
expect(rows([thought, { type: "text", text: "" }], true).map((row) => row._tag)).toEqual(["UserMessage"])
|
||||
expect(rows([{ ...thought, time: { created: 1 } }], true).map((row) => row._tag)).toEqual([
|
||||
"UserMessage",
|
||||
"AssistantPart",
|
||||
])
|
||||
expect(rows([thought], true).map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
|
||||
expect(rows([thought, { type: "text", text: "" }], true).map((row) => row._tag)).toEqual([
|
||||
"UserMessage",
|
||||
"AssistantPart",
|
||||
])
|
||||
expect(rows([thought, { type: "text", text: "Answer" }], true).map((row) => row._tag)).toEqual([
|
||||
"UserMessage",
|
||||
"AssistantPart",
|
||||
|
||||
@@ -429,8 +429,48 @@ export function CurrentContextToolGroup(props: {
|
||||
]),
|
||||
)
|
||||
|
||||
const reasoning = createMemo(() => props.parts.flatMap((part) => (part.type === "reasoning" ? [part] : [])))
|
||||
// Thoughts alone are not finished work yet, so the live group reads as Thinking, in place of Working, rather than
|
||||
// "Used 1 Thought".
|
||||
const thinking = createMemo(() => props.busy && reasoning().length > 0 && reasoning().length === props.parts.length)
|
||||
const [now, setNow] = createSignal(Date.now())
|
||||
|
||||
createEffect(() => {
|
||||
if (!thinking()) return
|
||||
setNow(Date.now())
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
const elapsed = createMemo(() => {
|
||||
const started = reasoning()[0]?.time?.created
|
||||
|
||||
if (!thinking() || started === undefined) return ""
|
||||
const total = Math.max(0, Math.floor((now() - started) / 1000))
|
||||
const numfmt = new Intl.NumberFormat(i18n.locale())
|
||||
|
||||
if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt.format(total) })
|
||||
|
||||
if (total < 3600)
|
||||
return i18n.t("ui.message.duration.minutesSeconds", {
|
||||
minutes: numfmt.format(Math.floor(total / 60)),
|
||||
seconds: numfmt.format(total % 60),
|
||||
})
|
||||
|
||||
return i18n.t("ui.message.duration.hoursMinutes", {
|
||||
hours: numfmt.format(Math.floor(total / 3600)),
|
||||
minutes: numfmt.format(Math.floor(total / 60) % 60),
|
||||
})
|
||||
})
|
||||
|
||||
const label = createMemo(() => {
|
||||
const thoughts = props.parts.filter((part) => part.type === "reasoning").length
|
||||
const thoughts = reasoning().length
|
||||
|
||||
if (thinking()) {
|
||||
const text = i18n.t("ui.sessionTurn.status.thinking")
|
||||
|
||||
return { text, title: text, before: "", count: undefined, between: "", after: "" }
|
||||
}
|
||||
|
||||
if (!names() && !thoughts) {
|
||||
const text = i18n.t("ui.messagePart.context.updates")
|
||||
@@ -566,7 +606,16 @@ export function CurrentContextToolGroup(props: {
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={label().title}>{(title) => <span data-slot="basic-tool-tool-title">{title()}</span>}</Show>
|
||||
<Show when={label().title}>
|
||||
{(title) => (
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<Show when={thinking()} fallback={title()}>
|
||||
<TextShimmer text={title()} active />
|
||||
</Show>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={elapsed()}>{(value) => <span data-slot="basic-tool-tool-subtitle">{value()}</span>}</Show>
|
||||
<Show when={label().after}>
|
||||
{(after) => <span data-slot="context-tool-group-prefix">{after()}</span>}
|
||||
</Show>
|
||||
@@ -616,7 +665,8 @@ export function CurrentContextToolGroup(props: {
|
||||
id={part().id}
|
||||
content={part()}
|
||||
streaming={part().streaming ?? false}
|
||||
defaultOpen={props.reasoningDefaultOpen}
|
||||
// The group already reads as Thinking, so opening it shows the thoughts themselves.
|
||||
defaultOpen={thinking() || props.reasoningDefaultOpen}
|
||||
open={props.reasoningOpen?.(part().id)}
|
||||
onOpenChange={(open) => props.onReasoningOpenChange?.(part().id, open)}
|
||||
onContentRendered={props.onSizeChange}
|
||||
|
||||
Reference in New Issue
Block a user