Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions packages/ui/src/features/sessions/components/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<ToolCallGroupChip
summary={row.summary}
expanded={row.expanded}
expandable={hasVisibleContent}
turnComplete={row.turnComplete}
onToggle={() =>
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -30,6 +36,7 @@ export function ToolCallGroupChip({
expanded,
turnComplete,
onToggle,
expandable = true,
children,
}: ToolCallGroupChipProps) {
const reduceMotion = useReducedMotion();
Expand All @@ -56,17 +63,19 @@ export function ToolCallGroupChip({
className="pl-3"
>
<ToolRow
collapsible
open={expanded}
onOpenChange={onToggle}
content={children}
collapsible={expandable}
open={expandable ? expanded : undefined}
onOpenChange={expandable ? onToggle : undefined}
content={expandable ? children : undefined}
leading={
<span className="shrink-0 pt-1">
<Caret
size={12}
weight="bold"
className="text-gray-10 transition-colors group-hover:text-gray-12"
/>
<span className="flex w-3 shrink-0 justify-center pt-1">
{expandable ? (
<Caret
size={12}
weight="bold"
className="text-gray-10 transition-colors group-hover:text-gray-12"
/>
) : null}
</span>
}
trailing={
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Comment on lines +72 to +122

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Prefer parameterised tests

The four cases share the same shape — a list of ConversationItems in, a set of GroupSummary fields out — which is the canonical fit for it.each. With separate it() blocks, adding or adjusting a case also requires updating the surrounding boilerplate, and the inline comments duplicate what a table's column names would express. Consolidating into an it.each table (with active, liveLabel, hasCountableWork, and doneLabel as expectation columns) keeps every scenario OnceAndOnlyOnce and matches the repo's stated test convention.

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export const ThoughtView = memo(function ThoughtView({
<ToolRow
icon={Brain}
isLoading={isLoading}
// Thinking has no useful one-line summary, so a collapsed "Thinking"
// header conveys nothing — show the text inline. The surrounding chip /
// collapse mode already decides whether the thought is revealed at all;
// once it is, the reasoning itself is what's worth reading.
defaultOpen
content={hasContent ? <ContentPre>{content}</ContentPre> : undefined}
>
Thinking
Expand Down
Loading