Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2607da1
fix(mobile): load deferred work when expanding thread history
Yash-Singh1 Oct 28, 2026
a7111d4
fix(history): simplify deferred loading and address review findings
Yash-Singh1 Oct 28, 2026
9b8d813
Merge remote-tracking branch 'origin/main' into t3/fix-last-user-mess…
Yash-Singh1 Oct 28, 2026
698fed5
fix(ui): explain deferred history effects under current lint rule
Yash-Singh1 Oct 28, 2026
20f8ace
fix(server): keep interrupt requests before paged results
Yash-Singh1 Oct 28, 2026
e46c291
merge: resolve upstream projection store cleanup
Yash-Singh1 Oct 28, 2026
30ba916
perf(server): scan work output only for deferred history
Yash-Singh1 Oct 28, 2026
9a0ab90
perf(server): compute blocked history runs once per snapshot
Yash-Singh1 Oct 28, 2026
e61e8b9
fix(chat): avoid repeating failed deferred work loads on render
Yash-Singh1 Oct 28, 2026
ecf9842
fix(history): make replaced work requests retryable
Yash-Singh1 Oct 28, 2026
ba78593
fix(web): refresh fold rows when deferred work changes
Yash-Singh1 Oct 28, 2026
65ea7bc
fix(history): show workspace preparation with deferred work
Yash-Singh1 Oct 28, 2026
83b6ee2
fix(history): load the last user message across surfaces
Yash-Singh1 Oct 28, 2026
6f480de
fix(history): load superseded work and resume long history pages
Yash-Singh1 Oct 28, 2026
a325bf7
refactor(history): simplify deferred work paging
Yash-Singh1 Oct 28, 2026
da1e4fe
fix(history): preserve hidden anchors and replacement cursors
Yash-Singh1 Oct 28, 2026
5709686
merge: resolve history branch conflicts with main
Yash-Singh1 Oct 28, 2026
ce1dbbd
style(history): format merged history regression test
Yash-Singh1 Oct 28, 2026
9d2f1d1
fix(web): guard deferred work loading without a thread ref
Yash-Singh1 Oct 28, 2026
23c8c77
fix(history): resolve hidden deferred tails by source ordinal
Yash-Singh1 Oct 28, 2026
1f9a6ed
refactor(history): use a fixed 150-item work threshold
Yash-Singh1 Oct 28, 2026
779a5e7
perf(history): scope marker lookups to retained runs
Yash-Singh1 Oct 28, 2026
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
119 changes: 95 additions & 24 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,92 @@ function compactionLabel(label: string, title: string | undefined) {
: label;
}

function RunFoldButton({
entry,
environmentId,
threadId,
onToggle,
rowSizing,
iconColor,
}: {
entry: Extract<ThreadFeedEntry, { type: "run-fold" }>;
environmentId: EnvironmentId;
threadId: ThreadId;
onToggle: (runId: RunId) => void;
rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
iconColor: ColorValue;
}) {
const loadWork = useAtomCommand(threadEnvironment.loadEarlierHistory, { reportFailure: false });
const anchorsKey = entry.deferredWorkItemIds?.join("\n") ?? "";
const [failed, setFailed] = useState(false);
const load = () => {
if (!anchorsKey) return;
void Promise.all(
anchorsKey
.split("\n")
.map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })),
).then((results) =>
setFailed(
results.some((result) => result._tag === "Failure" || result.value._tag === "error"),
),
);
};
const loadOnExpand = useEffectEvent(load);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount.
useEffect(() => {
if (entry.expanded && anchorsKey && environmentId && threadId) loadOnExpand();
}, [entry.expanded, anchorsKey, environmentId, threadId]);
const error = anchorsKey && failed ? "Couldn't load work. Tap to retry." : null;
const loading = entry.expanded && anchorsKey !== "" && !failed;
const toggle = () => {
setFailed(false);
if (error && entry.expanded) load();
else onToggle(entry.runId);
};

return (
<View className="min-w-0 flex-1">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: entry.expanded, busy: loading }}
onPress={toggle}
hitSlop={4}
className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2"
style={{ minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, rowSizing.estimatedRowHeight) }}
>
<Text
key={rowSizing.textSizeKey}
numberOfLines={1}
className="shrink font-t3-medium text-sm tabular-nums text-foreground-muted"
>
{entry.label}
{error ? ` · ${error}` : ""}
</Text>
<ThreadDisclosureChevron
expanded={entry.expanded}
collapsedDirection="right"
size={15}
tintColor={iconColor}
/>
</Pressable>
{loading ? (
<View
accessible={false}
importantForAccessibility="no-hide-descendants"
className="gap-2 px-1 pb-2"
>
{(["40%", "60%", "33%"] as const).map((width) => (
<View key={width} className="flex-row items-center gap-2">
<View className="size-3 rounded bg-subtle-strong" />
<View className="h-2.5 rounded-full bg-subtle-strong" style={{ width }} />
</View>
))}
</View>
) : null}
</View>
);
}

function renderFeedEntry(
info: { item: ThreadFeedRow; index: number },
props: Pick<
Expand Down Expand Up @@ -1279,30 +1365,15 @@ function renderFeedEntry(
if (entry.type === "run-fold") {
return (
<View className="mb-1 flex-row items-center border-b border-border-subtle px-2">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: entry.expanded }}
onPress={() => props.onToggleTurnFold(entry.runId)}
hitSlop={4}
className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2"
style={{
minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight),
}}
>
<Text
key={props.workRowSizing.textSizeKey}
numberOfLines={1}
className="shrink font-t3-medium text-sm tabular-nums text-foreground-muted"
>
{entry.label}
</Text>
<ThreadDisclosureChevron
expanded={entry.expanded}
collapsedDirection="right"
size={15}
tintColor={iconSubtleColor}
/>
</Pressable>
<RunFoldButton
key={entry.id}
entry={entry}
environmentId={props.environmentId}
threadId={props.threadId}
onToggle={props.onToggleTurnFold}
rowSizing={props.workRowSizing}
iconColor={iconSubtleColor}
/>
{entry.checkpointSummary ? (
<TurnDiffStat
checkpoint={entry.checkpointSummary}
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,21 @@ describe("buildThreadFeed", () => {
"message",
"message",
]);
const anchor = {
...command(),
deferredWork: { cursor: "old-work", itemCount: 100, lastOrdinal: 100 },
};
const deferredFeed = buildThreadFeed([projected(userMessage(), 0), projected(anchor, 1)]);
const running = { ...latestRun, status: "running" as const, completedAt: null };
const deferredRows = deriveThreadFeedPresentation(deferredFeed, running, new Set());
const disclosure = deferredRows.find((entry) => entry.type === "run-fold");
expect(disclosure).toMatchObject({ deferredWorkItemIds: [anchor.id], expanded: false });
if (disclosure?.type !== "run-fold") throw new Error("Expected deferred work disclosure");
expect(
deriveThreadFeedPresentation(deferredFeed, running, new Set([disclosure.runId])).some(
(entry) => entry.type === "activity-group",
),
).toBe(true);
expect(expanded[4]).toMatchObject({ message: { id: middle.messageId, text: middle.text } });

const active = deriveThreadFeedPresentation(
Expand Down
35 changes: 33 additions & 2 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ type ThreadFeedEntryContent =
}
| {
readonly type: "run-fold";
readonly deferredWorkItemIds?: ReadonlyArray<string>;
readonly id: string;
readonly createdAt: string;
readonly runId: RunId;
Expand Down Expand Up @@ -1082,6 +1083,7 @@ function threadFeedActivityIsVisible(
}

interface ThreadFeedRunFold {
readonly deferredWorkItemIds?: ReadonlyArray<string>;
readonly runId: RunId;
readonly createdAt: string;
readonly hiddenEntryIds: ReadonlySet<string>;
Expand Down Expand Up @@ -1273,7 +1275,17 @@ function deriveThreadFeedRunFolds(
const duration = elapsedMs === null ? null : formatDuration(elapsedMs);
const interrupted =
latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled");
const deferredWorkItemIds = group.entries.flatMap((entry) =>
entry.type === "activity-group"
? entry.activities.flatMap((activity) =>
activity.projectedItem.item.deferredWork
? [String(activity.projectedItem.sourceItemId)]
: [],
)
: [],
);
foldsByAnchorId.set(firstHiddenEntry.id, {
...(deferredWorkItemIds.length > 0 ? { deferredWorkItemIds } : {}),
runId,
createdAt: firstHiddenEntry.createdAt,
hiddenEntryIds,
Expand All @@ -1286,6 +1298,23 @@ function deriveThreadFeedRunFolds(
: "Worked",
});
}
const covered = new Set(
[...foldsByAnchorId.values()].flatMap((fold) => [...fold.hiddenEntryIds]),
);
for (const entry of feed) {
if (entry.type !== "activity-group" || covered.has(entry.id)) continue;
const deferredWorkItemIds = entry.activities.flatMap((activity) =>
activity.projectedItem.item.deferredWork ? [String(activity.projectedItem.sourceItemId)] : [],
);
if (deferredWorkItemIds.length === 0) continue;
foldsByAnchorId.set(entry.id, {
runId: RunId.make(`deferred-work:${entry.id}`),
createdAt: entry.createdAt,
hiddenEntryIds: new Set([entry.id]),
label: "Earlier work",
deferredWorkItemIds,
});
}
return foldsByAnchorId;
}

Expand Down Expand Up @@ -1501,11 +1530,13 @@ export function deriveThreadFeedPresentation(
row.runId !== fold.runId ||
row.createdAt !== fold.createdAt ||
row.label !== fold.label ||
row.expanded !== expanded
row.expanded !== expanded ||
row.deferredWorkItemIds?.join("|") !== fold.deferredWorkItemIds?.join("|")
) {
row = {
type: "run-fold",
id: `run-fold:${fold.runId}`,
...(fold.deferredWorkItemIds ? { deferredWorkItemIds: fold.deferredWorkItemIds } : {}),
createdAt: fold.createdAt,
runId: fold.runId,
label: fold.label,
Expand Down Expand Up @@ -2189,7 +2220,7 @@ export function buildThreadFeed(
for (let index = restart?.row ?? 0; index < visibleTurnItems.length; index += 1) {
const row = visibleTurnItems[index]!;
const item = row.item;
if (turnItemIsWorkspacePreparation(item)) continue;
if (turnItemIsWorkspacePreparation(item) && !item.deferredWork) continue;
if (item.type === "todo_list" || item.type === "checkpoint") continue;
if (
item.type === "user_message" &&
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ export interface OrchestratorV2Shape {
cursor: string,
throughEntryId?: string,
conversationOnly?: boolean,
workMode?: "deferred" | "work",
) => Effect.Effect<OrchestrationV2ThreadHistoryPage, OrchestratorV2Error>;
readonly getTimelinePage: (
threadId: ThreadId,
Expand Down Expand Up @@ -13281,9 +13282,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
);
}),
getThreadHistoryPage: Effect.fn("OrchestratorV2.getThreadHistoryPage")(
function (threadId, cursor, throughEntryId, conversationOnly) {
function (threadId, cursor, throughEntryId, conversationOnly, workMode) {
return projectionStore
.getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly)
.getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly, workMode)
.pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })));
},
),
Expand Down
Loading
Loading