Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
110 changes: 86 additions & 24 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,84 @@ 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.loadWorkHistory, { reportFailure: false });
const anchorsKey = entry.deferredWorkItemIds?.join("\n") ?? "";
const [failure, setFailure] = useState<{ attempt: number; message: string } | null>(null);
const [retry, setRetry] = useState(0);
const error = anchorsKey && failure?.attempt === retry ? failure.message : null;
const loading = entry.expanded && anchorsKey !== "" && error === null;
const requestWork = useEffectEvent(
(anchorIds: string[], environmentId: EnvironmentId, threadId: ThreadId) =>
Promise.all(
anchorIds.map((anchorId) => loadWork({ environmentId, input: { threadId, anchorId } })),
),
);
// oxlint-disable-next-line t3code/react-effect-budget -- Expanded folds restore their deferred server history.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
useEffect(() => {
if (!entry.expanded || !anchorsKey) return;
let active = true;
void requestWork(anchorsKey.split("\n"), environmentId, threadId).then((results) => {
if (!active) return;
const failed = results.some(
(result) => result._tag === "Failure" || result.value._tag === "error",
);
setFailure(failed ? { attempt: retry, message: "Couldn't load work. Tap to retry." } : null);
});
return () => {
active = false;
};
}, [entry.expanded, anchorsKey, retry, environmentId, threadId]);
const toggle = () => {
if (error !== null && entry.expanded) {
setFailure(null);
setRetry((value) => value + 1);
} else {
setFailure(null);
onToggle(entry.runId);
}
};
return (
<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}
{loading ? " · Loading work..." : error ? ` · ${error}` : ""}
</Text>
<ThreadDisclosureChevron
expanded={entry.expanded}
collapsedDirection="right"
size={15}
tintColor={iconColor}
/>
</Pressable>
);
}

function renderFeedEntry(
info: { item: ThreadFeedRow; index: number },
props: Pick<
Expand Down Expand Up @@ -1303,30 +1381,14 @@ 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
entry={entry}
environmentId={props.environmentId}
threadId={props.threadId}
onToggle={props.onToggleTurnFold}
rowSizing={props.workRowSizing}
iconColor={iconSubtleColor}
/>
{entry.checkpointSummary ? (
<TurnDiffStat
checkpoint={entry.checkpointSummary}
Expand Down
29 changes: 29 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,35 @@ function assistantMessage(updatedAt = "2026-06-20T00:00:03.000Z") {
}

describe("buildThreadFeed", () => {
it("keeps deferred work attached to its collapsed run and drops the cursor after loading", () => {
const work = {
...command(),
deferredWork: { cursor: "work", itemCount: 100, lastOrdinal: 100 },
};
const rows = [
projected(userMessage(), 0),
projected(work, 1),
projected(assistantMessage(), 2),
];
const cache = createThreadFeedCache();
const presented = deriveThreadFeedPresentation(
buildThreadFeed(rows, { cache }),
null,
new Set(),
);
expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]);
expect(presented[1]).toMatchObject({ deferredWorkItemIds: [work.id], expanded: false });
const loaded = deriveThreadFeedPresentation(
buildThreadFeed([rows[0]!, projected(command(), 1), rows[2]!], { cache }),
null,
new Set([runId]),
);
expect(loaded.find((entry) => entry.type === "run-fold")).toMatchObject({ expanded: true });
expect(loaded.find((entry) => entry.type === "run-fold")).not.toHaveProperty(
"deferredWorkItemIds",
);
});

it("keeps async answers in question history instead of user bubbles", () => {
const requestId = RuntimeRequestId.make("question");
const question: OrchestrationV2TurnItem = {
Expand Down
16 changes: 15 additions & 1 deletion 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 @@ -1083,6 +1084,7 @@ export function threadFeedActivityIsVisible(
}

interface ThreadFeedRunFold {
readonly deferredWorkItemIds?: ReadonlyArray<string>;
readonly runId: RunId;
readonly createdAt: string;
readonly hiddenEntryIds: ReadonlySet<string>;
Expand Down Expand Up @@ -1274,7 +1276,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 Down Expand Up @@ -1502,11 +1514,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
6 changes: 4 additions & 2 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,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 @@ -384,6 +385,7 @@ export interface OrchestratorV2Shape {
readonly schemaVersion: number;
readonly snapshotSequence: number;
readonly projection: OrchestrationV2ThreadProjection;
readonly historyContextItems?: OrchestrationV2ThreadProjection["visibleTurnItems"];
},
OrchestratorV2Error
>;
Expand Down Expand Up @@ -12688,9 +12690,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
84 changes: 84 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ModelSelection,
type OrchestrationV2ProviderThread,
type OrchestrationV2TurnItem,
type OrchestrationV2ThreadHistoryPage,
type OrchestrationV2ThreadShellSnapshot,
NodeId,
PlanId,
Expand Down Expand Up @@ -1916,6 +1917,89 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
Array.from({ length: 1_000 }, (_, index) => `turn-item:bounded-sql-history:${index + 1}`),
);

yield* setNewestTurnType("command_execution");
yield* sql`
UPDATE orchestration_v2_projection_turn_items
SET run_id = 'run:bounded-sql-history:1',
payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:1')
WHERE thread_id = ${threadId}
`;
const longTurnSnapshot = yield* humanWindow();
assert.lengthOf(longTurnSnapshot.projection.visibleTurnItems, sqlPageLimit);
const longTurnPage = buildBoundedThreadProjection({
...longTurnSnapshot,
});
const promptId = TurnItemId.make("turn-item:bounded-sql-history:1");
assert.strictEqual(longTurnPage.projection.visibleTurnItems[0]?.sourceItemId, promptId);
const longTurnIds = new Set(
longTurnPage.projection.visibleTurnItems.map((row) => row.sourceItemId),
);
let longTurnCursor = longTurnPage.historyCursor;
for (let pages = 0; longTurnCursor !== null; pages += 1) {
assert.isBelow(pages, 20);
assert.notStrictEqual(decodeThreadHistoryCursor(longTurnCursor).si, promptId);
const page = yield* projectionStore.getThreadHistoryPage(threadId, longTurnCursor);
assert.strictEqual(page.items[0]?.sourceItemId, promptId);
for (const row of page.items) longTurnIds.add(row.sourceItemId);
longTurnCursor = page.nextCursor;
}
assert.strictEqual(longTurnIds.size, 1_000);
const conversation = yield* projectionStore.getThreadHistoryPage(
threadId,
longTurnPage.historyCursor!,
undefined,
true,
);
assert.strictEqual(conversation.items[0]?.sourceItemId, promptId);
const deferred = yield* projectionStore.getThreadSnapshotWindow(threadId, {
rowLimit: sqlPageLimit,
userTurnLimit: 10,
deferWork: true,
});
assert.lengthOf(deferred.projection.visibleTurnItems, 3);
assert.strictEqual(deferred.projection.visibleTurnItems[0]?.sourceItemId, promptId);
const anchor = deferred.projection.visibleTurnItems[1]!;
assert.strictEqual(anchor.item.deferredWork?.itemCount, 999);
const workIds = new Set<TurnItemId>();
let workCursor: string | null = anchor.item.deferredWork!.cursor;
for (let pages = 0; workCursor !== null; pages += 1) {
assert.isBelow(pages, 20);
const page: OrchestrationV2ThreadHistoryPage = yield* projectionStore.getThreadHistoryPage(
threadId,
workCursor,
anchor.sourceItemId,
false,
"work",
);
assert.isAtMost(page.items.length, 75);
for (const row of page.items) workIds.add(row.sourceItemId);
workCursor = page.nextCursor;
}
assert.strictEqual(workIds.size, 999);
yield* sql`
UPDATE orchestration_v2_projection_turn_items
SET type = 'command_execution', payload_json = json_set(payload_json, '$.type', 'command_execution')
WHERE thread_id = ${threadId} AND ordinal = 1
`;
const backgroundWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, {
rowLimit: sqlPageLimit,
userTurnLimit: 10,
deferWork: true,
});
assert.lengthOf(backgroundWindow.projection.visibleTurnItems, sqlPageLimit);
yield* sql`
UPDATE orchestration_v2_projection_turn_items
SET type = 'user_message', payload_json = json_set(payload_json, '$.type', 'user_message')
WHERE thread_id = ${threadId} AND ordinal = 1
`;
yield* sql`
UPDATE orchestration_v2_projection_turn_items
SET run_id = 'run:bounded-sql-history:' || ordinal,
payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:' || ordinal)
WHERE thread_id = ${threadId}
`;
yield* setNewestTurnType("user_message");

const tiedIds = [
TurnItemId.make("turn-item:bounded-sql-history:tie:b"),
TurnItemId.make("turn-item:bounded-sql-history:tie:a"),
Expand Down
Loading
Loading