Skip to content
Open
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
35 changes: 26 additions & 9 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1503,7 +1503,11 @@ function renderFeedEntry(
readonly onCopyWorkRow: (rowId: string, value: string) => void;
readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void;
readonly onToggleWorkRow: (rowId: string, anchorKey?: string) => void;
readonly onToggleTurnFold: (runId: RunId) => void;
readonly onToggleTurnFold: (fold: {
readonly runId: RunId;
readonly expandKey: string;
readonly expanded: boolean;
}) => void;
readonly onPressPreview: (source: FilePreviewSource) => void;
readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
readonly markdownLinkHandlers: MarkdownLinkHandlers;
Expand Down Expand Up @@ -1531,7 +1535,7 @@ function renderFeedEntry(
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: entry.expanded }}
onPress={() => props.onToggleTurnFold(entry.runId)}
onPress={() => props.onToggleTurnFold(entry)}
hitSlop={4}
className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border-subtle px-2"
style={{
Expand Down Expand Up @@ -2168,7 +2172,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
readonly copiedRowId: string | null;
readonly expandedWorkGroups: Record<string, boolean>;
readonly expandedWorkRows: Record<string, boolean>;
readonly expandedTurnIds: ReadonlySet<RunId>;
readonly expandedTurnIds: ReadonlySet<string>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium threads/ThreadFeed.tsx:2171

When a steered run changes from running to interrupted, the completion effect adds only the bare runId, so the final fold keyed as ${runId}:${anchorId} stays collapsed and the You stopped after ... work remains hidden. Expand all fold keys for the interrupted run (or make the fold lookup treat the bare runId as a prefix match) instead of relying on the bare key matching only the pre-steer section.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadFeed.tsx around line 2171:

When a steered run changes from `running` to `interrupted`, the completion effect adds only the bare `runId`, so the final fold keyed as `${runId}:${anchorId}` stays collapsed and the `You stopped after ...` work remains hidden. Expand all fold keys for the interrupted run (or make the fold lookup treat the bare `runId` as a prefix match) instead of relying on the bare key matching only the pre-steer section.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[claude-opus-5-5] Responding on behalf of Guille

Fixed in 71d65b0. A bare run id now expands every fold of that run, so the interrupt effect (and web's citation and interrupt paths) open all sections again. A single-fold run still uses the run id as its key. Collapsing a fold that's open because of the run id clears that id too, so the fold can always be closed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

}>({
copiedRowId: null,
expandedWorkGroups: {},
Expand Down Expand Up @@ -2684,11 +2688,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
return;
}
setInteractionState((current) => {
if (!current.expandedTurnIds.has(previous.runId)) {
const previousKeys = [...current.expandedTurnIds].filter(
(key) => key === previous.runId || key.startsWith(`${previous.runId}:`),
);
if (previousKeys.length === 0) {
return current;
}
const next = new Set(current.expandedTurnIds);
next.delete(previous.runId);
for (const key of previousKeys) next.delete(key);
return { ...current, expandedTurnIds: next };
});
}, [props.latestRun]);
Expand Down Expand Up @@ -2814,14 +2821,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);

const onToggleTurnFold = useCallback(
(runId: RunId) => {
suspendEndScrollMaintenanceForDisclosure(`run-fold:${runId}`);
({
runId,
expandKey,
expanded,
}: {
readonly runId: RunId;
readonly expandKey: string;
readonly expanded: boolean;
}) => {
suspendEndScrollMaintenanceForDisclosure(`run-fold:${expandKey}`);
setInteractionState((current) => {
const next = new Set(current.expandedTurnIds);
if (next.has(runId)) {
// An interrupt expands the whole run by its id.
if (expanded) {
next.delete(expandKey);
next.delete(runId);
} else {
next.add(runId);
next.add(expandKey);
}
return { ...current, expandedTurnIds: next };
});
Expand Down
117 changes: 117 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,123 @@ describe("buildThreadFeed", () => {
]);
});

it("splits a settled run fold at a steer so work sits under the message it answered", () => {
const at = (second: number) => `2026-06-20T00:00:${String(second).padStart(2, "0")}.000Z`;
const steer = {
...userMessage(at(30)),
id: TurnItemId.make("item-steer"),
messageId: MessageId.make("message-steer"),
inputIntent: "steer" as const,
};
const feed = buildThreadFeed([
projected(userMessage(at(0)), 0),
projected(command(at(5)), 1),
projected(steer, 2),
projected({ ...command(at(35)), id: TurnItemId.make("item-command-2") }, 3),
projected(assistantMessage(at(50)), 4),
]);
const latestRun = {
runId,
status: "completed" as const,
startedAt: at(0),
completedAt: at(50),
};

const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
expect(
collapsed.map((entry) =>
entry.type === "run-fold"
? entry.label
: entry.type === "message"
? entry.message.text
: entry.type,
),
).toEqual(["Run checks", "Worked for 30s", "Run checks", "Worked for 20s", "Done"]);
expect(new Set(collapsed.map((entry) => entry.id)).size).toBe(collapsed.length);

const secondFold = collapsed.findLast((entry) => entry.type === "run-fold");
if (secondFold?.type !== "run-fold") throw new Error("Expected the steer fold");
const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([secondFold.expandKey]));
expect(expanded.map((entry) => (entry.type === "run-fold" ? entry.expanded : null))).toEqual([
null,
false,
null,
true,
null,
null,
]);

const expandedRun = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
expect(
expandedRun.flatMap((entry) => (entry.type === "run-fold" ? [entry.expanded] : [])),
).toEqual([true, true]);
});

it("gives the time after a steer without folded work to the next fold", () => {
const at = (second: number) => `2026-06-20T00:00:${String(second).padStart(2, "0")}.000Z`;
const steer = (id: string, second: number) => ({
...userMessage(at(second)),
id: TurnItemId.make(id),
messageId: MessageId.make(id),
inputIntent: "steer" as const,
});
const assistant = (id: string, second: number) => ({
...assistantMessage(at(second)),
id: TurnItemId.make(id),
messageId: MessageId.make(id),
});
const feed = buildThreadFeed([
projected(userMessage(at(0)), 0),
projected(command(at(5)), 1),
projected(steer("steer-1", 20), 2),
projected(assistant("first-answer", 25), 3),
projected(steer("steer-2", 30), 4),
projected({ ...command(at(35)), id: TurnItemId.make("item-command-2") }, 5),
projected(assistant("final-answer", 50), 6),
]);
const latestRun = {
runId,
status: "completed" as const,
startedAt: at(0),
completedAt: at(50),
};

expect(
deriveThreadFeedPresentation(feed, latestRun, new Set()).flatMap((entry) =>
entry.type === "run-fold" ? [entry.label] : [],
),
).toEqual(["Worked for 20s", "Worked for 30s"]);
});

it("does not split a run fold at a queued prompt", () => {
const at = (second: number) => `2026-06-20T00:00:${String(second).padStart(2, "0")}.000Z`;
const queued = {
...userMessage(at(30)),
id: TurnItemId.make("item-queued"),
messageId: MessageId.make("message-queued"),
runId: RunId.make("next-run"),
};
const feed = buildThreadFeed([
projected(userMessage(at(0)), 0),
projected(command(at(5)), 1),
projected(queued, 2),
projected({ ...command(at(35)), id: TurnItemId.make("item-command-2") }, 3),
projected(assistantMessage(at(50)), 4),
]);
const latestRun = {
runId,
status: "completed" as const,
startedAt: at(0),
completedAt: at(50),
};

expect(
deriveThreadFeedPresentation(feed, latestRun, new Set()).filter(
(entry) => entry.type === "run-fold",
),
).toHaveLength(1);
});

it("keeps an active run expanded and detects failures from completed command output", () => {
const failedCommand: OrchestrationV2TurnItem = {
...command(),
Expand Down
Loading
Loading