authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-22 14:59:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-22 14:59:39-07:00
log6c693baecf75454cac96d1ae3f78afc73f2cbe53
treeb10fca0e11b6383af1afac4191b63473ede666e6
parent77c9d1eb5b6a48d38f6f0a2bb4a8cff8e4752ade
signature Signed by PGP key B5690EEEBB952194

fix(clients): keep opening responses visible after turns settle (#7723)


4 files changed, 160 insertions(+), 23 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+65-6
...@@ -371,7 +371,7 @@ describe("buildThreadFeed", () => {...@@ -371,7 +371,7 @@ describe("buildThreadFeed", () => {
371 expect(serializedToolOutputs).toBe(1);371 expect(serializedToolOutputs).toBe(1);
372 });372 });
373373
374 it("folds settled turn work while leaving the terminal answer visible", () => {374 it("keeps the first and terminal assistant messages visible around settled work", () => {
375 const turnId = TurnId.make("turn-1");375 const turnId = TurnId.make("turn-1");
376 const thread = makeThread({376 const thread = makeThread({
377 id: ThreadId.make("thread-3"),377 id: ThreadId.make("thread-3"),
...@@ -387,9 +387,9 @@ describe("buildThreadFeed", () => {...@@ -387,9 +387,9 @@ describe("buildThreadFeed", () => {
387 },387 },
388 messages: [388 messages: [
389 {389 {
390 id: MessageId.make("assistant-commentary"),390 id: MessageId.make("assistant-first"),
391 role: "assistant",391 role: "assistant",
392 text: "I am checking.",392 text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.",
393 turnId,393 turnId,
394 streaming: false,394 streaming: false,
395 createdAt: "2026-04-01T00:00:02.000Z",395 createdAt: "2026-04-01T00:00:02.000Z",
...@@ -424,8 +424,12 @@ describe("buildThreadFeed", () => {...@@ -424,8 +424,12 @@ describe("buildThreadFeed", () => {
424424
425 const feed = buildThreadFeed(thread);425 const feed = buildThreadFeed(thread);
426 const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());426 const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
427 expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]);427 expect(collapsed.map((entry) => entry.id)).toEqual([
428 expect(collapsed[0]).toMatchObject({428 "assistant-first",
429 "turn-fold:turn-1",
430 "assistant-final",
431 ]);
432 expect(collapsed[1]).toMatchObject({
429 type: "turn-fold",433 type: "turn-fold",
430 label: "Worked for 17s",434 label: "Worked for 17s",
431 expanded: false,435 expanded: false,
...@@ -433,13 +437,68 @@ describe("buildThreadFeed", () => {...@@ -433,13 +437,68 @@ describe("buildThreadFeed", () => {
433437
434 const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId]));438 const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId]));
435 expect(expanded.map((entry) => entry.id)).toEqual([439 expect(expanded.map((entry) => entry.id)).toEqual([
440 "assistant-first",
436 "turn-fold:turn-1",441 "turn-fold:turn-1",
437 "assistant-commentary",
438 "tool-completed",442 "tool-completed",
439 "assistant-final",443 "assistant-final",
440 ]);444 ]);
441 });445 });
442446
447 it("folds assistant messages between the first and terminal messages", () => {
448 const turnId = TurnId.make("turn-1");
449 const thread = makeThread({
450 id: ThreadId.make("thread-middle-message"),
451 projectId: ProjectId.make("project-1"),
452 title: "Bounded narration",
453 latestTurn: {
454 turnId,
455 state: "completed",
456 requestedAt: "2026-04-01T00:00:00.000Z",
457 startedAt: "2026-04-01T00:00:01.000Z",
458 completedAt: "2026-04-01T00:00:06.000Z",
459 assistantMessageId: MessageId.make("assistant-final"),
460 },
461 messages: [
462 {
463 id: MessageId.make("assistant-first"),
464 role: "assistant",
465 text: "The main result is ready.",
466 turnId,
467 streaming: false,
468 createdAt: "2026-04-01T00:00:01.000Z",
469 updatedAt: "2026-04-01T00:00:02.000Z",
470 },
471 {
472 id: MessageId.make("assistant-middle"),
473 role: "assistant",
474 text: "I am checking one more detail.",
475 turnId,
476 streaming: false,
477 createdAt: "2026-04-01T00:00:03.000Z",
478 updatedAt: "2026-04-01T00:00:04.000Z",
479 },
480 {
481 id: MessageId.make("assistant-final"),
482 role: "assistant",
483 text: "Verification finished.",
484 turnId,
485 streaming: false,
486 createdAt: "2026-04-01T00:00:05.000Z",
487 updatedAt: "2026-04-01T00:00:06.000Z",
488 },
489 ],
490 });
491
492 const feed = buildThreadFeed(thread);
493 const rows = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
494
495 expect(rows.map((entry) => entry.id)).toEqual([
496 "assistant-first",
497 "turn-fold:turn-1",
498 "assistant-final",
499 ]);
500 });
501
443 it("measures a steer-superseded turn from its user boundary through trailing work", () => {502 it("measures a steer-superseded turn from its user boundary through trailing work", () => {
444 const firstTurnId = TurnId.make("turn-1");503 const firstTurnId = TurnId.make("turn-1");
445 const secondTurnId = TurnId.make("turn-2");504 const secondTurnId = TurnId.make("turn-2");
apps/mobile/src/lib/threadActivity.ts+15-4
...@@ -1144,9 +1144,13 @@ function deriveThreadFeedTurnFolds(...@@ -1144,9 +1144,13 @@ function deriveThreadFeedTurnFolds(
1144 feed: ReadonlyArray<ThreadFeedEntry>,1144 feed: ReadonlyArray<ThreadFeedEntry>,
1145 latestTurn: ThreadFeedLatestTurn | null,1145 latestTurn: ThreadFeedLatestTurn | null,
1146): ReadonlyMap<string, ThreadFeedTurnFold> {1146): ReadonlyMap<string, ThreadFeedTurnFold> {
1147 const firstAssistantMessageIdByTurn = new Map<TurnId, string>();
1147 const terminalAssistantMessageIdByTurn = new Map<TurnId, string>();1148 const terminalAssistantMessageIdByTurn = new Map<TurnId, string>();
1148 for (const entry of feed) {1149 for (const entry of feed) {
1149 if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) {1150 if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) {
1151 if (!firstAssistantMessageIdByTurn.has(entry.message.turnId)) {
1152 firstAssistantMessageIdByTurn.set(entry.message.turnId, entry.id);
1153 }
1150 terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id);1154 terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id);
1151 }1155 }
1152 }1156 }
...@@ -1194,17 +1198,24 @@ function deriveThreadFeedTurnFolds(...@@ -1194,17 +1198,24 @@ function deriveThreadFeedTurnFolds(
1194 continue;1198 continue;
1195 }1199 }
11961200
1201 const firstAssistantMessageId = firstAssistantMessageIdByTurn.get(turnId);
1197 const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId);1202 const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId);
1198 const hiddenEntryIds = new Set(1203 const hiddenEntryIds = new Set(
1199 entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id),1204 entries
1205 .filter(
1206 (entry) =>
1207 entry.id !== firstAssistantMessageId && entry.id !== terminalAssistantMessageId,
1208 )
1209 .map((entry) => entry.id),
1200 );1210 );
1201 if (hiddenEntryIds.size === 0) {1211 if (hiddenEntryIds.size === 0) {
1202 continue;1212 continue;
1203 }1213 }
12041214
1205 const firstEntry = entries[0];1215 const firstEntry = entries[0];
1216 const firstHiddenEntry = entries.find((entry) => hiddenEntryIds.has(entry.id));
1206 const lastEntry = entries.at(-1);1217 const lastEntry = entries.at(-1);
1207 if (!firstEntry || !lastEntry) {1218 if (!firstEntry || !firstHiddenEntry || !lastEntry) {
1208 continue;1219 continue;
1209 }1220 }
1210 const terminalEntry = terminalAssistantMessageId1221 const terminalEntry = terminalAssistantMessageId
...@@ -1233,9 +1244,9 @@ function deriveThreadFeedTurnFolds(...@@ -1233,9 +1244,9 @@ function deriveThreadFeedTurnFolds(
1233 ? `Worked for ${duration}`1244 ? `Worked for ${duration}`
1234 : "Worked";1245 : "Worked";
12351246
1236 foldsByAnchorId.set(firstEntry.id, {1247 foldsByAnchorId.set(firstHiddenEntry.id, {
1237 turnId,1248 turnId,
1238 createdAt: firstEntry.createdAt,1249 createdAt: firstHiddenEntry.createdAt,
1239 hiddenEntryIds,1250 hiddenEntryIds,
1240 label,1251 label,
1241 });1252 });
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+67-5
...@@ -452,7 +452,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -452,7 +452,7 @@ describe("deriveMessagesTimelineRows", () => {
452 expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary);452 expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary);
453 });453 });
454454
455 it("folds settled-turn commentary and work behind a Worked-for row", () => {455 it("keeps the first and terminal assistant messages visible around settled work", () => {
456 const timelineEntries = [456 const timelineEntries = [
457 {457 {
458 id: "user-entry",458 id: "user-entry",
...@@ -469,13 +469,13 @@ describe("deriveMessagesTimelineRows", () => {...@@ -469,13 +469,13 @@ describe("deriveMessagesTimelineRows", () => {
469 },469 },
470 },470 },
471 {471 {
472 id: "assistant-thought-entry",472 id: "assistant-first-entry",
473 kind: "message" as const,473 kind: "message" as const,
474 createdAt: "2026-01-01T00:00:05Z",474 createdAt: "2026-01-01T00:00:05Z",
475 message: {475 message: {
476 id: "assistant-thought" as never,476 id: "assistant-first" as never,
477 role: "assistant" as const,477 role: "assistant" as const,
478 text: "Looking around first.",478 text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.",
479 turnId: "turn-1" as never,479 turnId: "turn-1" as never,
480 createdAt: "2026-01-01T00:00:05Z",480 createdAt: "2026-01-01T00:00:05Z",
481 updatedAt: "2026-01-01T00:00:06Z",481 updatedAt: "2026-01-01T00:00:06Z",
...@@ -528,6 +528,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -528,6 +528,7 @@ describe("deriveMessagesTimelineRows", () => {
528 expect(foldRow?.label).toBe("Worked for 22s");528 expect(foldRow?.label).toBe("Worked for 22s");
529 expect(collapsedRows.map((row) => row.id)).toEqual([529 expect(collapsedRows.map((row) => row.id)).toEqual([
530 "user-entry",530 "user-entry",
531 "assistant-first-entry",
531 "turn-fold:turn-1",532 "turn-fold:turn-1",
532 "assistant-final-entry",533 "assistant-final-entry",
533 ]);534 ]);
...@@ -543,8 +544,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -543,8 +544,8 @@ describe("deriveMessagesTimelineRows", () => {
543544
544 expect(expandedRows.map((row) => row.id)).toEqual([545 expect(expandedRows.map((row) => row.id)).toEqual([
545 "user-entry",546 "user-entry",
547 "assistant-first-entry",
546 "turn-fold:turn-1",548 "turn-fold:turn-1",
547 "assistant-thought-entry",
548 "work-toggle:work-entry-1",549 "work-toggle:work-entry-1",
549 "assistant-final-entry",550 "assistant-final-entry",
550 ]);551 ]);
...@@ -553,6 +554,67 @@ describe("deriveMessagesTimelineRows", () => {...@@ -553,6 +554,67 @@ describe("deriveMessagesTimelineRows", () => {
553 ).toBeDefined();554 ).toBeDefined();
554 });555 });
555556
557 it("folds assistant messages between the first and terminal messages", () => {
558 const 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-middle-entry",
575 kind: "message" as const,
576 createdAt: "2026-01-01T00:00:03Z",
577 message: {
578 id: "assistant-middle" as never,
579 role: "assistant" as const,
580 text: "I am checking one more detail.",
581 turnId: "turn-1" as never,
582 createdAt: "2026-01-01T00:00:03Z",
583 updatedAt: "2026-01-01T00:00:04Z",
584 streaming: false,
585 },
586 },
587 {
588 id: "assistant-final-entry",
589 kind: "message" as const,
590 createdAt: "2026-01-01T00:00:05Z",
591 message: {
592 id: "assistant-final" as never,
593 role: "assistant" as const,
594 text: "Verification finished.",
595 turnId: "turn-1" as never,
596 createdAt: "2026-01-01T00:00:05Z",
597 updatedAt: "2026-01-01T00:00:06Z",
598 streaming: false,
599 },
600 },
601 ];
602
603 const rows = deriveMessagesTimelineRows({
604 timelineEntries,
605 isWorking: false,
606 activeTurnStartedAt: null,
607 turnDiffSummaryByAssistantMessageId: new Map(),
608 revertTurnCountByUserMessageId: new Map(),
609 });
610
611 expect(rows.map((row) => row.id)).toEqual([
612 "assistant-first-entry",
613 "turn-fold:turn-1",
614 "assistant-final-entry",
615 ]);
616 });
617
556 it("derives a sane duration for a steer-superseded turn with one instant commentary message", () => {618 it("derives a sane duration for a steer-superseded turn with one instant commentary message", () => {
557 // A steer ends the previous turn early: its only message completes the619 // A steer ends the previous turn early: its only message completes the
558 // instant it is created, and trailing work entries land after it. The620 // instant it is created, and trailing work entries land after it. The
apps/web/src/components/chat/MessagesTimeline.logic.ts+13-8
...@@ -521,9 +521,10 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {...@@ -521,9 +521,10 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
521}521}
522522
523/**523/**
524 * Settled turns fold their commentary and tool activity behind a524 * Settled turns keep their first and terminal assistant messages visible.
525 * "Worked for ..." row anchored at the turn's first foldable entry; the525 * Everything between them folds behind a "Worked for ..." row anchored at
526 * terminal assistant message stays visible below the fold.526 * the first hidden entry. Keeping both ends prevents a short follow-up from
527 * hiding a substantive opening response while still bounding noisy turns.
527 */528 */
528function deriveTurnFolds(input: {529function deriveTurnFolds(input: {
529 timelineEntries: ReadonlyArray<TimelineEntry>;530 timelineEntries: ReadonlyArray<TimelineEntry>;
...@@ -593,9 +594,12 @@ function deriveTurnFolds(input: {...@@ -593,9 +594,12 @@ function deriveTurnFolds(input: {
593 if (group.hasStreamingMessage) {594 if (group.hasStreamingMessage) {
594 continue;595 continue;
595 }596 }
597 const firstAssistantEntry = group.entries.find(
598 (entry): entry is Extract<TimelineEntry, { kind: "message" }> => entry.kind === "message",
599 );
596 const hiddenEntryIds = new Set<string>();600 const hiddenEntryIds = new Set<string>();
597 for (const entry of group.entries) {601 for (const entry of group.entries) {
598 if (entry.id === group.terminalEntry?.id) {602 if (entry.id === firstAssistantEntry?.id || entry.id === group.terminalEntry?.id) {
599 continue;603 continue;
600 }604 }
601 // Agent-spawn CTA rows never fold: workflows outlive their launching605 // Agent-spawn CTA rows never fold: workflows outlive their launching
...@@ -611,8 +615,9 @@ function deriveTurnFolds(input: {...@@ -611,8 +615,9 @@ function deriveTurnFolds(input: {
611 }615 }
612616
613 const firstEntry = group.entries[0];617 const firstEntry = group.entries[0];
618 const firstHiddenEntry = group.entries.find((entry) => hiddenEntryIds.has(entry.id));
614 const lastEntry = group.entries.at(-1);619 const lastEntry = group.entries.at(-1);
615 if (!firstEntry || !lastEntry) {620 if (!firstEntry || !firstHiddenEntry || !lastEntry) {
616 continue;621 continue;
617 }622 }
618623
...@@ -641,10 +646,10 @@ function deriveTurnFolds(input: {...@@ -641,10 +646,10 @@ function deriveTurnFolds(input: {
641 ? `Worked for ${duration}`646 ? `Worked for ${duration}`
642 : "Worked";647 : "Worked";
643648
644 foldsByAnchorEntryId.set(firstEntry.id, {649 foldsByAnchorEntryId.set(firstHiddenEntry.id, {
645 turnId,650 turnId,
646 anchorEntryId: firstEntry.id,651 anchorEntryId: firstHiddenEntry.id,
647 createdAt: firstEntry.createdAt,652 createdAt: firstHiddenEntry.createdAt,
648 hiddenEntryIds,653 hiddenEntryIds,
649 label,654 label,
650 });655 });