Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ describe("tool-group scroll restoration", () => {
index: 2,
viewOffset: -80,
});
const nestedChildren = Array.from({ length: 1_000 }, (_, index) => ({
id: `child-${index}`,
}));
const nestedPosition = { ...position, rowId: "child-800" };
expect(resolveThreadWorkGroupInitialScroll(nestedChildren, nestedPosition)).toEqual({
index: 800,
viewOffset: -80,
});
expect(
resolveThreadWorkGroupInitialScroll(
nestedChildren.map((row) => ({ ...row })),
nestedPosition,
),
).toEqual({ index: 800, viewOffset: -80 });
});
});

Expand Down
68 changes: 48 additions & 20 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -480,23 +480,44 @@ interface ThreadWorkLogProps {
}

export function ThreadWorkLog(props: ThreadWorkLogProps) {
const renderRow = (row: ThreadFeedActivity) => (
<ThreadWorkLogRow
key={row.id}
row={row}
anchorKey={props.anchorKey}
copied={props.copiedRowId === row.id}
expanded={props.expandedRows[row.id] ?? false}
environmentId={props.environmentId}
iconSubtleColor={props.iconSubtleColor}
onCopyRow={props.onCopyRow}
onToggleRow={props.onToggleRow}
renderImage={props.renderImage}
renderReasoning={props.renderReasoning}
onPressPreview={props.onPressPreview}
themeAppearance={props.themeAppearance}
/>
);
const renderRow = (row: ThreadFeedActivity): ReactNode => {
const content = (
<ThreadWorkLogRow
key={row.id}
row={row}
anchorKey={props.anchorKey}
copied={props.copiedRowId === row.id}
expanded={props.expandedRows[row.id] ?? false}
environmentId={props.environmentId}
iconSubtleColor={props.iconSubtleColor}
onCopyRow={props.onCopyRow}
onToggleRow={props.onToggleRow}
renderImage={props.renderImage}
renderReasoning={props.renderReasoning}
onPressPreview={props.onPressPreview}
themeAppearance={props.themeAppearance}
/>
);
if (!row.nestedToolActivities) return content;
return (
<View key={row.id}>
{content}
{props.expandedRows[row.id] ? (
<View className="ml-7">
<ThreadWorkGroupList
activities={row.nestedToolActivities}
edgeFadeColor={props.edgeFadeColor}
expandedRows={props.expandedRows}
groupId={row.id}
rowSizing={props.rowSizing}
scrollPositions={props.scrollPositions}
renderRow={renderRow}
/>
</View>
) : null}
</View>
);
};

if (props.activities.length === 0) {
return null;
Expand Down Expand Up @@ -969,7 +990,9 @@ const ThreadWorkLogRow = function ThreadWorkLogRow(
row.projectedItem.item.type === "notification"
? notificationChildThreadId(row.projectedItem.item.source)
: undefined;
const canExpand = row.canExpand && notifiedSubagentThreadId === undefined;
const canExpand =
(row.canExpand || row.nestedToolActivities !== undefined) &&
notifiedSubagentThreadId === undefined;
const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null;
const fetchedItem = fetchedDetail.data?.item ?? null;
const isRead = toolGroupAction(row.workEntry) === "read";
Expand Down Expand Up @@ -1018,12 +1041,17 @@ const ThreadWorkLogRow = function ThreadWorkLogRow(
</Text>
);
const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
const previewText = row.groupStandIn?.label ?? workEntryRowLabel(row.workEntry);
const groupLabel =
row.nestedToolActivities && row.lifecycleStatus === "inProgress"
? `${workEntryRowLabel(row.workEntry)} (running)`
: undefined;
const previewText = groupLabel ?? row.groupStandIn?.label ?? workEntryRowLabel(row.workEntry);
const answerPreview = row.workEntry.questionAnswer
? getQuestionAnswerPreview(row.workEntry.questionAnswer)
: null;
const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": ");
const displayText = row.groupStandIn?.label ?? workEntryRowLabel(row.workEntry, expanded);
const displayText =
groupLabel ?? row.groupStandIn?.label ?? workEntryRowLabel(row.workEntry, expanded);
const isSystemNotice = row.projectedItem.item.type === "system_notice";
const isUsageLimit =
row.projectedItem.item.type === "error" &&
Expand Down
107 changes: 107 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,113 @@ function assistantMessage(updatedAt = "2026-06-20T00:00:03.000Z") {
}

describe("buildThreadFeed", () => {
it("keeps nested tools together across messages and shows a running child after the parent finishes", () => {
const parent: OrchestrationV2TurnItem = {
...base("outer", "2026-06-20T00:00:02.000Z", 1),
type: "dynamic_tool",
toolName: "Script",
input: {},
};
const child = {
...command(),
id: TurnItemId.make("nested"),
parentItemId: parent.id,
status: "running" as const,
};
const items = [projected(parent, 0), projected(assistantMessage(), 1), projected(child, 2)];
const cache = createThreadFeedCache();
const feed = buildThreadFeed(items, { cache });
expect(feed).toMatchObject([
{
type: "activity-group",
activities: [
{
lifecycleStatus: "inProgress",
nestedToolActivities: [{ projectedItem: { item: { id: child.id } } }],
},
],
},
{ type: "message" },
]);
const presented = deriveThreadFeedPresentation(feed, null, new Set());
expect(presented[0]).toMatchObject({
type: "activity-group",
activities: [{ lifecycleStatus: "inProgress" }],
});
expect(
deriveThreadFeedPresentation(
feed,
{
runId,
status: "running",
startedAt: "2026-06-20T00:00:02.000Z",
completedAt: null,
},
new Set(),
new Set(),
"2026-06-20T00:00:02.000Z",
).some((entry) => entry.type === "thinking"),
).toBe(false);
const unrelatedUpdate = buildThreadFeed(
[items[0]!, projected({ ...assistantMessage(), text: "Still working" }, 1), items[2]!],
{ cache },
);
expect(unrelatedUpdate[0]).toBe(feed[0]);
const completed = { ...child, status: "completed" as const };
const updated = buildThreadFeed([...items.slice(0, 2), projected(completed, 2)], { cache });
expect(updated[0]).not.toBe(feed[0]);
expect(updated[0]).toMatchObject({
activities: [
{
lifecycleStatus: "completed",
nestedToolActivities: [{ projectedItem: { item: { status: "completed" } } }],
},
],
});
const nextPrompt = projected(
{
...userMessage(),
id: TurnItemId.make("next-prompt"),
runId: RunId.make("next-run"),
messageId: MessageId.make("next-message"),
},
2,
);
const beforeLateChild = [...items.slice(0, 2), nextPrompt];
buildThreadFeed(beforeLateChild, { cache });
const withLateChild = [...beforeLateChild, projected(child, 3)];
expect(buildThreadFeed(withLateChild, { cache })).toEqual(buildThreadFeed(withLateChild));
const answer = projected(
{
...assistantMessage(),
id: TurnItemId.make("next-answer"),
messageId: MessageId.make("next-answer-message"),
runId: RunId.make("next-run"),
},
4,
);
const beforeUnrelatedUpdate = buildThreadFeed([...withLateChild, answer], { cache });
const afterUnrelatedUpdate = buildThreadFeed(
[
...withLateChild,
{
...answer,
item: { ...answer.item, updatedAt: DateTime.makeUnsafe("2026-06-20T00:00:09.000Z") },
},
],
{ cache },
);
expect(afterUnrelatedUpdate[0]).toBe(beforeUnrelatedUpdate[0]);
expect(afterUnrelatedUpdate[1]).toBe(beforeUnrelatedUpdate[1]);
const childBeforeParent = [items[2]!, items[1]!, items[0]!, nextPrompt, answer];
buildThreadFeed(childBeforeParent, { cache });
const changedParent = [
...childBeforeParent.slice(0, 2),
projected({ ...parent, status: "running" }, 2),
...childBeforeParent.slice(3),
];
expect(buildThreadFeed(changedParent, { cache })).toEqual(buildThreadFeed(changedParent));
});
it("keeps async answers in question history instead of user bubbles", () => {
const requestId = RuntimeRequestId.make("question");
const question: OrchestrationV2TurnItem = {
Expand Down
86 changes: 81 additions & 5 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
ThreadPendingApproval,
ThreadPendingUserInput,
} from "@t3tools/client-runtime/state/thread-requests";
import { groupNestedToolItems } from "@t3tools/client-runtime/work-log/nested-tools";
import { linkRequestTargetName } from "@t3tools/client-runtime/link-request";
import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
Expand Down Expand Up @@ -85,6 +86,7 @@ export type PendingApproval = ThreadPendingApproval;
export type PendingUserInput = ThreadPendingUserInput;

export interface ThreadFeedActivity {
readonly nestedToolActivities?: ReadonlyArray<ThreadFeedActivity>;
readonly id: string;
readonly createdAt: string;
readonly runId: RunId | null;
Expand Down Expand Up @@ -325,6 +327,7 @@ const localMessageEntriesCache = new WeakMap<
Extract<RawThreadFeedEntry, { readonly type: "message" }>
>();
const activityGroupsCache = new WeakMap<ThreadFeedActivity, ThreadFeedActivityGroup>();
const nestedActivitiesCache = new WeakMap<ThreadFeedActivity, ThreadFeedActivity>();
const failedActivityGroupsCache = new WeakMap<
ThreadFeedActivityGroup,
ReadonlyArray<ThreadFeedActivityGroup>
Expand Down Expand Up @@ -896,6 +899,42 @@ function groupAdjacentActivities(
childrenByRun: ReadonlyMap<RunId, ReadonlySet<string>>,
onRestart?: (entryIndex: number, groupedLength: number) => void,
): ThreadFeedEntry[] {
const sourceSpans = new Map(
entries.map((entry, index) => [entry.id, { index, start: index, end: index }]),
);
const crossings = new Array<number>(entries.length).fill(0);
const nestedEntries = groupNestedToolItems(
entries,
(entry) => (entry.type === "activity" ? entry.activity.projectedItem.item : undefined),
(entry, children, running) => {
const span = sourceSpans.get(entry.id)!;
for (const child of children) {
const childSpan = sourceSpans.get(child.id)!;
span.start = Math.min(span.start, childSpan.start);
span.end = Math.max(span.end, childSpan.end);
}
crossings[span.start]! += 1;
crossings[span.end]! -= 1;
if (entry.type !== "activity") return entry;
const activities = children.flatMap((child) =>
child.type === "activity" ? [child.activity] : [],
);
const lifecycleStatus = running ? "inProgress" : entry.activity.lifecycleStatus;
const cached = nestedActivitiesCache.get(entry.activity);
const activity =
cached?.lifecycleStatus === lifecycleStatus &&
cached.nestedToolActivities?.length === activities.length &&
cached.nestedToolActivities.every((child, index) => child === activities[index])
? cached
: { ...entry.activity, lifecycleStatus, nestedToolActivities: activities };
nestedActivitiesCache.set(entry.activity, activity);
return { ...entry, activity };
},
);
// A restart must not split a family, including children received before their parent.
for (let index = 1; index < crossings.length; index += 1) {
crossings[index]! += crossings[index - 1]!;
}
const grouped: ThreadFeedEntry[] = [];
let firstActivityEntry: Extract<RawThreadFeedEntry, { readonly type: "activity" }> | null = null;
let openGroupActivities: ThreadFeedActivity[] = [];
Expand Down Expand Up @@ -923,11 +962,12 @@ function groupAdjacentActivities(
openGroupActivities = [];
};

for (let entryIndex = 0; entryIndex < entries.length; entryIndex += 1) {
const entry = entries[entryIndex]!;
for (let entryIndex = 0; entryIndex < nestedEntries.length; entryIndex += 1) {
const entry = nestedEntries[entryIndex]!;
if (entry.type === "activity") {
const item = entry.activity.projectedItem.item;
if (
entry.activity.nestedToolActivities === undefined &&
item.type === "dynamic_tool" &&
item.runId !== null &&
(item.status === "running" || item.status === "completed") &&
Expand All @@ -950,11 +990,13 @@ function groupAdjacentActivities(
if (entry.type !== "activity") {
flushGroup();
grouped.push(entry);
onRestart?.(entryIndex, grouped.length);
const sourceIndex = sourceSpans.get(entry.id)!.index;
if (crossings[sourceIndex] === 0) onRestart?.(sourceIndex, grouped.length);
continue;
}

const isStandaloneActivity =
entry.activity.nestedToolActivities !== undefined ||
entry.activity.workEntry.questionAnswer !== undefined ||
entry.activity.projectedItem.item.type === "compaction" ||
entry.activity.projectedItem.item.type === "handoff" ||
Expand Down Expand Up @@ -1243,7 +1285,9 @@ function deriveThreadFeedRunFolds(
activity.projectedItem.item.type === "handoff" ||
// A child still working outlives its settled launching run;
// its group stays visible while any member is live.
isLiveSubagentTurnItem(activity.projectedItem.item),
isLiveSubagentTurnItem(activity.projectedItem.item) ||
(activity.nestedToolActivities !== undefined &&
activity.lifecycleStatus === "inProgress"),
)
),
)
Expand Down Expand Up @@ -1803,6 +1847,13 @@ export function deriveThreadFeedPresentation(
!result.some(
(row) =>
row.id === LIVE_ACTIVITY_ROW_ID ||
(row.type === "activity-group" &&
row.activities.some(
(activity) =>
activity.nestedToolActivities !== undefined &&
activity.lifecycleStatus === "inProgress" &&
activity.runId === activeRunId,
)) ||
(row.type === "activity-group" &&
isContextCompactionActivityGroup(row) &&
row.runId === activeRunId &&
Expand Down Expand Up @@ -1963,6 +2014,7 @@ function appendPresentedFeedEntry(
isContextCompactionActivityGroup(entry) ||
isContextHandoffActivityGroup(entry) ||
isUserInputActivityGroup(entry) ||
entry.activities[0]?.nestedToolActivities !== undefined ||
entry.activities[0]?.projectedItem.item.type === "subagent"
) {
result.push(entry);
Expand Down Expand Up @@ -2357,7 +2409,31 @@ export function buildThreadFeed(
mapsEqual(previous.foldedAnswerRunIdByMessageId, foldedAnswerRunIdByMessageId) &&
setMapsEqual(previous.delegatedChildrenByRun, delegatedChildrenByRun)
) {
const firstChanged = firstChangedIndex(previous.rows, visibleTurnItems);
let firstChanged = firstChangedIndex(previous.rows, visibleTurnItems);
// Changed or late children invalidate their ancestors, even across a later prompt.
const changedStart = firstChanged;
for (const rows of [previous.rows, visibleTurnItems]) {
const rowsById = new Map<string, { parentId: string | null; index: number }>();
for (const [index, { item }] of rows.entries()) {
rowsById.set(JSON.stringify([item.threadId, item.id]), {
parentId:
item.parentItemId === null ? null : JSON.stringify([item.threadId, item.parentItemId]),
index,
});
}
const visited = new Set<string>();
for (let index = changedStart; index < rows.length; index += 1) {
const item = rows[index]!.item;
let key: string | null = JSON.stringify([item.threadId, item.id]);
while (key !== null && !visited.has(key)) {
visited.add(key);
const ancestor = rowsById.get(key);
if (!ancestor) break;
firstChanged = Math.min(firstChanged, ancestor.index);
key = ancestor.parentId;
}
}
}
restartIndex = previous.restarts.length - 1;
while (restartIndex >= 0 && previous.restarts[restartIndex]!.row > firstChanged) {
restartIndex -= 1;
Expand Down
Loading