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
17 changes: 5 additions & 12 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1122,7 +1122,7 @@ describe("deriveMessagesTimelineRows", () => {
]);
});

it("folds a settled subagent spawn row and keeps a live one outside the fold", () => {
it("keeps subagent spawn rows outside turn folds even after they settle", () => {
const firstMessage: ChatMessage = {
id: MessageId.make("assistant-first-entry"),
role: "assistant",
Expand Down Expand Up @@ -1161,7 +1161,6 @@ describe("deriveMessagesTimelineRows", () => {
const derive = (
timelineEntries: typeof direct,
liveAgentTaskIds: ReadonlySet<string> | undefined,
expandedSpawnEntryIds?: ReadonlySet<string>,
expandedTurnIds?: ReadonlySet<TurnId>,
) =>
deriveMessagesTimelineRows({
Expand All @@ -1171,10 +1170,8 @@ describe("deriveMessagesTimelineRows", () => {
turnDiffSummaries: [],
supportsConversationRollback: false,
liveAgentTaskIds,
expandedSpawnEntryIds,
...(expandedTurnIds ? { expandedTurnIds } : {}),
}).map((row) => row.id);
const folded = ["turn-fold:turn-1", "assistant-final-entry"];
const unfolded = ["turn-fold:turn-1", "spawn-entry", "assistant-final-entry"];

const activeRows = (
Expand Down Expand Up @@ -1237,19 +1234,15 @@ describe("deriveMessagesTimelineRows", () => {
}
}

expect(derive(direct, new Set())).toEqual(folded);
expect(derive(direct, new Set())).toEqual(unfolded);
expect(derive(direct, new Set(["agent-b"]))).toEqual(unfolded);
// A workflow coordinator between phases keeps its batch out of the fold.
expect(derive(workflow, new Set(["wf-1"]))).toEqual(unfolded);
expect(derive(workflow, new Set())).toEqual(folded);
expect(derive(workflow, new Set())).toEqual(unfolded);
// No live set is known.
expect(derive(direct, undefined)).toEqual(unfolded);
// The user has it open: it stays visible under the collapsed fold and
// keeps its place when the fold is expanded.
expect(derive(direct, new Set(), new Set(["spawn-entry"]))).toEqual(unfolded);
expect(
derive(direct, new Set(), new Set(["spawn-entry"]), new Set(["turn-1" as TurnId])),
).toEqual([
// Expanding the turn reveals the other work without duplicating the batch.
expect(derive(direct, new Set(), new Set(["turn-1" as TurnId]))).toEqual([
"turn-fold:turn-1",
"assistant-first-entry",
"spawn-entry",
Expand Down
36 changes: 6 additions & 30 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,6 @@ function deriveTurnFolds(input: {
terminalAssistantMessageIds: ReadonlySet<string>;
latestTurn: TimelineLatestTurn | null;
unfoldedTurnIds: ReadonlySet<TurnId>;
liveAgentTaskIds: ReadonlySet<string> | undefined;
}): ReadonlyMap<string, TurnFold> {
interface TurnGroup {
entries: Array<TimelineEntry>;
Expand Down Expand Up @@ -658,27 +657,13 @@ function deriveTurnFolds(input: {
if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) {
continue;
}
// User input stays visible after the surrounding work settles.
if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) {
// User input and subagent batches stay visible after their turn settles.
if (
entry.kind === "work" &&
(entry.entry.questionAnswer !== undefined || entry.entry.agentSpawn !== undefined)
) {
continue;
}
// Workflows outlive their launching turn (dynamic spawns, background
// execution), so a spawn row with a live member or coordinator stays
// outside the fold instead of hiding a still-running fleet. Settled
// spawns fold with the rest of the turn. Without a live set (no agent
// panel model, as in the held paint during a thread switch) every
// spawn row stays out.
if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) {
const live = input.liveAgentTaskIds;
const { workflowId, agentTaskIds } = entry.entry.agentSpawn;
if (
live === undefined ||
(workflowId !== null && live.has(workflowId)) ||
agentTaskIds.some((taskId) => live.has(taskId))
) {
continue;
}
}
hiddenEntryIds.add(entry.id);
}
if (hiddenEntryIds.size === 0) {
Expand Down Expand Up @@ -870,13 +855,8 @@ export function deriveMessagesTimelineRows(input: {
activeTurnStartedAt: string | null;
turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
supportsConversationRollback: boolean;
/**
* Task ids of subagents still working; their spawn row stays outside turn
* folds. Undefined means unknown, which keeps every spawn row out.
*/
/** Task ids of subagents still working, used by the active tool indicator. */
liveAgentTaskIds?: ReadonlySet<string> | undefined;
/** Spawn rows the user opened stay visible while their turn fold is collapsed. */
expandedSpawnEntryIds?: ReadonlySet<string> | undefined;
}): MessagesTimelineRow[] {
const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
for (const summary of input.turnDiffSummaries) {
Expand Down Expand Up @@ -911,15 +891,11 @@ export function deriveMessagesTimelineRows(input: {
terminalAssistantMessageIds,
latestTurn: input.latestTurn ?? null,
unfoldedTurnIds: activeVisualResponseTurnIds,
liveAgentTaskIds: input.liveAgentTaskIds,
});
const collapsedEntryIds = new Set<string>();
for (const fold of foldsByAnchorEntryId.values()) {
if (!input.expandedTurnIds?.has(fold.turnId)) {
for (const entryId of fold.hiddenEntryIds) {
// An opened spawn row keeps its fold membership but is not pulled
// away mid-read when its last member settles.
if (input.expandedSpawnEntryIds?.has(entryId)) continue;
collapsedEntryIds.add(entryId);
}
}
Expand Down
11 changes: 3 additions & 8 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -465,8 +465,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
}: MessagesTimelineProps) {
const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
// Expanded spawn rows outlive virtualization and stay visible while their
// turn fold is collapsed, so a settling fleet is not pulled away mid-read.
// Preserve member disclosure state across virtualization.
const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>(
new Set(),
);
Expand Down Expand Up @@ -628,10 +627,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
workspaceRoot: string | undefined;
projection: MessagesTimelineRowsProjection;
} | null>(null);
// Subagents still working keep their spawn row outside the turn fold. Same
// liveness rule as the row header (deriveAgentSpawnSummary): members while
// active, workflow coordinators until terminal. Keyed by content so the
// projection input keeps its identity across unrelated panel updates.
// Match the row header's liveness, retaining projection input identity
// across unrelated panel updates.
const liveAgentTaskKey = useMemo(() => {
if (agentPanelModel === undefined) return undefined;
const ids: string[] = [];
Expand Down Expand Up @@ -667,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
turnDiffSummaries,
supportsConversationRollback,
liveAgentTaskIds,
expandedSpawnEntryIds: paintedExpandedSpawnEntryIds,
},
previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot
? previous.projection
Expand All @@ -689,7 +685,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
turnDiffSummaries,
supportsConversationRollback,
liveAgentTaskIds,
paintedExpandedSpawnEntryIds,
]);
const rows = useStableRows(rawRows, listIdentityKey);
const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
Expand Down
Loading