| author | |
| committer | |
| log | 44dc8ae259f5c3349f7ab8045e39bd62408b52a9 |
| tree | da462111cce802530325ceed96b66662765d56a3 |
| parent | f2e3764c257a7e27c8171d7dd1e38d4383074206 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 440 insertions(+), 115 deletions(-)
apps/mobile/src/lib/threadActivity.test.ts+239-1| ... | @@ -415,6 +415,173 @@ function makeThread( | ... | @@ -415,6 +415,173 @@ function makeThread( |
| 415 | } | 415 | } |
| 416 | 416 | ||
| 417 | describe("buildThreadFeed", () => { | 417 | describe("buildThreadFeed", () => { |
| 418 | it("reuses unchanged feed and presentation rows during an assistant text update", () => { | ||
| 419 | const completedTurnId = TurnId.make("completed-turn"); | ||
| 420 | const activeTurnId = TurnId.make("active-turn"); | ||
| 421 | const thread = makeThread({ | ||
| 422 | id: ThreadId.make("feed-reuse"), | ||
| 423 | projectId: ProjectId.make("project-1"), | ||
| 424 | title: "Feed reuse", | ||
| 425 | messages: [ | ||
| 426 | { | ||
| 427 | id: MessageId.make("completed-message"), | ||
| 428 | role: "assistant", | ||
| 429 | text: "Completed response", | ||
| 430 | turnId: completedTurnId, | ||
| 431 | streaming: false, | ||
| 432 | createdAt: "2026-04-01T00:00:01.000Z", | ||
| 433 | updatedAt: "2026-04-01T00:00:01.000Z", | ||
| 434 | }, | ||
| 435 | { | ||
| 436 | id: MessageId.make("streaming-message"), | ||
| 437 | role: "assistant", | ||
| 438 | text: "Current response", | ||
| 439 | turnId: activeTurnId, | ||
| 440 | streaming: true, | ||
| 441 | createdAt: "2026-04-01T00:00:04.000Z", | ||
| 442 | updatedAt: "2026-04-01T00:00:04.000Z", | ||
| 443 | }, | ||
| 444 | ], | ||
| 445 | activities: [ | ||
| 446 | makeActivity({ | ||
| 447 | id: EventId.make("completed-tool"), | ||
| 448 | kind: "tool.completed", | ||
| 449 | summary: "Read files", | ||
| 450 | createdAt: "2026-04-01T00:00:02.000Z", | ||
| 451 | turnId: completedTurnId, | ||
| 452 | payload: { itemType: "file_read", status: "completed" }, | ||
| 453 | }), | ||
| 454 | makeActivity({ | ||
| 455 | id: EventId.make("active-tool"), | ||
| 456 | kind: "tool.updated", | ||
| 457 | summary: "Run checks", | ||
| 458 | createdAt: "2026-04-01T00:00:03.000Z", | ||
| 459 | turnId: activeTurnId, | ||
| 460 | payload: { itemType: "command_execution", command: "vp test", status: "inProgress" }, | ||
| 461 | }), | ||
| 462 | ], | ||
| 463 | }); | ||
| 464 | const latestTurn = { | ||
| 465 | turnId: activeTurnId, | ||
| 466 | state: "running" as const, | ||
| 467 | startedAt: "2026-04-01T00:00:03.000Z", | ||
| 468 | completedAt: null, | ||
| 469 | }; | ||
| 470 | const expandedTurns = new Set([completedTurnId]); | ||
| 471 | const expandedGroups = new Set(["work-group:completed-tool", "work-group:active-tool"]); | ||
| 472 | const previousFeed = buildThreadFeed(thread); | ||
| 473 | const previousRows = deriveThreadFeedPresentation( | ||
| 474 | previousFeed, | ||
| 475 | latestTurn, | ||
| 476 | expandedTurns, | ||
| 477 | expandedGroups, | ||
| 478 | latestTurn.startedAt, | ||
| 479 | ); | ||
| 480 | const updatedMessage = { | ||
| 481 | ...thread.messages[1]!, | ||
| 482 | text: "Current response with more text", | ||
| 483 | updatedAt: "2026-04-01T00:00:05.000Z", | ||
| 484 | }; | ||
| 485 | const nextFeed = buildThreadFeed({ | ||
| 486 | ...thread, | ||
| 487 | messages: [thread.messages[0]!, updatedMessage], | ||
| 488 | }); | ||
| 489 | const nextRows = deriveThreadFeedPresentation( | ||
| 490 | nextFeed, | ||
| 491 | latestTurn, | ||
| 492 | expandedTurns, | ||
| 493 | expandedGroups, | ||
| 494 | latestTurn.startedAt, | ||
| 495 | ); | ||
| 496 | |||
| 497 | expect(nextFeed).toHaveLength(previousFeed.length); | ||
| 498 | expect(nextRows).toHaveLength(previousRows.length); | ||
| 499 | for (const [before, after] of [ | ||
| 500 | [previousFeed, nextFeed], | ||
| 501 | [previousRows, nextRows], | ||
| 502 | ] as const) { | ||
| 503 | for (const [index, row] of after.entries()) { | ||
| 504 | if (row.id === updatedMessage.id) { | ||
| 505 | expect(row).not.toBe(before[index]); | ||
| 506 | expect(row).toMatchObject({ message: updatedMessage }); | ||
| 507 | } else { | ||
| 508 | expect(row).toBe(before[index]); | ||
| 509 | } | ||
| 510 | } | ||
| 511 | } | ||
| 512 | expect(nextRows.some((row) => row.type === "turn-fold")).toBe(true); | ||
| 513 | expect(nextRows.some((row) => row.type === "activity-group")).toBe(true); | ||
| 514 | }); | ||
| 515 | |||
| 516 | it("regroups cached activities for message changes and pagination", () => { | ||
| 517 | const messages = [2, 4].map((second) => ({ | ||
| 518 | id: MessageId.make(`message-${second}`), | ||
| 519 | role: "assistant" as const, | ||
| 520 | text: second === 2 ? "" : "Response", | ||
| 521 | streaming: false, | ||
| 522 | turnId: null, | ||
| 523 | createdAt: `2026-04-01T00:00:0${second}.000Z`, | ||
| 524 | updatedAt: `2026-04-01T00:00:0${second}.000Z`, | ||
| 525 | })); | ||
| 526 | const thread = makeThread({ | ||
| 527 | id: ThreadId.make("feed-regroup"), | ||
| 528 | projectId: ProjectId.make("project-1"), | ||
| 529 | title: "Feed grouping", | ||
| 530 | messages, | ||
| 531 | activities: [1, 3, 5].map((second) => | ||
| 532 | makeActivity({ | ||
| 533 | id: EventId.make(`work-${second}`), | ||
| 534 | kind: "runtime.warning", | ||
| 535 | summary: `Notice ${second}`, | ||
| 536 | createdAt: `2026-04-01T00:00:0${second}.000Z`, | ||
| 537 | }), | ||
| 538 | ), | ||
| 539 | }); | ||
| 540 | const initial = buildThreadFeed(thread); | ||
| 541 | expect(initial.map((row) => row.id)).toEqual(["work-1", "message-4", "work-5"]); | ||
| 542 | const split = buildThreadFeed({ | ||
| 543 | ...thread, | ||
| 544 | messages: [{ ...messages[0]!, text: "Now visible" }, messages[1]!], | ||
| 545 | }); | ||
| 546 | expect(split.map((row) => row.id)).toEqual([ | ||
| 547 | "work-1", | ||
| 548 | "message-2", | ||
| 549 | "work-3", | ||
| 550 | "message-4", | ||
| 551 | "work-5", | ||
| 552 | ]); | ||
| 553 | expect(split[0]).not.toBe(initial[0]); | ||
| 554 | expect(split.at(-1)).toBe(initial.at(-1)); | ||
| 555 | expect(initial[0]).toMatchObject({ activities: [{ id: "work-1" }, { id: "work-3" }] }); | ||
| 556 | |||
| 557 | const reordered = buildThreadFeed({ | ||
| 558 | ...thread, | ||
| 559 | messages: [messages[0]!, { ...messages[1]!, createdAt: "2026-04-01T00:00:06.000Z" }], | ||
| 560 | }); | ||
| 561 | expect(reordered.map((row) => row.id)).toEqual(["work-1", "message-4"]); | ||
| 562 | expect(reordered[0]).toMatchObject({ | ||
| 563 | activities: [{ id: "work-1" }, { id: "work-3" }, { id: "work-5" }], | ||
| 564 | }); | ||
| 565 | const olderMessage = { | ||
| 566 | ...messages[1]!, | ||
| 567 | id: MessageId.make("older-message"), | ||
| 568 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 569 | }; | ||
| 570 | const page = buildThreadFeed(thread, { | ||
| 571 | loadedMessages: [messages[1]!], | ||
| 572 | localMessages: [olderMessage], | ||
| 573 | }); | ||
| 574 | expect(page.map((row) => row.id)).toEqual(["older-message", "message-4", "work-5"]); | ||
| 575 | const prepended = buildThreadFeed(thread, { loadedMessages: [olderMessage, ...messages] }); | ||
| 576 | expect(prepended.map((row) => row.id)).toEqual([ | ||
| 577 | "older-message", | ||
| 578 | "work-1", | ||
| 579 | "message-4", | ||
| 580 | "work-5", | ||
| 581 | ]); | ||
| 582 | expect(prepended.at(-1)).toBe(page.at(-1)); | ||
| 583 | }); | ||
| 584 | |||
| 418 | it("keeps context compaction as a standalone timeline row", () => { | 585 | it("keeps context compaction as a standalone timeline row", () => { |
| 419 | const thread = makeThread({ | 586 | const thread = makeThread({ |
| 420 | id: ThreadId.make("thread-context-compaction"), | 587 | id: ThreadId.make("thread-context-compaction"), |
| ... | @@ -696,7 +863,8 @@ describe("buildThreadFeed", () => { | ... | @@ -696,7 +863,8 @@ describe("buildThreadFeed", () => { |
| 696 | }; | 863 | }; |
| 697 | 864 | ||
| 698 | for (const currentTurn of [null, latestTurn]) { | 865 | for (const currentTurn of [null, latestTurn]) { |
| 699 | const feed = buildThreadFeed({ ...thread, latestTurn: currentTurn }); | 866 | const currentThread = { ...thread, latestTurn: currentTurn }; |
| 867 | const feed = buildThreadFeed(currentThread); | ||
| 700 | expect(feed).toMatchObject([ | 868 | expect(feed).toMatchObject([ |
| 701 | { | 869 | { |
| 702 | type: "activity-group", | 870 | type: "activity-group", |
| ... | @@ -1618,6 +1786,30 @@ describe("buildThreadFeed", () => { | ... | @@ -1618,6 +1786,30 @@ describe("buildThreadFeed", () => { |
| 1618 | "work-toggle:work-group:tool-completed", | 1786 | "work-toggle:work-group:tool-completed", |
| 1619 | "assistant-final", | 1787 | "assistant-final", |
| 1620 | ]); | 1788 | ]); |
| 1789 | |||
| 1790 | const interrupted = deriveThreadFeedPresentation( | ||
| 1791 | feed, | ||
| 1792 | { ...thread.latestTurn!, state: "interrupted", completedAt: "2026-04-01T00:00:20.000Z" }, | ||
| 1793 | new Set(), | ||
| 1794 | ); | ||
| 1795 | expect(interrupted[1]).toMatchObject({ | ||
| 1796 | type: "turn-fold", | ||
| 1797 | label: "You stopped after 19s", | ||
| 1798 | expanded: false, | ||
| 1799 | }); | ||
| 1800 | const retimed = deriveThreadFeedPresentation( | ||
| 1801 | buildThreadFeed({ | ||
| 1802 | ...thread, | ||
| 1803 | messages: [ | ||
| 1804 | thread.messages[0]!, | ||
| 1805 | { ...thread.messages[1]!, updatedAt: "2026-04-01T00:00:25.000Z" }, | ||
| 1806 | ], | ||
| 1807 | }), | ||
| 1808 | null, | ||
| 1809 | new Set(), | ||
| 1810 | ); | ||
| 1811 | expect(retimed[1]).toMatchObject({ type: "turn-fold", label: "Worked for 23s" }); | ||
| 1812 | expect(collapsed[1]).toMatchObject({ type: "turn-fold", label: "Worked for 17s" }); | ||
| 1621 | }); | 1813 | }); |
| 1622 | 1814 | ||
| 1623 | it("folds assistant messages between the first and terminal messages", () => { | 1815 | it("folds assistant messages between the first and terminal messages", () => { |
| ... | @@ -1916,6 +2108,15 @@ describe("buildThreadFeed", () => { | ... | @@ -1916,6 +2108,15 @@ describe("buildThreadFeed", () => { |
| 1916 | { id: "activity-3", groupedToolDetail: true, live: false }, | 2108 | { id: "activity-3", groupedToolDetail: true, live: false }, |
| 1917 | ], | 2109 | ], |
| 1918 | }); | 2110 | }); |
| 2111 | const unchanged = deriveThreadFeedPresentation( | ||
| 2112 | feed, | ||
| 2113 | null, | ||
| 2114 | new Set(), | ||
| 2115 | new Set(["work-group:activity-1", "unrelated-group"]), | ||
| 2116 | ); | ||
| 2117 | expect(unchanged[0]).toBe(expanded[0]); | ||
| 2118 | expect(unchanged[1]).toBe(expanded[1]); | ||
| 2119 | expect(deriveThreadFeedPresentation(feed, null, new Set())).toEqual(collapsed); | ||
| 1919 | }); | 2120 | }); |
| 1920 | 2121 | ||
| 1921 | it.each( | 2122 | it.each( |
| ... | @@ -2272,6 +2473,43 @@ describe("buildThreadFeed", () => { | ... | @@ -2272,6 +2473,43 @@ describe("buildThreadFeed", () => { |
| 2272 | { id: "call-b-2", lifecycleStatus: "completed", groupedToolDetail: true, live: false }, | 2473 | { id: "call-b-2", lifecycleStatus: "completed", groupedToolDetail: true, live: false }, |
| 2273 | ], | 2474 | ], |
| 2274 | }); | 2475 | }); |
| 2476 | |||
| 2477 | const correctedFeed = buildThreadFeed({ | ||
| 2478 | ...thread, | ||
| 2479 | activities: thread.activities.map((activity) => | ||
| 2480 | activity.id === "call-a-3" | ||
| 2481 | ? { | ||
| 2482 | ...activity, | ||
| 2483 | tone: "error", | ||
| 2484 | payload: { | ||
| 2485 | toolCallId: "call-a", | ||
| 2486 | itemType: "command_execution", | ||
| 2487 | status: "failed", | ||
| 2488 | detail: "Corrected failure output", | ||
| 2489 | }, | ||
| 2490 | } | ||
| 2491 | : activity, | ||
| 2492 | ), | ||
| 2493 | }); | ||
| 2494 | const correctedGroup = correctedFeed.find((entry) => entry.type === "activity-group"); | ||
| 2495 | expect(correctedGroup).toMatchObject({ | ||
| 2496 | activities: [ | ||
| 2497 | { id: "call-a-1", lifecycleStatus: "failed", detail: "Corrected failure output" }, | ||
| 2498 | { id: "call-b-2", lifecycleStatus: "completed", detail: "second output" }, | ||
| 2499 | ], | ||
| 2500 | }); | ||
| 2501 | expect(correctedGroup?.activities[0]?.getCopyText()).toContain("Corrected failure output"); | ||
| 2502 | expect(activityGroup?.activities[0]?.getCopyText()).toContain("first output"); | ||
| 2503 | const correctedRows = deriveThreadFeedPresentation( | ||
| 2504 | correctedFeed, | ||
| 2505 | null, | ||
| 2506 | new Set([turnId]), | ||
| 2507 | new Set([groupId]), | ||
| 2508 | ); | ||
| 2509 | expect(correctedRows.find((entry) => entry.type === "activity-group")).toMatchObject({ | ||
| 2510 | id: "call-a-1", | ||
| 2511 | activities: [{ status: "failure", workEntry: { tone: "error" } }], | ||
| 2512 | }); | ||
| 2275 | }); | 2513 | }); |
| 2276 | }); | 2514 | }); |
| 2277 | 2515 |
apps/mobile/src/lib/threadActivity.ts+182-102| ... | @@ -176,6 +176,32 @@ export type ThreadFeedLatestTurn = Pick< | ... | @@ -176,6 +176,32 @@ export type ThreadFeedLatestTurn = Pick< |
| 176 | "turnId" | "state" | "startedAt" | "completedAt" | 176 | "turnId" | "state" | "startedAt" | "completedAt" |
| 177 | >; | 177 | >; |
| 178 | 178 | ||
| 179 | type ThreadFeedActivityGroup = Extract<ThreadFeedEntry, { readonly type: "activity-group" }>; | ||
| 180 | |||
| 181 | // These keys are immutable inputs. Weak caches release old histories with their source data. | ||
| 182 | const activityEntriesCache = new WeakMap< | ||
| 183 | ReadonlyArray<OrchestrationThreadActivity>, | ||
| 184 | ReadonlyArray<Extract<RawThreadFeedEntry, { readonly type: "activity" }>> | ||
| 185 | >(); | ||
| 186 | const messageEntriesCache = new WeakMap< | ||
| 187 | OrchestrationThread["messages"][number], | ||
| 188 | Extract<RawThreadFeedEntry, { readonly type: "message" }> | ||
| 189 | >(); | ||
| 190 | const activityGroupsCache = new WeakMap<ThreadFeedActivity, ThreadFeedActivityGroup>(); | ||
| 191 | const presentedActivityGroupsCache = new WeakMap< | ||
| 192 | ThreadFeedActivityGroup, | ||
| 193 | { | ||
| 194 | readonly unsettledTurnId: TurnId | null; | ||
| 195 | readonly isWorking: boolean; | ||
| 196 | readonly activeTail: boolean; | ||
| 197 | readonly rows: ReadonlyArray<ThreadFeedEntry>; | ||
| 198 | } | ||
| 199 | >(); | ||
| 200 | const turnFoldRowsCache = new WeakMap< | ||
| 201 | ThreadFeedEntry, | ||
| 202 | Extract<ThreadFeedEntry, { readonly type: "turn-fold" }> | ||
| 203 | >(); | ||
| 204 | |||
| 179 | export function isContextCompactionActivityGroup( | 205 | export function isContextCompactionActivityGroup( |
| 180 | entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>, | 206 | entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>, |
| 181 | ): boolean { | 207 | ): boolean { |
| ... | @@ -1266,11 +1292,31 @@ function isEmptyMessage(entry: RawThreadFeedEntry): boolean { | ... | @@ -1266,11 +1292,31 @@ function isEmptyMessage(entry: RawThreadFeedEntry): boolean { |
| 1266 | 1292 | ||
| 1267 | function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] { | 1293 | function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] { |
| 1268 | const grouped: ThreadFeedEntry[] = []; | 1294 | const grouped: ThreadFeedEntry[] = []; |
| 1269 | // Mutable backing array for the trailing group so appending an activity is | 1295 | let firstActivityEntry: Extract<RawThreadFeedEntry, { readonly type: "activity" }> | null = null; |
| 1270 | // O(1) instead of re-copying the group (which made this loop quadratic on | 1296 | let openGroupActivities: ThreadFeedActivity[] = []; |
| 1271 | // long tool runs). The array is only mutated while it is the trailing group. | 1297 | const flushGroup = () => { |
| 1272 | let openGroupActivities: ThreadFeedActivity[] | null = null; | 1298 | if (firstActivityEntry === null) return; |
| 1273 | let openGroupTurnId: TurnId | null = null; | 1299 | const cached = activityGroupsCache.get(firstActivityEntry.activity); |
| 1300 | if ( | ||
| 1301 | cached && | ||
| 1302 | cached.activities.length === openGroupActivities.length && | ||
| 1303 | cached.activities.every((activity, index) => activity === openGroupActivities[index]) | ||
| 1304 | ) { | ||
| 1305 | grouped.push(cached); | ||
| 1306 | } else { | ||
| 1307 | const group: ThreadFeedActivityGroup = { | ||
| 1308 | type: "activity-group", | ||
| 1309 | id: firstActivityEntry.id, | ||
| 1310 | createdAt: firstActivityEntry.createdAt, | ||
| 1311 | turnId: firstActivityEntry.turnId, | ||
| 1312 | activities: openGroupActivities, | ||
| 1313 | }; | ||
| 1314 | activityGroupsCache.set(firstActivityEntry.activity, group); | ||
| 1315 | grouped.push(group); | ||
| 1316 | } | ||
| 1317 | firstActivityEntry = null; | ||
| 1318 | openGroupActivities = []; | ||
| 1319 | }; | ||
| 1274 | 1320 | ||
| 1275 | for (const entry of entries) { | 1321 | for (const entry of entries) { |
| 1276 | // Skip empty messages so they don't break activity grouping. | 1322 | // Skip empty messages so they don't break activity grouping. |
| ... | @@ -1279,39 +1325,22 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th | ... | @@ -1279,39 +1325,22 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th |
| 1279 | } | 1325 | } |
| 1280 | 1326 | ||
| 1281 | if (entry.type !== "activity") { | 1327 | if (entry.type !== "activity") { |
| 1328 | flushGroup(); | ||
| 1282 | grouped.push(entry); | 1329 | grouped.push(entry); |
| 1283 | openGroupActivities = null; | ||
| 1284 | continue; | 1330 | continue; |
| 1285 | } | 1331 | } |
| 1286 | 1332 | ||
| 1287 | if (entry.activity.workEntry.sourceActivityKind === "context-compaction") { | 1333 | const isCompaction = entry.activity.workEntry.sourceActivityKind === "context-compaction"; |
| 1288 | grouped.push({ | 1334 | if (isCompaction || firstActivityEntry?.turnId !== entry.turnId) { |
| 1289 | type: "activity-group", | 1335 | flushGroup(); |
| 1290 | id: entry.id, | ||
| 1291 | createdAt: entry.createdAt, | ||
| 1292 | turnId: entry.turnId, | ||
| 1293 | activities: [entry.activity], | ||
| 1294 | }); | ||
| 1295 | openGroupActivities = null; | ||
| 1296 | continue; | ||
| 1297 | } | 1336 | } |
| 1298 | 1337 | firstActivityEntry ??= entry; | |
| 1299 | if (openGroupActivities !== null && openGroupTurnId === entry.turnId) { | 1338 | openGroupActivities.push(entry.activity); |
| 1300 | openGroupActivities.push(entry.activity); | 1339 | if (isCompaction) { |
| 1301 | continue; | 1340 | flushGroup(); |
| 1302 | } | 1341 | } |
| 1303 | |||
| 1304 | openGroupActivities = [entry.activity]; | ||
| 1305 | openGroupTurnId = entry.turnId; | ||
| 1306 | grouped.push({ | ||
| 1307 | type: "activity-group", | ||
| 1308 | id: entry.id, | ||
| 1309 | createdAt: entry.createdAt, | ||
| 1310 | turnId: entry.turnId, | ||
| 1311 | activities: openGroupActivities, | ||
| 1312 | }); | ||
| 1313 | } | 1342 | } |
| 1314 | 1343 | flushGroup(); | |
| 1315 | return grouped; | 1344 | return grouped; |
| 1316 | } | 1345 | } |
| 1317 | 1346 | ||
| ... | @@ -1509,14 +1538,26 @@ export function deriveThreadFeedPresentation( | ... | @@ -1509,14 +1538,26 @@ export function deriveThreadFeedPresentation( |
| 1509 | entry.turnId === unsettledTurnId; | 1538 | entry.turnId === unsettledTurnId; |
| 1510 | const fold = foldsByAnchorId.get(entry.id); | 1539 | const fold = foldsByAnchorId.get(entry.id); |
| 1511 | if (fold) { | 1540 | if (fold) { |
| 1512 | result.push({ | 1541 | const expanded = expandedTurnIds.has(fold.turnId); |
| 1513 | type: "turn-fold", | 1542 | let row = turnFoldRowsCache.get(entry); |
| 1514 | id: `turn-fold:${fold.turnId}`, | 1543 | if ( |
| 1515 | createdAt: fold.createdAt, | 1544 | !row || |
| 1516 | turnId: fold.turnId, | 1545 | row.turnId !== fold.turnId || |
| 1517 | label: fold.label, | 1546 | row.createdAt !== fold.createdAt || |
| 1518 | expanded: expandedTurnIds.has(fold.turnId), | 1547 | row.label !== fold.label || |
| 1519 | }); | 1548 | row.expanded !== expanded |
| 1549 | ) { | ||
| 1550 | row = { | ||
| 1551 | type: "turn-fold", | ||
| 1552 | id: `turn-fold:${fold.turnId}`, | ||
| 1553 | createdAt: fold.createdAt, | ||
| 1554 | turnId: fold.turnId, | ||
| 1555 | label: fold.label, | ||
| 1556 | expanded, | ||
| 1557 | }; | ||
| 1558 | turnFoldRowsCache.set(entry, row); | ||
| 1559 | } | ||
| 1560 | result.push(row); | ||
| 1520 | } | 1561 | } |
| 1521 | if (!collapsedEntryIds.has(entry.id)) { | 1562 | if (!collapsedEntryIds.has(entry.id)) { |
| 1522 | appendPresentedFeedEntry( | 1563 | appendPresentedFeedEntry( |
| ... | @@ -1549,6 +1590,41 @@ function appendPresentedFeedEntry( | ... | @@ -1549,6 +1590,41 @@ function appendPresentedFeedEntry( |
| 1549 | return; | 1590 | return; |
| 1550 | } | 1591 | } |
| 1551 | 1592 | ||
| 1593 | let cached = presentedActivityGroupsCache.get(entry); | ||
| 1594 | if ( | ||
| 1595 | !cached || | ||
| 1596 | cached.unsettledTurnId !== unsettledTurnId || | ||
| 1597 | cached.isWorking !== isWorking || | ||
| 1598 | cached.activeTail !== activeTail || | ||
| 1599 | cached.rows.some( | ||
| 1600 | (row) => row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded, | ||
| 1601 | ) | ||
| 1602 | ) { | ||
| 1603 | const rows: ThreadFeedEntry[] = []; | ||
| 1604 | appendActivityGroupRows( | ||
| 1605 | rows, | ||
| 1606 | entry, | ||
| 1607 | expandedWorkGroupIds, | ||
| 1608 | unsettledTurnId, | ||
| 1609 | isWorking, | ||
| 1610 | activeTail, | ||
| 1611 | ); | ||
| 1612 | cached = { unsettledTurnId, isWorking, activeTail, rows }; | ||
| 1613 | presentedActivityGroupsCache.set(entry, cached); | ||
| 1614 | } | ||
| 1615 | for (const row of cached.rows) { | ||
| 1616 | result.push(row); | ||
| 1617 | } | ||
| 1618 | } | ||
| 1619 | |||
| 1620 | function appendActivityGroupRows( | ||
| 1621 | result: ThreadFeedEntry[], | ||
| 1622 | entry: ThreadFeedActivityGroup, | ||
| 1623 | expandedWorkGroupIds: ReadonlySet<string>, | ||
| 1624 | unsettledTurnId: TurnId | null, | ||
| 1625 | isWorking: boolean, | ||
| 1626 | activeTail: boolean, | ||
| 1627 | ): void { | ||
| 1552 | const activities = omitSupersededLifecycleMarkers( | 1628 | const activities = omitSupersededLifecycleMarkers( |
| 1553 | entry.activities.filter( | 1629 | entry.activities.filter( |
| 1554 | (activity) => | 1630 | (activity) => |
| ... | @@ -1920,7 +1996,7 @@ export function buildPendingUserInputAnswers( | ... | @@ -1920,7 +1996,7 @@ export function buildPendingUserInputAnswers( |
| 1920 | } | 1996 | } |
| 1921 | 1997 | ||
| 1922 | export function buildThreadFeed( | 1998 | export function buildThreadFeed( |
| 1923 | thread: OrchestrationThread, | 1999 | thread: Pick<OrchestrationThread, "messages" | "activities">, |
| 1924 | options?: { | 2000 | options?: { |
| 1925 | readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>; | 2001 | readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>; |
| 1926 | readonly localMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>; | 2002 | readonly localMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>; |
| ... | @@ -1932,70 +2008,21 @@ export function buildThreadFeed( | ... | @@ -1932,70 +2008,21 @@ export function buildThreadFeed( |
| 1932 | : loadedMessages; | 2008 | : loadedMessages; |
| 1933 | const oldestLoadedMessageCreatedAt = | 2009 | const oldestLoadedMessageCreatedAt = |
| 1934 | options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; | 2010 | options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; |
| 1935 | const workLogEntries = deriveWorkLogEntries(thread.activities); | 2011 | const activityEntries = getThreadFeedActivityEntries(thread.activities); |
| 1936 | const entries = Arr.sortWith( | 2012 | const entries = Arr.sortWith( |
| 1937 | [ | 2013 | [ |
| 1938 | ...messages.map<RawThreadFeedEntry>((message) => ({ | 2014 | ...messages.map((message) => { |
| 1939 | type: "message", | 2015 | let entry = messageEntriesCache.get(message); |
| 1940 | id: message.id, | 2016 | if (!entry) { |
| 1941 | createdAt: message.createdAt, | 2017 | entry = { type: "message", id: message.id, createdAt: message.createdAt, message }; |
| 1942 | message, | 2018 | messageEntriesCache.set(message, entry); |
| 1943 | })), | 2019 | } |
| 1944 | ...workLogEntries | 2020 | return entry; |
| 1945 | .filter((entry) => { | 2021 | }), |
| 1946 | if (options?.loadedMessages === undefined) { | 2022 | ...activityEntries.filter( |
| 1947 | return true; | 2023 | (entry) => |
| 1948 | } | 2024 | oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, |
| 1949 | return ( | 2025 | ), |
| 1950 | oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt | ||
| 1951 | ); | ||
| 1952 | }) | ||
| 1953 | .map<RawThreadFeedEntry>((entry) => { | ||
| 1954 | const summary = workEntryHeading(entry); | ||
| 1955 | const detail = workEntryPreview(entry); | ||
| 1956 | const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); | ||
| 1957 | const getCopyText = memoizeValue(() => { | ||
| 1958 | const copyLabel = capitalizePhrase( | ||
| 1959 | normalizeCompactToolLabel(entry.toolTitle || entry.label), | ||
| 1960 | ); | ||
| 1961 | const fullDetail = getFullDetail(); | ||
| 1962 | if (entry.command) { | ||
| 1963 | const normalizedCommand = | ||
| 1964 | entry.rawCommand && copyLabel.trim() !== entry.command.trim() | ||
| 1965 | ? entry.command | ||
| 1966 | : null; | ||
| 1967 | return [copyLabel, normalizedCommand, fullDetail ?? entry.command] | ||
| 1968 | .filter((value): value is string => Boolean(value)) | ||
| 1969 | .join("\n"); | ||
| 1970 | } | ||
| 1971 | return [copyLabel, detail, fullDetail] | ||
| 1972 | .filter((value, index, values): value is string => { | ||
| 1973 | return Boolean(value) && values.indexOf(value) === index; | ||
| 1974 | }) | ||
| 1975 | .join("\n"); | ||
| 1976 | }); | ||
| 1977 | return { | ||
| 1978 | type: "activity", | ||
| 1979 | id: entry.id, | ||
| 1980 | createdAt: entry.createdAt, | ||
| 1981 | turnId: entry.turnId, | ||
| 1982 | activity: { | ||
| 1983 | id: entry.id, | ||
| 1984 | createdAt: entry.createdAt, | ||
| 1985 | turnId: entry.turnId, | ||
| 1986 | summary, | ||
| 1987 | detail, | ||
| 1988 | canExpand: workEntryHasExpandedBody(entry), | ||
| 1989 | getFullDetail, | ||
| 1990 | getCopyText, | ||
| 1991 | icon: workEntryIcon(entry), | ||
| 1992 | toolLike: workLogEntryIsToolLike(entry), | ||
| 1993 | status: workEntryStatus(entry), | ||
| 1994 | ...(entry.toolLifecycleStatus ? { lifecycleStatus: entry.toolLifecycleStatus } : {}), | ||
| 1995 | workEntry: entry, | ||
| 1996 | }, | ||
| 1997 | }; | ||
| 1998 | }), | ||
| 1999 | ], | 2026 | ], |
| 2000 | (s) => new Date(s.createdAt), | 2027 | (s) => new Date(s.createdAt), |
| 2001 | Order.Date, | 2028 | Order.Date, |
| ... | @@ -2003,3 +2030,56 @@ export function buildThreadFeed( | ... | @@ -2003,3 +2030,56 @@ export function buildThreadFeed( |
| 2003 | 2030 | ||
| 2004 | return groupAdjacentActivities(entries); | 2031 | return groupAdjacentActivities(entries); |
| 2005 | } | 2032 | } |
| 2033 | |||
| 2034 | function getThreadFeedActivityEntries(activities: ReadonlyArray<OrchestrationThreadActivity>) { | ||
| 2035 | const cached = activityEntriesCache.get(activities); | ||
| 2036 | if (cached) return cached; | ||
| 2037 | const entries = deriveWorkLogEntries(activities).map(toThreadFeedActivityEntry); | ||
| 2038 | activityEntriesCache.set(activities, entries); | ||
| 2039 | return entries; | ||
| 2040 | } | ||
| 2041 | |||
| 2042 | function toThreadFeedActivityEntry( | ||
| 2043 | entry: DerivedWorkLogEntry, | ||
| 2044 | ): Extract<RawThreadFeedEntry, { readonly type: "activity" }> { | ||
| 2045 | const summary = workEntryHeading(entry); | ||
| 2046 | const detail = workEntryPreview(entry); | ||
| 2047 | const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); | ||
| 2048 | const getCopyText = memoizeValue(() => { | ||
| 2049 | const copyLabel = capitalizePhrase(normalizeCompactToolLabel(entry.toolTitle || entry.label)); | ||
| 2050 | const fullDetail = getFullDetail(); | ||
| 2051 | if (entry.command) { | ||
| 2052 | const normalizedCommand = | ||
| 2053 | entry.rawCommand && copyLabel.trim() !== entry.command.trim() ? entry.command : null; | ||
| 2054 | return [copyLabel, normalizedCommand, fullDetail ?? entry.command] | ||
| 2055 | .filter((value): value is string => Boolean(value)) | ||
| 2056 | .join("\n"); | ||
| 2057 | } | ||
| 2058 | return [copyLabel, detail, fullDetail] | ||
| 2059 | .filter((value, index, values): value is string => { | ||
| 2060 | return Boolean(value) && values.indexOf(value) === index; | ||
| 2061 | }) | ||
| 2062 | .join("\n"); | ||
| 2063 | }); | ||
| 2064 | return { | ||
| 2065 | type: "activity", | ||
| 2066 | id: entry.id, | ||
| 2067 | createdAt: entry.createdAt, | ||
| 2068 | turnId: entry.turnId, | ||
| 2069 | activity: { | ||
| 2070 | id: entry.id, | ||
| 2071 | createdAt: entry.createdAt, | ||
| 2072 | turnId: entry.turnId, | ||
| 2073 | summary, | ||
| 2074 | detail, | ||
| 2075 | canExpand: workEntryHasExpandedBody(entry), | ||
| 2076 | getFullDetail, | ||
| 2077 | getCopyText, | ||
| 2078 | icon: workEntryIcon(entry), | ||
| 2079 | toolLike: workLogEntryIsToolLike(entry), | ||
| 2080 | status: workEntryStatus(entry), | ||
| 2081 | ...(entry.toolLifecycleStatus ? { lifecycleStatus: entry.toolLifecycleStatus } : {}), | ||
| 2082 | workEntry: entry, | ||
| 2083 | }, | ||
| 2084 | }; | ||
| 2085 | } |
apps/mobile/src/state/use-thread-composer-state.ts+19-12| ... | @@ -126,21 +126,28 @@ export function useThreadComposerState() { | ... | @@ -126,21 +126,28 @@ export function useThreadComposerState() { |
| 126 | () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), | 126 | () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), |
| 127 | [queuedMessagesByThreadKey, selectedThreadKey], | 127 | [queuedMessagesByThreadKey, selectedThreadKey], |
| 128 | ); | 128 | ); |
| 129 | const selectedThreadFeed = useMemo(() => { | 129 | const localFeedbackMessages = useMemo(() => { |
| 130 | if (!selectedThreadDetail) { | ||
| 131 | return []; | ||
| 132 | } | ||
| 133 | const submissions = selectedThreadKey | 130 | const submissions = selectedThreadKey |
| 134 | ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) | 131 | ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) |
| 135 | : []; | 132 | : []; |
| 136 | return buildThreadFeed(selectedThreadDetail, { | 133 | return submissions.flatMap((submission) => |
| 137 | localMessages: submissions.flatMap((submission) => | 134 | submission.status === "interrupted" |
| 138 | submission.status === "interrupted" | 135 | ? [] |
| 139 | ? [] | 136 | : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], |
| 140 | : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], | 137 | ); |
| 141 | ), | 138 | }, [feedbackSubmissionsByThreadKey, selectedThreadKey]); |
| 142 | }); | 139 | const selectedThreadMessages = selectedThreadDetail?.messages; |
| 143 | }, [feedbackSubmissionsByThreadKey, selectedThreadDetail, selectedThreadKey]); | 140 | const selectedThreadActivities = selectedThreadDetail?.activities; |
| 141 | const selectedThreadFeed = useMemo( | ||
| 142 | () => | ||
| 143 | selectedThreadMessages && selectedThreadActivities | ||
| 144 | ? buildThreadFeed( | ||
| 145 | { messages: selectedThreadMessages, activities: selectedThreadActivities }, | ||
| 146 | { localMessages: localFeedbackMessages }, | ||
| 147 | ) | ||
| 148 | : [], | ||
| 149 | [localFeedbackMessages, selectedThreadActivities, selectedThreadMessages], | ||
| 150 | ); | ||
| 144 | 151 | ||
| 145 | const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; | 152 | const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; |
| 146 | const draftMessage = selectedDraft?.text ?? ""; | 153 | const draftMessage = selectedDraft?.text ?? ""; |