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", () => {
371371 expect(serializedToolOutputs).toBe(1);
372372 });
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", () => {
375375 const turnId = TurnId.make("turn-1");
376376 const thread = makeThread({
377377 id: ThreadId.make("thread-3"),
......@@ -387,9 +387,9 @@ describe("buildThreadFeed", () => {
387387 },
388388 messages: [
389389 {
390 id: MessageId.make("assistant-commentary"),
390 id: MessageId.make("assistant-first"),
391391 role: "assistant",
392 text: "I am checking.",
392 text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.",
393393 turnId,
394394 streaming: false,
395395 createdAt: "2026-04-01T00:00:02.000Z",
......@@ -424,8 +424,12 @@ describe("buildThreadFeed", () => {
424424
425425 const feed = buildThreadFeed(thread);
426426 const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
427 expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]);
428 expect(collapsed[0]).toMatchObject({
427 expect(collapsed.map((entry) => entry.id)).toEqual([
428 "assistant-first",
429 "turn-fold:turn-1",
430 "assistant-final",
431 ]);
432 expect(collapsed[1]).toMatchObject({
429433 type: "turn-fold",
430434 label: "Worked for 17s",
431435 expanded: false,
......@@ -433,13 +437,68 @@ describe("buildThreadFeed", () => {
433437
434438 const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId]));
435439 expect(expanded.map((entry) => entry.id)).toEqual([
440 "assistant-first",
436441 "turn-fold:turn-1",
437 "assistant-commentary",
438442 "tool-completed",
439443 "assistant-final",
440444 ]);
441445 });
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
443502 it("measures a steer-superseded turn from its user boundary through trailing work", () => {
444503 const firstTurnId = TurnId.make("turn-1");
445504 const secondTurnId = TurnId.make("turn-2");
apps/mobile/src/lib/threadActivity.ts+15-4
......@@ -1144,9 +1144,13 @@ function deriveThreadFeedTurnFolds(
11441144 feed: ReadonlyArray<ThreadFeedEntry>,
11451145 latestTurn: ThreadFeedLatestTurn | null,
11461146): ReadonlyMap<string, ThreadFeedTurnFold> {
1147 const firstAssistantMessageIdByTurn = new Map<TurnId, string>();
11471148 const terminalAssistantMessageIdByTurn = new Map<TurnId, string>();
11481149 for (const entry of feed) {
11491150 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 }
11501154 terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id);
11511155 }
11521156 }
......@@ -1194,17 +1198,24 @@ function deriveThreadFeedTurnFolds(
11941198 continue;
11951199 }
11961200
1201 const firstAssistantMessageId = firstAssistantMessageIdByTurn.get(turnId);
11971202 const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId);
11981203 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),
12001210 );
12011211 if (hiddenEntryIds.size === 0) {
12021212 continue;
12031213 }
12041214
12051215 const firstEntry = entries[0];
1216 const firstHiddenEntry = entries.find((entry) => hiddenEntryIds.has(entry.id));
12061217 const lastEntry = entries.at(-1);
1207 if (!firstEntry || !lastEntry) {
1218 if (!firstEntry || !firstHiddenEntry || !lastEntry) {
12081219 continue;
12091220 }
12101221 const terminalEntry = terminalAssistantMessageId
......@@ -1233,9 +1244,9 @@ function deriveThreadFeedTurnFolds(
12331244 ? `Worked for ${duration}`
12341245 : "Worked";
12351246
1236 foldsByAnchorId.set(firstEntry.id, {
1247 foldsByAnchorId.set(firstHiddenEntry.id, {
12371248 turnId,
1238 createdAt: firstEntry.createdAt,
1249 createdAt: firstHiddenEntry.createdAt,
12391250 hiddenEntryIds,
12401251 label,
12411252 });
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+67-5
......@@ -452,7 +452,7 @@ describe("deriveMessagesTimelineRows", () => {
452452 expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary);
453453 });
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", () => {
456456 const timelineEntries = [
457457 {
458458 id: "user-entry",
......@@ -469,13 +469,13 @@ describe("deriveMessagesTimelineRows", () => {
469469 },
470470 },
471471 {
472 id: "assistant-thought-entry",
472 id: "assistant-first-entry",
473473 kind: "message" as const,
474474 createdAt: "2026-01-01T00:00:05Z",
475475 message: {
476 id: "assistant-thought" as never,
476 id: "assistant-first" as never,
477477 role: "assistant" as const,
478 text: "Looking around first.",
478 text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.",
479479 turnId: "turn-1" as never,
480480 createdAt: "2026-01-01T00:00:05Z",
481481 updatedAt: "2026-01-01T00:00:06Z",
......@@ -528,6 +528,7 @@ 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",
531532 "turn-fold:turn-1",
532533 "assistant-final-entry",
533534 ]);
......@@ -543,8 +544,8 @@ describe("deriveMessagesTimelineRows", () => {
543544
544545 expect(expandedRows.map((row) => row.id)).toEqual([
545546 "user-entry",
547 "assistant-first-entry",
546548 "turn-fold:turn-1",
547 "assistant-thought-entry",
548549 "work-toggle:work-entry-1",
549550 "assistant-final-entry",
550551 ]);
......@@ -553,6 +554,67 @@ describe("deriveMessagesTimelineRows", () => {
553554 ).toBeDefined();
554555 });
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
556618 it("derives a sane duration for a steer-superseded turn with one instant commentary message", () => {
557619 // A steer ends the previous turn early: its only message completes the
558620 // 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 {
521521}
522522
523523/**
524 * Settled turns fold their commentary and tool activity behind a
525 * "Worked for ..." row anchored at the turn's first foldable entry; the
526 * terminal assistant message stays visible below the fold.
524 * Settled turns keep their first and terminal assistant messages visible.
525 * Everything between them folds behind a "Worked for ..." row anchored at
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.
527528 */
528529function deriveTurnFolds(input: {
529530 timelineEntries: ReadonlyArray<TimelineEntry>;
......@@ -593,9 +594,12 @@ function deriveTurnFolds(input: {
593594 if (group.hasStreamingMessage) {
594595 continue;
595596 }
597 const firstAssistantEntry = group.entries.find(
598 (entry): entry is Extract<TimelineEntry, { kind: "message" }> => entry.kind === "message",
599 );
596600 const hiddenEntryIds = new Set<string>();
597601 for (const entry of group.entries) {
598 if (entry.id === group.terminalEntry?.id) {
602 if (entry.id === firstAssistantEntry?.id || entry.id === group.terminalEntry?.id) {
599603 continue;
600604 }
601605 // Agent-spawn CTA rows never fold: workflows outlive their launching
......@@ -611,8 +615,9 @@ function deriveTurnFolds(input: {
611615 }
612616
613617 const firstEntry = group.entries[0];
618 const firstHiddenEntry = group.entries.find((entry) => hiddenEntryIds.has(entry.id));
614619 const lastEntry = group.entries.at(-1);
615 if (!firstEntry || !lastEntry) {
620 if (!firstEntry || !firstHiddenEntry || !lastEntry) {
616621 continue;
617622 }
618623
......@@ -641,10 +646,10 @@ function deriveTurnFolds(input: {
641646 ? `Worked for ${duration}`
642647 : "Worked";
643648
644 foldsByAnchorEntryId.set(firstEntry.id, {
649 foldsByAnchorEntryId.set(firstHiddenEntry.id, {
645650 turnId,
646 anchorEntryId: firstEntry.id,
647 createdAt: firstEntry.createdAt,
651 anchorEntryId: firstHiddenEntry.id,
652 createdAt: firstHiddenEntry.createdAt,
648653 hiddenEntryIds,
649654 label,
650655 });