Skip to content
Merged
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
15 changes: 15 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2614,8 +2614,10 @@ describe("deriveMessagesTimelineRows", () => {
it("reuses one activity row for initial thinking and the latest tool", () => {
const deriveRows = (
toolLifecycleStatus: "inProgress" | "completed" | "failed" | "declined" | null,
expandedWorkGroupIds?: ReadonlySet<string>,
) =>
deriveMessagesTimelineRows({
...(expandedWorkGroupIds ? { expandedWorkGroupIds } : {}),
timelineEntries:
toolLifecycleStatus === null
? []
Expand Down Expand Up @@ -2663,6 +2665,19 @@ describe("deriveMessagesTimelineRows", () => {
expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true });
expect(failedRows.some((row) => row.kind === "work-live")).toBe(false);
expect(failedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
const failedThinkingRow = failedRows.at(-1);
const failedGroupId =
failedThinkingRow?.kind === "thinking" ? failedThinkingRow.groupId : undefined;
expect(failedGroupId).toBeDefined();
const expandedFailedRows = deriveRows("failed", new Set([failedGroupId!]));
expect(expandedFailedRows.slice(-2)).toMatchObject([
{ kind: "thinking", id: "live-activity-row", expanded: true },
{
kind: "work",
isExpandedToolGroup: true,
groupedEntries: [{ id: "latest-command" }],
},
]);
expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false });
expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
Expand Down
42 changes: 36 additions & 6 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,9 @@ type MessagesTimelineRowContent =
kind: "thinking";
id: string;
createdAt: string | null;
/** Tool calls this row stands in for after the latest one failed. */
groupId?: string;
expanded?: boolean;
}
| {
kind: "work-toggle";
Expand Down Expand Up @@ -1728,11 +1731,35 @@ export function deriveMessagesTimelineRows(input: {
!hasActiveCompaction &&
(!hasActivityRow || latestToolFailed)
) {
nextRows.push({
kind: "thinking",
id: LIVE_ACTIVITY_ROW_ID,
createdAt: input.activeTurnStartedAt ?? null,
});
// A failed latest tool hands the row back to thinking, but its group
// stays reachable through the same disclosure the live row offers.
const failedGroupAnchor = latestToolFailed ? activeWorkAnchor : undefined;
if (failedGroupAnchor) {
const groupId = workGroupId(failedGroupAnchor.id);
const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
nextRows.push({
kind: "thinking",
id: LIVE_ACTIVITY_ROW_ID,
createdAt: input.activeTurnStartedAt ?? null,
groupId,
expanded,
});
if (expanded) {
nextRows.push(
expandedWorkGroupRow(
groupId,
failedGroupAnchor.createdAt,
visibleActiveToolEntries.map((entry) => entry.entry),
),
);
}
} else {
nextRows.push({
kind: "thinking",
id: LIVE_ACTIVITY_ROW_ID,
createdAt: input.activeTurnStartedAt ?? null,
});
}
}

const result = attachTrailingToolGroupsToAssistant(
Expand Down Expand Up @@ -1943,8 +1970,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean

switch (a.kind) {
case "working":
case "thinking":
return a.createdAt === (b as typeof a).createdAt;
case "thinking": {
const bt = b as typeof a;
return a.createdAt === bt.createdAt && a.groupId === bt.groupId && a.expanded === bt.expanded;
}
case "worktree-setup":
return a.snapshot === (b as typeof a).snapshot;

Expand Down
25 changes: 18 additions & 7 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1741,7 +1741,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
row.kind === "work-toggle" ||
row.kind === "thinking";
const isExpandedToolGroupHeader =
(row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);
(row.kind === "work-toggle" || row.kind === "work-live" || row.kind === "thinking") &&
row.expanded === true;

return (
<div
Expand Down Expand Up @@ -1793,7 +1794,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
) : null}
{row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
{row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
{row.kind === "thinking" ? <ThinkingTimelineRow /> : null}
{row.kind === "thinking" ? <ThinkingTimelineRow row={row} /> : null}
</WorkLogBlock>
) : null}
{row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
Expand Down Expand Up @@ -3461,13 +3462,23 @@ function CompactingLabel() {
);
}

function ThinkingTimelineRow() {
function ThinkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "thinking" }> }) {
const ctx = use(TimelineRowCtx);
const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
// Reserve the activity row during setup so the handoff keeps the same height.
return isPreparingWorktree || isCompacting ? (
<WorkLogRow label="" />
) : (
<LiveActivityRow label="Thinking" iconName="brain" active shimmer />
if (isPreparingWorktree || isCompacting) return <WorkLogRow label="" />;
const activity = <LiveActivityRow label="Thinking" iconName="brain" active shimmer />;
const { groupId } = row;
if (groupId === undefined) return activity;
return (
<button
type="button"
className="group/live-work flex min-h-6 w-full max-w-full cursor-pointer items-center rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
aria-expanded={row.expanded === true}
onClick={() => ctx.onToggleWorkGroup(groupId, row.id)}
>
{activity}
</button>
);
}

Expand Down
Loading