| author | |
| committer | |
| log | 6c693baecf75454cac96d1ae3f78afc73f2cbe53 |
| tree | b10fca0e11b6383af1afac4191b63473ede666e6 |
| parent | 77c9d1eb5b6a48d38f6f0a2bb4a8cff8e4752ade |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 160 insertions(+), 23 deletions(-)
apps/mobile/src/lib/threadActivity.test.ts+65-6| ... | ... | @@ -371,7 +371,7 @@ describe("buildThreadFeed", () => { |
| 371 | 371 | expect(serializedToolOutputs).toBe(1); |
| 372 | 372 | }); |
| 373 | 373 | |
| 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 | 375 | const turnId = TurnId.make("turn-1"); |
| 376 | 376 | const thread = makeThread({ |
| 377 | 377 | id: ThreadId.make("thread-3"), |
| ... | ... | @@ -387,9 +387,9 @@ describe("buildThreadFeed", () => { |
| 387 | 387 | }, |
| 388 | 388 | messages: [ |
| 389 | 389 | { |
| 390 | id: MessageId.make("assistant-commentary"), | |
| 390 | id: MessageId.make("assistant-first"), | |
| 391 | 391 | role: "assistant", |
| 392 | text: "I am checking.", | |
| 392 | text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.", | |
| 393 | 393 | turnId, |
| 394 | 394 | streaming: false, |
| 395 | 395 | createdAt: "2026-04-01T00:00:02.000Z", |
| ... | ... | @@ -424,8 +424,12 @@ describe("buildThreadFeed", () => { |
| 424 | 424 | |
| 425 | 425 | const feed = buildThreadFeed(thread); |
| 426 | 426 | 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({ | |
| 429 | 433 | type: "turn-fold", |
| 430 | 434 | label: "Worked for 17s", |
| 431 | 435 | expanded: false, |
| ... | ... | @@ -433,13 +437,68 @@ describe("buildThreadFeed", () => { |
| 433 | 437 | |
| 434 | 438 | const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); |
| 435 | 439 | expect(expanded.map((entry) => entry.id)).toEqual([ |
| 440 | "assistant-first", | |
| 436 | 441 | "turn-fold:turn-1", |
| 437 | "assistant-commentary", | |
| 438 | 442 | "tool-completed", |
| 439 | 443 | "assistant-final", |
| 440 | 444 | ]); |
| 441 | 445 | }); |
| 442 | 446 | |
| 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 | 502 | it("measures a steer-superseded turn from its user boundary through trailing work", () => { |
| 444 | 503 | const firstTurnId = TurnId.make("turn-1"); |
| 445 | 504 | const secondTurnId = TurnId.make("turn-2"); |
apps/mobile/src/lib/threadActivity.ts+15-4| ... | ... | @@ -1144,9 +1144,13 @@ function deriveThreadFeedTurnFolds( |
| 1144 | 1144 | feed: ReadonlyArray<ThreadFeedEntry>, |
| 1145 | 1145 | latestTurn: ThreadFeedLatestTurn | null, |
| 1146 | 1146 | ): ReadonlyMap<string, ThreadFeedTurnFold> { |
| 1147 | const firstAssistantMessageIdByTurn = new Map<TurnId, string>(); | |
| 1147 | 1148 | const terminalAssistantMessageIdByTurn = new Map<TurnId, string>(); |
| 1148 | 1149 | for (const entry of feed) { |
| 1149 | 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 | 1154 | terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); |
| 1151 | 1155 | } |
| 1152 | 1156 | } |
| ... | ... | @@ -1194,17 +1198,24 @@ function deriveThreadFeedTurnFolds( |
| 1194 | 1198 | continue; |
| 1195 | 1199 | } |
| 1196 | 1200 | |
| 1201 | const firstAssistantMessageId = firstAssistantMessageIdByTurn.get(turnId); | |
| 1197 | 1202 | const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); |
| 1198 | 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 | 1211 | if (hiddenEntryIds.size === 0) { |
| 1202 | 1212 | continue; |
| 1203 | 1213 | } |
| 1204 | 1214 | |
| 1205 | 1215 | const firstEntry = entries[0]; |
| 1216 | const firstHiddenEntry = entries.find((entry) => hiddenEntryIds.has(entry.id)); | |
| 1206 | 1217 | const lastEntry = entries.at(-1); |
| 1207 | if (!firstEntry || !lastEntry) { | |
| 1218 | if (!firstEntry || !firstHiddenEntry || !lastEntry) { | |
| 1208 | 1219 | continue; |
| 1209 | 1220 | } |
| 1210 | 1221 | const terminalEntry = terminalAssistantMessageId |
| ... | ... | @@ -1233,9 +1244,9 @@ function deriveThreadFeedTurnFolds( |
| 1233 | 1244 | ? `Worked for ${duration}` |
| 1234 | 1245 | : "Worked"; |
| 1235 | 1246 | |
| 1236 | foldsByAnchorId.set(firstEntry.id, { | |
| 1247 | foldsByAnchorId.set(firstHiddenEntry.id, { | |
| 1237 | 1248 | turnId, |
| 1238 | createdAt: firstEntry.createdAt, | |
| 1249 | createdAt: firstHiddenEntry.createdAt, | |
| 1239 | 1250 | hiddenEntryIds, |
| 1240 | 1251 | label, |
| 1241 | 1252 | }); |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+67-5| ... | ... | @@ -452,7 +452,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 452 | 452 | expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary); |
| 453 | 453 | }); |
| 454 | 454 | |
| 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 | 456 | const timelineEntries = [ |
| 457 | 457 | { |
| 458 | 458 | id: "user-entry", |
| ... | ... | @@ -469,13 +469,13 @@ describe("deriveMessagesTimelineRows", () => { |
| 469 | 469 | }, |
| 470 | 470 | }, |
| 471 | 471 | { |
| 472 | id: "assistant-thought-entry", | |
| 472 | id: "assistant-first-entry", | |
| 473 | 473 | kind: "message" as const, |
| 474 | 474 | createdAt: "2026-01-01T00:00:05Z", |
| 475 | 475 | message: { |
| 476 | id: "assistant-thought" as never, | |
| 476 | id: "assistant-first" as never, | |
| 477 | 477 | role: "assistant" as const, |
| 478 | text: "Looking around first.", | |
| 478 | text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.", | |
| 479 | 479 | turnId: "turn-1" as never, |
| 480 | 480 | createdAt: "2026-01-01T00:00:05Z", |
| 481 | 481 | updatedAt: "2026-01-01T00:00:06Z", |
| ... | ... | @@ -528,6 +528,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 528 | 528 | expect(foldRow?.label).toBe("Worked for 22s"); |
| 529 | 529 | expect(collapsedRows.map((row) => row.id)).toEqual([ |
| 530 | 530 | "user-entry", |
| 531 | "assistant-first-entry", | |
| 531 | 532 | "turn-fold:turn-1", |
| 532 | 533 | "assistant-final-entry", |
| 533 | 534 | ]); |
| ... | ... | @@ -543,8 +544,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 543 | 544 | |
| 544 | 545 | expect(expandedRows.map((row) => row.id)).toEqual([ |
| 545 | 546 | "user-entry", |
| 547 | "assistant-first-entry", | |
| 546 | 548 | "turn-fold:turn-1", |
| 547 | "assistant-thought-entry", | |
| 548 | 549 | "work-toggle:work-entry-1", |
| 549 | 550 | "assistant-final-entry", |
| 550 | 551 | ]); |
| ... | ... | @@ -553,6 +554,67 @@ describe("deriveMessagesTimelineRows", () => { |
| 553 | 554 | ).toBeDefined(); |
| 554 | 555 | }); |
| 555 | 556 | |
| 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 | 618 | it("derives a sane duration for a steer-superseded turn with one instant commentary message", () => { |
| 557 | 619 | // A steer ends the previous turn early: its only message completes the |
| 558 | 620 | // 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 | 521 | } |
| 522 | 522 | |
| 523 | 523 | /** |
| 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. | |
| 527 | 528 | */ |
| 528 | 529 | function deriveTurnFolds(input: { |
| 529 | 530 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| ... | ... | @@ -593,9 +594,12 @@ function deriveTurnFolds(input: { |
| 593 | 594 | if (group.hasStreamingMessage) { |
| 594 | 595 | continue; |
| 595 | 596 | } |
| 597 | const firstAssistantEntry = group.entries.find( | |
| 598 | (entry): entry is Extract<TimelineEntry, { kind: "message" }> => entry.kind === "message", | |
| 599 | ); | |
| 596 | 600 | const hiddenEntryIds = new Set<string>(); |
| 597 | 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 | 603 | continue; |
| 600 | 604 | } |
| 601 | 605 | // Agent-spawn CTA rows never fold: workflows outlive their launching |
| ... | ... | @@ -611,8 +615,9 @@ function deriveTurnFolds(input: { |
| 611 | 615 | } |
| 612 | 616 | |
| 613 | 617 | const firstEntry = group.entries[0]; |
| 618 | const firstHiddenEntry = group.entries.find((entry) => hiddenEntryIds.has(entry.id)); | |
| 614 | 619 | const lastEntry = group.entries.at(-1); |
| 615 | if (!firstEntry || !lastEntry) { | |
| 620 | if (!firstEntry || !firstHiddenEntry || !lastEntry) { | |
| 616 | 621 | continue; |
| 617 | 622 | } |
| 618 | 623 | |
| ... | ... | @@ -641,10 +646,10 @@ function deriveTurnFolds(input: { |
| 641 | 646 | ? `Worked for ${duration}` |
| 642 | 647 | : "Worked"; |
| 643 | 648 | |
| 644 | foldsByAnchorEntryId.set(firstEntry.id, { | |
| 649 | foldsByAnchorEntryId.set(firstHiddenEntry.id, { | |
| 645 | 650 | turnId, |
| 646 | anchorEntryId: firstEntry.id, | |
| 647 | createdAt: firstEntry.createdAt, | |
| 651 | anchorEntryId: firstHiddenEntry.id, | |
| 652 | createdAt: firstHiddenEntry.createdAt, | |
| 648 | 653 | hiddenEntryIds, |
| 649 | 654 | label, |
| 650 | 655 | }); |