diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index d15543515e..1766d2e475 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -18,9 +18,10 @@ import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems"; -import type { - ThreadGrouping, - ThreadRow, +import { + groupItemRendersContent, + type ThreadGrouping, + type ThreadRow, } from "@posthog/ui/features/sessions/components/new-thread/buildThreadGroups"; import type { CollapseMode } from "@posthog/ui/features/sessions/components/new-thread/conversationThreadConfig"; import { createIncrementalThreadGrouper } from "@posthog/ui/features/sessions/components/new-thread/incrementalThreadGrouping"; @@ -294,17 +295,28 @@ export function ConversationView({ const renderRow = useCallback( (row: ThreadRow) => { if (row.kind === "item") return renderItem(row.item); + // Only items that actually render content reach the chip body — otherwise + // a turn whose sole activity was a blank thinking block would expand to an + // empty bordered box (the box draws whenever it has children, even hidden + // ones). When nothing is renderable the chip is a plain summary line with + // no expand affordance, rather than a caret that opens onto nothing. + const hasVisibleContent = row.items.some(groupItemRendersContent); + const visibleItems = + row.expanded && hasVisibleContent + ? row.items.filter(groupItemRendersContent) + : []; return ( sessionViewActions.setGroupOverride(row.id, !row.expanded) } > - {row.expanded - ? row.items.map((it) => { + {visibleItems.length > 0 + ? visibleItems.map((it) => { // Plain assistant text inside the group has no leading icon, so // pad it to line up with the tool titles (the text-next-to-icon // column = ToolCallBlock's pl-3 + the icon/gap width). Tool and diff --git a/packages/ui/src/features/sessions/components/new-thread/ToolCallGroupChip.tsx b/packages/ui/src/features/sessions/components/new-thread/ToolCallGroupChip.tsx index 94e8a1cd4e..fdfa18b1a5 100644 --- a/packages/ui/src/features/sessions/components/new-thread/ToolCallGroupChip.tsx +++ b/packages/ui/src/features/sessions/components/new-thread/ToolCallGroupChip.tsx @@ -15,6 +15,12 @@ interface ToolCallGroupChipProps { expanded: boolean; turnComplete: boolean; onToggle: () => void; + /** + * Whether the chip can be expanded. False when the group has no renderable + * body (e.g. a turn whose only activity was a blank thinking block) — the + * chip then reads as a plain summary line with no caret. Defaults to true. + */ + expandable?: boolean; /** Rendered group items, shown inside the ToolRow's box when expanded. */ children?: ReactNode; } @@ -30,6 +36,7 @@ export function ToolCallGroupChip({ expanded, turnComplete, onToggle, + expandable = true, children, }: ToolCallGroupChipProps) { const reduceMotion = useReducedMotion(); @@ -56,17 +63,19 @@ export function ToolCallGroupChip({ className="pl-3" > - + + {expandable ? ( + + ) : null} } trailing={ diff --git a/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.test.ts b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.test.ts new file mode 100644 index 0000000000..c0b33031bb --- /dev/null +++ b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.test.ts @@ -0,0 +1,152 @@ +import type { + ConversationItem, + TurnContext, +} from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { + buildThreadGroups, + groupItemRendersContent, +} from "@posthog/ui/features/sessions/components/new-thread/buildThreadGroups"; +import { describe, expect, it } from "vitest"; + +const activeContext: TurnContext = { + toolCalls: new Map(), + childItems: new Map(), + turnCancelled: false, + turnComplete: false, +}; + +const completeContext: TurnContext = { + toolCalls: new Map(), + childItems: new Map(), + turnCancelled: false, + turnComplete: true, +}; + +function thought( + id: string, + { + thoughtComplete, + text = "pondering", + }: { thoughtComplete?: boolean; text?: string }, + turnContext: TurnContext = activeContext, +): ConversationItem { + return { + type: "session_update", + id, + turnContext, + thoughtComplete, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text }, + }, + }; +} + +function toolItem( + id: string, + turnContext: TurnContext = activeContext, +): ConversationItem { + return { + type: "session_update", + id, + turnContext, + update: { + sessionUpdate: "tool_call", + toolCallId: id, + kind: "read", + title: "Read file.ts", + status: turnContext.turnComplete ? "completed" : "in_progress", + }, + }; +} + +/** The single tool_group row's summary, or a failed assertion. */ +function summaryOf(items: ConversationItem[]) { + const { rows } = buildThreadGroups(items, "all", {}); + const group = rows.find((r) => r.kind === "tool_group"); + if (group?.kind !== "tool_group") + throw new Error("expected a tool_group row"); + return group.summary; +} + +describe("buildThreadGroups summary — thinking awareness", () => { + it.each([ + { + // A still-streaming thought is the only activity so far: the chip must say + // it's thinking, not fall back to the done label. + name: "reads a turn mid extended-thinking as live, not 'Worked'", + items: [thought("th1", { thoughtComplete: false })], + active: true, + liveLabel: "Thinking…", + hasCountableWork: false, + doneLabel: "Worked", + }, + { + // Thought, then an in-flight tool call: the tool is the latest activity, + // so its title wins over the thinking label. + name: "keeps the tool's live label when a tool runs after thinking", + items: [thought("th1", { thoughtComplete: true }), toolItem("t1")], + active: true, + liveLabel: "Read file.ts", + hasCountableWork: true, + doneLabel: "Read a file", + }, + { + // Tool finished, agent is thinking once more: countable work plus a live + // thinking label, so the chip can read "Read a file · Thinking…". + name: "shows thinking again when a thought trails completed tool work", + items: [toolItem("t1"), thought("th1", { thoughtComplete: false })], + active: true, + liveLabel: "Thinking…", + hasCountableWork: true, + doneLabel: "Read a file", + }, + { + // A finished turn whose only activity was thinking: no live label, falls + // back to the "Worked" done label (there is no countable tool work). + name: "does not treat a completed thought as live work", + items: [thought("th1", { thoughtComplete: true }, completeContext)], + active: false, + liveLabel: null, + hasCountableWork: false, + doneLabel: "Worked", + }, + ])("$name", ({ items, active, liveLabel, hasCountableWork, doneLabel }) => { + const summary = summaryOf(items); + + expect(summary.active).toBe(active); + expect(summary.liveLabel).toBe(liveLabel); + expect(summary.hasCountableWork).toBe(hasCountableWork); + expect(summary.doneLabel).toBe(doneLabel); + }); +}); + +describe("groupItemRendersContent", () => { + it.each([ + { + name: "a completed thought with text renders", + item: thought("th", { thoughtComplete: true, text: "reasoned" }), + expected: true, + }, + { + // The bug source: blank extended-thinking streams as a text-less thought + // chunk, which renders nothing once complete — so it must not keep the + // chip's bordered box alive. + name: "a completed blank thought renders nothing", + item: thought("th", { thoughtComplete: true, text: " " }), + expected: false, + }, + { + name: "a blank thought still streaming renders (its spinner)", + item: thought("th", { thoughtComplete: false, text: "" }), + expected: true, + }, + { + name: "a tool call renders", + item: toolItem("t1", completeContext), + expected: true, + }, + ])("$name", ({ item, expected }) => { + expect(groupItemRendersContent(item)).toBe(expected); + }); +}); diff --git a/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts index e4ea31e509..71cd017987 100644 --- a/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts +++ b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts @@ -11,6 +11,9 @@ import { SUBAGENT_ICON, } from "@posthog/ui/features/sessions/components/new-thread/conversationThreadConfig"; +/** Live label for a turn that is mid extended-thinking (no tool call yet). */ +const THINKING_LIVE_LABEL = "Thinking…"; + export interface GroupIconEntry { Icon: Icon; key: GroupIconKey; @@ -126,6 +129,27 @@ export function isGroupableItem(item: ConversationItem): boolean { return true; } +/** + * Whether a grouped item renders anything visible inside an expanded chip. + * Mirrors the `null`-returning branches of SessionUpdateView / ThoughtView so + * the chip can drop its bordered box when expanding would reveal nothing — + * otherwise a turn whose only activity was an empty thinking block (blank + * extended-thinking streams as a text-less thought chunk) shows an empty box. + */ +export function groupItemRendersContent(item: ConversationItem): boolean { + if (item.type !== "session_update") return true; + const update = item.update; + if (update.sessionUpdate === "user_message_chunk") return false; + if (update.sessionUpdate === "agent_thought_chunk") { + const hasText = + update.content.type === "text" && update.content.text.trim().length > 0; + // A blank thought still renders a spinner while streaming; only a blank + // *completed* thought collapses to nothing (see ThoughtView). + return hasText || item.thoughtComplete !== true; + } + return true; +} + function summarize(items: ConversationItem[]): GroupSummary { const counts: GroupCounts = { execute: 0, @@ -202,8 +226,24 @@ function summarize(items: ConversationItem[]): GroupSummary { } } + // The agent's extended thinking streams as thought chunks, which carry no + // tool status or title. Without accounting for them, a turn that is mid- + // thought (before its first tool call) summarizes as "Worked" with no + // spinner — reading as finished while the agent is actively thinking. Treat a + // trailing, still-streaming thought as live work so the collapsed chip shows + // "Thinking…" and spins. `thoughtComplete` is the same flag ThoughtView uses + // to drive its own loading state. + const last = items[items.length - 1]; + const streamingThought = + last?.type === "session_update" && + last.update.sessionUpdate === "agent_thought_chunk" && + last.thoughtComplete !== true; + if (streamingThought) liveLabel = THINKING_LIVE_LABEL; + const active = - lastToolStatus === "pending" || lastToolStatus === "in_progress"; + streamingThought || + lastToolStatus === "pending" || + lastToolStatus === "in_progress"; const hasCountableWork = counts.execute + counts.read + diff --git a/packages/ui/src/features/sessions/components/session-update/ThoughtView.tsx b/packages/ui/src/features/sessions/components/session-update/ThoughtView.tsx index b1d9b0cc22..bc9a079457 100644 --- a/packages/ui/src/features/sessions/components/session-update/ThoughtView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ThoughtView.tsx @@ -23,6 +23,11 @@ export const ThoughtView = memo(function ThoughtView({ {content} : undefined} > Thinking