Skip to content
Merged
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
65 changes: 60 additions & 5 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,62 @@ describe("buildThreadFeed", () => {
]);
});

it("keeps imported V1 turns folded once the thread's first V2 run starts", () => {
const imported = <T extends OrchestrationV2TurnItem>(item: T, id: string) => ({
...item,
id: TurnItemId.make(id),
runId: null,
});
const presented = (start: OrchestrationV2TurnItem) =>
deriveThreadFeedPresentation(
buildThreadFeed(
[
imported(userMessage("2026-06-20T00:00:00.000Z"), "imported-prompt"),
imported(
{
...assistantMessage("2026-06-20T00:00:02.000Z"),
messageId: MessageId.make("update"),
},
"imported-update",
),
imported(command("2026-06-20T00:00:04.000Z"), "imported-ls"),
imported(
{
...assistantMessage("2026-06-20T00:00:08.000Z"),
messageId: MessageId.make("answer"),
},
"imported-answer",
),
start,
].map((item, position) => projected(item, position)),
),
{ runId, status: "running", startedAt: "2026-06-20T00:01:00.000Z", completedAt: null },
new Set(),
new Set(),
"2026-06-20T00:01:00.000Z",
)
.slice(0, 4)
.map((entry) => (entry.type === "message" ? entry.message.role : entry.type));

// A sent prompt and an automatic wake both start V2 work below the import.
expect(
presented({
...userMessage("2026-06-20T00:01:00.000Z"),
id: TurnItemId.make("new-prompt"),
messageId: MessageId.make("new-prompt"),
}),
).toEqual(["user", "assistant", "run-fold", "assistant"]);
expect(
presented({
...base("wake", "2026-06-20T00:01:00.000Z", 4),
type: "notification",
source: { kind: "background_task" },
outcome: "completed",
summary: "Background task finished",
}),
).toEqual(["user", "assistant", "run-fold", "assistant"]);
});

it("keeps a provider-native subagent's runless tool call live while it works", () => {
const startedAt = "2026-06-20T00:00:01.000Z";
const { exitCode: _exitCode, ...completedCommand } = command();
Expand Down Expand Up @@ -1116,9 +1172,10 @@ describe("buildThreadFeed", () => {
expect(presented.some((entry) => entry.type === "thinking")).toBe(false);
});

it("keeps a runless tail settled while a normal thread waits for its sent run", () => {
it("keeps a runless tail folded while a normal thread waits for its sent run", () => {
// Right after a send the local clock runs before the server creates the
// run, and the latest run may still be queued: neither is runless work.
// run, and the latest run may still be queued: neither is runless work,
// so the settled tail must not reopen and shift the feed.
const startedAt = "2026-06-20T00:00:05.000Z";
const feed = buildThreadFeed([
projected({ ...userMessage(), runId: null }, 0),
Expand All @@ -1135,9 +1192,7 @@ describe("buildThreadFeed", () => {
new Set(),
startedAt,
);
const toggle = presented.find((entry) => entry.type === "work-toggle");
expect(toggle).toMatchObject({ live: false, shimmer: false });
expect(presented.at(-1)?.type).toBe("thinking");
expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "thinking"]);
}
});

Expand Down
22 changes: 14 additions & 8 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,13 +972,14 @@ export function failedFeedRunIds(
}

/**
* A thread without runs (a provider-native subagent) folds each prompt's
* response like a run; `isWorking` keeps its latest response open.
* A prompt without a run (a provider-native subagent, or a turn imported from
* V1) folds its response like a run. `runlessWorkActive` keeps the latest
* runless response open; V2 work must not reopen imported turns.
*/
function deriveThreadFeedRunFolds(
feed: ReadonlyArray<ThreadFeedEntry>,
latestRun: ThreadFeedLatestRun | null,
isWorking: boolean,
runlessWorkActive: boolean,
): ReadonlyMap<string, ThreadFeedRunFold> {
const firstAssistantMessageIdByRun = new Map<RunId, string>();
const terminalAssistantMessageIdByRun = new Map<RunId, string>();
Expand All @@ -988,14 +989,15 @@ function deriveThreadFeedRunFolds(
RunId,
{ entries: ThreadFeedEntry[]; startBoundary: string | null }
>();
// Fold state is keyed by run, so each prompt of a runless thread lends its
// response a stable key of its own.
// Fold state is keyed by run, so each runless prompt lends its response a
// stable key of its own. Decide per prompt, not per thread: a V1 thread's
// first V2 run must not unfold every imported turn above it.
let runlessKey: RunId | null = null;
let pendingUserBoundary: string | null = null;
for (const entry of feed) {
if (entry.type === "message" && entry.message.role === "user") {
pendingUserBoundary = entry.message.createdAt;
runlessKey = latestRun === null ? RunId.make(`runless:${entry.id}`) : null;
runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null;
continue;
}
const runId =
Expand Down Expand Up @@ -1038,7 +1040,7 @@ function deriveThreadFeedRunFolds(
for (const [runId, group] of groupsByRunId) {
if (
runId === activeRunId ||
(isWorking && runId === runlessKey) ||
(runlessWorkActive && runId === runlessKey) ||
interruptedRunIds.has(runId) ||
failedRunIds.has(runId) ||
group.entries.some((entry) => entry.type === "message" && entry.message.streaming)
Expand Down Expand Up @@ -1161,7 +1163,11 @@ export function deriveThreadFeedPresentation(
const activeTailGroup = sourceFeed.at(-1);
const activeRunId = unsettledRunId(latestRun);
const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing";
const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun, isWorking);
const foldsByAnchorId = deriveThreadFeedRunFolds(
sourceFeed,
latestRun,
isWorking && runlessWorkActive,
);
const collapsedEntryIds = new Set<string>();
for (const fold of foldsByAnchorId.values()) {
if (!expandedRunIds.has(fold.runId)) {
Expand Down
70 changes: 70 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2230,6 +2230,76 @@ describe("deriveMessagesTimelineRows", () => {
]);
});

it("keeps imported V1 turns folded once the thread's first V2 run starts", () => {
const at = (second: number) => `2026-01-01T00:00:${String(second).padStart(2, "0")}Z`;
const message = (
id: string,
role: "user" | "assistant",
second: number,
runId: string | null = null,
) => ({
id,
kind: "message" as const,
createdAt: at(second),
message: {
id: id as never,
role,
text: id,
runId: runId as never,
createdAt: at(second),
updatedAt: at(second),
streaming: false,
},
});
const rows = (tail: ReadonlyArray<ReturnType<typeof message>>) =>
deriveMessagesTimelineRows({
timelineEntries: [
message("imported-prompt", "user", 0),
message("imported-update", "assistant", 4),
{
id: "imported-command",
kind: "work",
createdAt: at(5),
entry: {
id: "imported-command",
createdAt: at(5),
runId: null,
label: "Ran git",
command: "git status",
requestKind: "command",
tone: "tool" as const,
toolLifecycleStatus: "completed" as const,
},
},
message("imported-answer", "assistant", 8),
...tail,
],
latestRun: {
runId: "run-1" as never,
status: "running",
startedAt: at(20),
completedAt: null,
},
isWorking: true,
activeTurnStartedAt: at(20),
turnDiffSummaries: [],
supportsConversationRollback: false,
}).map((row) =>
row.kind === "message" ? `${row.message.role}:${row.message.id}` : row.kind,
);

// V2 work starts from a sent prompt, or with no new prompt (a wake or a resume).
expect(rows([message("new-prompt", "user", 20, "run-1")]).slice(0, 4)).toEqual([
"user:imported-prompt",
"turn-fold",
"assistant:imported-answer",
"user:new-prompt",
]);
const withoutPrompt = rows([]);
expect(withoutPrompt).toContain("turn-fold");
expect(withoutPrompt).not.toContain("assistant:imported-update");
});

it("shows a provider-native subagent's runless tools as live work while it works", () => {
const entries = (commandStatus: "inProgress" | "completed") => [
{
Expand Down
27 changes: 18 additions & 9 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,15 +850,17 @@ function failedTimelineRunIds(
/**
* Settled turns fold activity before their terminal assistant message behind
* a "Worked for ..." row. Ordinary trailing work joins the fold, while failures
* and work still in progress stay visible. A thread without runs (a
* provider-native subagent) folds each prompt's response the same way.
* and work still in progress stay visible. A prompt without a run (a
* provider-native subagent, or a turn imported from V1) folds its response
* the same way.
*/
function deriveTurnFolds(input: {
timelineEntries: ReadonlyArray<TimelineEntry>;
terminalAssistantMessageIds: ReadonlySet<string>;
latestRun: TimelineLatestRun | null;
unfoldedRunIds: ReadonlySet<RunId>;
isWorking: boolean;
/** Keeps the latest runless response open; V2 work must not reopen imported turns. */
runlessWorkActive: boolean;
}): ReadonlyMap<string, TurnFold> {
const interruptedRunIds = new Set<RunId>();
for (const entry of input.timelineEntries) {
Expand Down Expand Up @@ -888,8 +890,9 @@ function deriveTurnFolds(input: {
const groupsByRunId = new Map<RunId, TurnGroup>();
const runlessFailedKeys = new Set<RunId>();

// Fold state is keyed by run, so each prompt of a runless thread lends its
// response a stable key of its own.
// Fold state is keyed by run, so each runless prompt lends its response a
// stable key of its own. Decide per prompt, not per thread: a V1 thread's
// first V2 run must not unfold every imported turn above it.
let runlessKey: RunId | null = null;
let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null;
for (const [index, entry] of input.timelineEntries.entries()) {
Expand All @@ -898,7 +901,13 @@ function deriveTurnFolds(input: {
pendingBoundary = nextEntry
? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id }
: null;
runlessKey = input.latestRun === null ? RunId.make(`runless:${entry.id}`) : null;
const boundaryRunId =
entry.kind === "message"
? entry.message.runId
: entry.kind === "work"
? entry.entry.runId
: null;
runlessKey = boundaryRunId == null ? RunId.make(`runless:${entry.id}`) : null;
continue;
}
const runId = timelineEntryFoldRunId(entry, runlessKey);
Expand Down Expand Up @@ -940,7 +949,7 @@ function deriveTurnFolds(input: {
input.unfoldedRunIds.has(runId) ||
interruptedRunIds.has(runId) ||
runlessFailedKeys.has(runId) ||
(input.isWorking && runId === runlessKey)
(input.runlessWorkActive && runId === runlessKey)
) {
continue;
}
Expand Down Expand Up @@ -1229,12 +1238,13 @@ export function deriveMessagesTimelineRows(input: {
unsettledRunId,
isWorking: input.isWorking,
});
const runlessWorkActive = input.isWorking && input.runlessWorkActive === true;
const foldsByAnchorEntryId = deriveTurnFolds({
timelineEntries: timelineEntries,
terminalAssistantMessageIds,
latestRun: input.latestRun ?? null,
unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
isWorking: input.isWorking,
runlessWorkActive,
});
const collapsedEntryIds = new Set<string>();
for (const fold of foldsByAnchorEntryId.values()) {
Expand All @@ -1252,7 +1262,6 @@ export function deriveMessagesTimelineRows(input: {
}
}
}
const runlessWorkActive = input.isWorking && input.runlessWorkActive === true;
const runIdIsActiveResponse = (runId: RunId | null | undefined) =>
runId == null ? runlessWorkActive : activeVisualResponseRunIds.has(runId);
const workEntryIsInActiveRun = (entry: WorkLogEntry) =>
Expand Down
Loading