diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts index 852564b7b4b..0710cecc00f 100644 --- a/packages/app/e2e/regression/timeline-stream.spec.ts +++ b/packages/app/e2e/regression/timeline-stream.spec.ts @@ -543,6 +543,26 @@ test.describe("Working", () => { await expect(working).toHaveCount(0) }) + test("keeps Working instead of Used 1 Thought while reasoning is the only activity", async ({ page }) => { + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([reasoningPart("prt_only_thought", "Still considering the answer.")], { completed: false }), + ], + settings: { timelineDetail: detailed }, + }) + 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) + await expect(page.getByRole("button", { name: "Used 1 Thought" })).toHaveCount(0) + + 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(working).toBeVisible() + }) + for (const name of ["shell", "patch", "subagent"] as const) { test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => { const timeline = await setupTimeline(page, { diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index f51ab32fb96..19b1536702c 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -308,15 +308,25 @@ export namespace Timeline { const previousUserMessage = index > 0 const compaction = entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction") const lastContent = lastAssistant?.content.at(-1) - const thinking = - (detail ? detail.thinking.placement === "separate" : showReasoning) && + const working = isActive && status.type === "busy" && lastAssistant?.time.completed === undefined && !lastAssistant?.error && - !lastAssistant?.retry && + !lastAssistant?.retry + const thinking = + working && + (detail ? detail.thinking.placement === "separate" : showReasoning) && 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 })) @@ -326,6 +336,7 @@ 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( diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index d14c0612815..5b13a463af5 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -264,6 +264,54 @@ describe("current session timeline rows", () => { }) }) + test("omits a grouped reasoning-only row while busy until other content or completion", () => { + const thought = { type: "reasoning", text: "Thinking", time: { created: 1, completed: 2 } } as const + const rows = (content: SessionMessageAssistant["content"], busy: boolean) => + createTimelineProjection({ + sessionMessages: storyDocument(content, busy).messages, + status: { type: busy ? "busy" : "idle" }, + reasoningMode: "compact", + 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, { type: "text", text: "Answer" }], true).map((row) => row._tag)).toEqual([ + "UserMessage", + "AssistantPart", + "AssistantPart", + ]) + expect( + rows([thought, storyTool("read", "read", "running", { filePath: "package.json" })], true).map((row) => row._tag), + ).toEqual(["UserMessage", "AssistantPart"]) + expect(rows([thought], false).map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"]) + + const failed = storyDocument([thought], true) + const messages = failed.messages.map((message) => + message.type === "assistant" ? { ...message, error: { type: "provider.error", message: "Failed" } } : message, + ) + expect( + createTimelineProjection({ + sessionMessages: messages, + status: failed.status, + reasoningMode: "compact", + timelineDetail: timelinePresets[2].value, + }).rows.map((row) => row._tag), + ).toEqual(["UserMessage", "AssistantPart", "Error"]) + + expect( + createTimelineProjection({ + sessionMessages: failed.messages.map((message) => + message.type === "assistant" ? { ...message, error: { type: "Interrupted", message: "Stopped" } } : message, + ), + status: failed.status, + reasoningMode: "compact", + timelineDetail: { ...timelinePresets[2].value, notices: { placement: "hidden" } }, + }).rows.map((row) => row._tag), + ).toEqual(["UserMessage", "AssistantPart"]) + }) + test("stops thinking on idle, message completion, errors and retries", () => { const document = storyDocument([{ type: "reasoning", text: "Current thought" }], true) expect(