authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 07:25:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 07:25:15-07:00
log44dc8ae259f5c3349f7ab8045e39bd62408b52a9
treeda462111cce802530325ceed96b66662765d56a3
parentf2e3764c257a7e27c8171d7dd1e38d4383074206
signature Signed by PGP key B5690EEEBB952194

perf(mobile): reuse chat feed rows during streaming (#9688)


3 files changed, 440 insertions(+), 115 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+239-1
......@@ -415,6 +415,173 @@ function makeThread(
415415}
416416
417417describe("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
418585 it("keeps context compaction as a standalone timeline row", () => {
419586 const thread = makeThread({
420587 id: ThreadId.make("thread-context-compaction"),
......@@ -696,7 +863,8 @@ describe("buildThreadFeed", () => {
696863 };
697864
698865 for (const currentTurn of [null, latestTurn]) {
699 const feed = buildThreadFeed({ ...thread, latestTurn: currentTurn });
866 const currentThread = { ...thread, latestTurn: currentTurn };
867 const feed = buildThreadFeed(currentThread);
700868 expect(feed).toMatchObject([
701869 {
702870 type: "activity-group",
......@@ -1618,6 +1786,30 @@ describe("buildThreadFeed", () => {
16181786 "work-toggle:work-group:tool-completed",
16191787 "assistant-final",
16201788 ]);
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" });
16211813 });
16221814
16231815 it("folds assistant messages between the first and terminal messages", () => {
......@@ -1916,6 +2108,15 @@ describe("buildThreadFeed", () => {
19162108 { id: "activity-3", groupedToolDetail: true, live: false },
19172109 ],
19182110 });
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);
19192120 });
19202121
19212122 it.each(
......@@ -2272,6 +2473,43 @@ describe("buildThreadFeed", () => {
22722473 { id: "call-b-2", lifecycleStatus: "completed", groupedToolDetail: true, live: false },
22732474 ],
22742475 });
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 });
22752513 });
22762514});
22772515
apps/mobile/src/lib/threadActivity.ts+182-102
......@@ -176,6 +176,32 @@ export type ThreadFeedLatestTurn = Pick<
176176 "turnId" | "state" | "startedAt" | "completedAt"
177177>;
178178
179type ThreadFeedActivityGroup = Extract<ThreadFeedEntry, { readonly type: "activity-group" }>;
180
181// These keys are immutable inputs. Weak caches release old histories with their source data.
182const activityEntriesCache = new WeakMap<
183 ReadonlyArray<OrchestrationThreadActivity>,
184 ReadonlyArray<Extract<RawThreadFeedEntry, { readonly type: "activity" }>>
185>();
186const messageEntriesCache = new WeakMap<
187 OrchestrationThread["messages"][number],
188 Extract<RawThreadFeedEntry, { readonly type: "message" }>
189>();
190const activityGroupsCache = new WeakMap<ThreadFeedActivity, ThreadFeedActivityGroup>();
191const 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>();
200const turnFoldRowsCache = new WeakMap<
201 ThreadFeedEntry,
202 Extract<ThreadFeedEntry, { readonly type: "turn-fold" }>
203>();
204
179205export function isContextCompactionActivityGroup(
180206 entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>,
181207): boolean {
......@@ -1266,11 +1292,31 @@ function isEmptyMessage(entry: RawThreadFeedEntry): boolean {
12661292
12671293function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] {
12681294 const grouped: ThreadFeedEntry[] = [];
1269 // Mutable backing array for the trailing group so appending an activity is
1270 // O(1) instead of re-copying the group (which made this loop quadratic on
1271 // long tool runs). The array is only mutated while it is the trailing group.
1272 let openGroupActivities: ThreadFeedActivity[] | null = null;
1273 let openGroupTurnId: TurnId | null = null;
1295 let firstActivityEntry: Extract<RawThreadFeedEntry, { readonly type: "activity" }> | null = null;
1296 let openGroupActivities: ThreadFeedActivity[] = [];
1297 const flushGroup = () => {
1298 if (firstActivityEntry === null) return;
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 };
12741320
12751321 for (const entry of entries) {
12761322 // Skip empty messages so they don't break activity grouping.
......@@ -1279,39 +1325,22 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
12791325 }
12801326
12811327 if (entry.type !== "activity") {
1328 flushGroup();
12821329 grouped.push(entry);
1283 openGroupActivities = null;
12841330 continue;
12851331 }
12861332
1287 if (entry.activity.workEntry.sourceActivityKind === "context-compaction") {
1288 grouped.push({
1289 type: "activity-group",
1290 id: entry.id,
1291 createdAt: entry.createdAt,
1292 turnId: entry.turnId,
1293 activities: [entry.activity],
1294 });
1295 openGroupActivities = null;
1296 continue;
1333 const isCompaction = entry.activity.workEntry.sourceActivityKind === "context-compaction";
1334 if (isCompaction || firstActivityEntry?.turnId !== entry.turnId) {
1335 flushGroup();
12971336 }
1298
1299 if (openGroupActivities !== null && openGroupTurnId === entry.turnId) {
1300 openGroupActivities.push(entry.activity);
1301 continue;
1337 firstActivityEntry ??= entry;
1338 openGroupActivities.push(entry.activity);
1339 if (isCompaction) {
1340 flushGroup();
13021341 }
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 });
13131342 }
1314
1343 flushGroup();
13151344 return grouped;
13161345}
13171346
......@@ -1509,14 +1538,26 @@ export function deriveThreadFeedPresentation(
15091538 entry.turnId === unsettledTurnId;
15101539 const fold = foldsByAnchorId.get(entry.id);
15111540 if (fold) {
1512 result.push({
1513 type: "turn-fold",
1514 id: `turn-fold:${fold.turnId}`,
1515 createdAt: fold.createdAt,
1516 turnId: fold.turnId,
1517 label: fold.label,
1518 expanded: expandedTurnIds.has(fold.turnId),
1519 });
1541 const expanded = expandedTurnIds.has(fold.turnId);
1542 let row = turnFoldRowsCache.get(entry);
1543 if (
1544 !row ||
1545 row.turnId !== fold.turnId ||
1546 row.createdAt !== fold.createdAt ||
1547 row.label !== fold.label ||
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);
15201561 }
15211562 if (!collapsedEntryIds.has(entry.id)) {
15221563 appendPresentedFeedEntry(
......@@ -1549,6 +1590,41 @@ function appendPresentedFeedEntry(
15491590 return;
15501591 }
15511592
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
1620function appendActivityGroupRows(
1621 result: ThreadFeedEntry[],
1622 entry: ThreadFeedActivityGroup,
1623 expandedWorkGroupIds: ReadonlySet<string>,
1624 unsettledTurnId: TurnId | null,
1625 isWorking: boolean,
1626 activeTail: boolean,
1627): void {
15521628 const activities = omitSupersededLifecycleMarkers(
15531629 entry.activities.filter(
15541630 (activity) =>
......@@ -1920,7 +1996,7 @@ export function buildPendingUserInputAnswers(
19201996}
19211997
19221998export function buildThreadFeed(
1923 thread: OrchestrationThread,
1999 thread: Pick<OrchestrationThread, "messages" | "activities">,
19242000 options?: {
19252001 readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;
19262002 readonly localMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;
......@@ -1932,70 +2008,21 @@ export function buildThreadFeed(
19322008 : loadedMessages;
19332009 const oldestLoadedMessageCreatedAt =
19342010 options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null;
1935 const workLogEntries = deriveWorkLogEntries(thread.activities);
2011 const activityEntries = getThreadFeedActivityEntries(thread.activities);
19362012 const entries = Arr.sortWith(
19372013 [
1938 ...messages.map<RawThreadFeedEntry>((message) => ({
1939 type: "message",
1940 id: message.id,
1941 createdAt: message.createdAt,
1942 message,
1943 })),
1944 ...workLogEntries
1945 .filter((entry) => {
1946 if (options?.loadedMessages === undefined) {
1947 return true;
1948 }
1949 return (
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 }),
2014 ...messages.map((message) => {
2015 let entry = messageEntriesCache.get(message);
2016 if (!entry) {
2017 entry = { type: "message", id: message.id, createdAt: message.createdAt, message };
2018 messageEntriesCache.set(message, entry);
2019 }
2020 return entry;
2021 }),
2022 ...activityEntries.filter(
2023 (entry) =>
2024 oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt,
2025 ),
19992026 ],
20002027 (s) => new Date(s.createdAt),
20012028 Order.Date,
......@@ -2003,3 +2030,56 @@ export function buildThreadFeed(
20032030
20042031 return groupAdjacentActivities(entries);
20052032}
2033
2034function 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
2042function 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() {
126126 () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
127127 [queuedMessagesByThreadKey, selectedThreadKey],
128128 );
129 const selectedThreadFeed = useMemo(() => {
130 if (!selectedThreadDetail) {
131 return [];
132 }
129 const localFeedbackMessages = useMemo(() => {
133130 const submissions = selectedThreadKey
134131 ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? [])
135132 : [];
136 return buildThreadFeed(selectedThreadDetail, {
137 localMessages: submissions.flatMap((submission) =>
138 submission.status === "interrupted"
139 ? []
140 : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")],
141 ),
142 });
143 }, [feedbackSubmissionsByThreadKey, selectedThreadDetail, selectedThreadKey]);
133 return submissions.flatMap((submission) =>
134 submission.status === "interrupted"
135 ? []
136 : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")],
137 );
138 }, [feedbackSubmissionsByThreadKey, selectedThreadKey]);
139 const selectedThreadMessages = selectedThreadDetail?.messages;
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 );
144151
145152 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
146153 const draftMessage = selectedDraft?.text ?? "";