| 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,7 +371,7 @@ describe("buildThreadFeed", () => { |
| 371 | expect(serializedToolOutputs).toBe(1); | 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 | 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", () => { |
| 424 | 424 | ||
| 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", () => { |
| 433 | 437 | ||
| 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 | }); |
| 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 | 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 | } |
| 1196 | 1200 | ||
| 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 | } |
| 1204 | 1214 | ||
| 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 = terminalAssistantMessageId | 1221 | 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"; |
| 1235 | 1246 | ||
| 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 | }); |
| 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 | 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", () => { |
| 543 | 544 | ||
| 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 | }); |
| 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 | 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 the | 619 | // A steer ends the previous turn early: its only message completes the |
| 558 | // instant it is created, and trailing work entries land after it. The | 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,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 | 524 | * Settled turns keep their first and terminal assistant messages visible. |
| 525 | * "Worked for ..." row anchored at the turn's first foldable entry; the | 525 | * 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 | */ |
| 528 | function deriveTurnFolds(input: { | 529 | function 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 launching | 605 | // 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 | } |
| 612 | 616 | ||
| 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 | } |
| 618 | 623 | ||
| ... | @@ -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"; |
| 643 | 648 | ||
| 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 | }); |