Skip to content

Commit 5e3fd47

Browse files
authored
Merge pull request #215 from pylon-code/upstream/2026-08-31-turn-folding
fix(web): fold interim turn responses
2 parents a04f41d + 97aaed8 commit 5e3fd47

2 files changed

Lines changed: 66 additions & 17 deletions

File tree

‎apps/web/src/components/chat/MessagesTimeline.logic.test.ts‎

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ describe("deriveMessagesTimelineRows", () => {
452452
expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary);
453453
});
454454

455-
it("keeps the first and terminal assistant messages visible around settled work", () => {
455+
it("folds the first assistant message and settled work before the terminal response", () => {
456456
const timelineEntries = [
457457
{
458458
id: "user-entry",
@@ -528,7 +528,6 @@ describe("deriveMessagesTimelineRows", () => {
528528
expect(foldRow?.label).toBe("Worked for 22s");
529529
expect(collapsedRows.map((row) => row.id)).toEqual([
530530
"user-entry",
531-
"assistant-first-entry",
532531
"turn-fold:turn-1",
533532
"assistant-final-entry",
534533
]);
@@ -544,8 +543,8 @@ describe("deriveMessagesTimelineRows", () => {
544543

545544
expect(expandedRows.map((row) => row.id)).toEqual([
546545
"user-entry",
547-
"assistant-first-entry",
548546
"turn-fold:turn-1",
547+
"assistant-first-entry",
549548
"work-toggle:work-entry-1",
550549
"assistant-final-entry",
551550
]);
@@ -554,7 +553,50 @@ describe("deriveMessagesTimelineRows", () => {
554553
).toBeDefined();
555554
});
556555

557-
it("folds assistant messages between the first and terminal messages", () => {
556+
it("keeps the first assistant message when the turn ends with no text", () => {
557+
const rows = deriveMessagesTimelineRows({
558+
timelineEntries: [
559+
{
560+
id: "assistant-first-entry",
561+
kind: "message" as const,
562+
createdAt: "2026-01-01T00:00:01Z",
563+
message: {
564+
id: "assistant-first" as never,
565+
role: "assistant" as const,
566+
text: "The main result is ready.",
567+
turnId: "turn-1" as never,
568+
createdAt: "2026-01-01T00:00:01Z",
569+
updatedAt: "2026-01-01T00:00:02Z",
570+
streaming: false,
571+
},
572+
},
573+
{
574+
id: "assistant-final-entry",
575+
kind: "message" as const,
576+
createdAt: "2026-01-01T00:00:05Z",
577+
message: {
578+
id: "assistant-final" as never,
579+
role: "assistant" as const,
580+
text: "",
581+
turnId: "turn-1" as never,
582+
createdAt: "2026-01-01T00:00:05Z",
583+
updatedAt: "2026-01-01T00:00:06Z",
584+
streaming: false,
585+
},
586+
},
587+
],
588+
isWorking: false,
589+
activeTurnStartedAt: null,
590+
turnDiffSummaryByAssistantMessageId: new Map(),
591+
revertTurnCountByUserMessageId: new Map(),
592+
});
593+
594+
// Folding here would leave "Worked for 6s" above an empty response with the
595+
// answer nowhere on screen.
596+
expect(rows.map((row) => row.id)).toContain("assistant-first-entry");
597+
});
598+
599+
it("folds all assistant messages before the terminal message", () => {
558600
const timelineEntries = [
559601
{
560602
id: "assistant-first-entry",
@@ -608,11 +650,7 @@ describe("deriveMessagesTimelineRows", () => {
608650
revertTurnCountByUserMessageId: new Map(),
609651
});
610652

611-
expect(rows.map((row) => row.id)).toEqual([
612-
"assistant-first-entry",
613-
"turn-fold:turn-1",
614-
"assistant-final-entry",
615-
]);
653+
expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]);
616654
});
617655

618656
it("keeps a missing-response work row visible outside settled-turn folding", () => {

‎apps/web/src/components/chat/MessagesTimeline.logic.ts‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -524,10 +524,9 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
524524
}
525525

526526
/**
527-
* Settled turns keep their first and terminal assistant messages visible.
528-
* Everything between them folds behind a "Worked for ..." row anchored at
529-
* the first hidden entry. Keeping both ends prevents a short follow-up from
530-
* hiding a substantive opening response while still bounding noisy turns.
527+
* Settled turns keep only their terminal assistant message visible.
528+
* Everything before it folds behind a "Worked for ..." row anchored at the
529+
* first hidden entry, so the duration leads directly into the final response.
531530
*/
532531
function deriveTurnFolds(input: {
533532
timelineEntries: ReadonlyArray<TimelineEntry>;
@@ -597,12 +596,24 @@ function deriveTurnFolds(input: {
597596
if (group.hasStreamingMessage) {
598597
continue;
599598
}
600-
const firstAssistantEntry = group.entries.find(
601-
(entry): entry is Extract<TimelineEntry, { kind: "message" }> => entry.kind === "message",
602-
);
599+
// Folding everything before the terminal message assumes that message is
600+
// the answer. A turn that ends with no text at all renders as "Worked for
601+
// 22s" above "(empty response)" with the actual answer folded out of sight,
602+
// so the first assistant message stays visible in that case.
603+
const terminalText =
604+
group.terminalEntry?.kind === "message"
605+
? (group.terminalEntry.message.text?.trim() ?? "")
606+
: "";
607+
const keepFirstAssistantEntry = group.terminalEntry !== null && terminalText.length === 0;
608+
const firstAssistantEntry = keepFirstAssistantEntry
609+
? group.entries.find(
610+
(entry): entry is Extract<TimelineEntry, { kind: "message" }> => entry.kind === "message",
611+
)
612+
: undefined;
613+
603614
const hiddenEntryIds = new Set<string>();
604615
for (const entry of group.entries) {
605-
if (entry.id === firstAssistantEntry?.id || entry.id === group.terminalEntry?.id) {
616+
if (entry.id === group.terminalEntry?.id || entry.id === firstAssistantEntry?.id) {
606617
continue;
607618
}
608619
// Agent-spawn CTA rows never fold: workflows outlive their launching

0 commit comments

Comments
 (0)