From 8b50489dd665c3e3e7bd31d3b76635394e066530 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:30:22 +0000 Subject: [PATCH 1/6] fix(clients): zen shows the agent's reply while a question waits on you Zen hid every assistant message of a run that was still working, and a pending question keeps the run working. The reply written right before the question stayed hidden until the turn ended, so the user saw the question card alone, or a one-line preview on mobile, and had to press Stop to read what the agent wrote. While a question or approval waits on the user, zen now treats the run as answered: the latest reply shows in full, and the live one-line preview is dropped. Co-Authored-By: Claude Opus 5.5 --- .../mobile/src/features/threads/ThreadDetailScreen.tsx | 3 +++ apps/mobile/src/features/threads/ThreadFeed.tsx | 8 +++++++- apps/mobile/src/lib/threadActivity.test.ts | 10 +++++++++- apps/mobile/src/lib/threadActivity.ts | 8 ++++++-- .../src/components/chat/MessagesTimeline.logic.test.ts | 6 ++++++ apps/web/src/components/chat/MessagesTimeline.logic.ts | 7 ++++++- apps/web/src/components/chat/MessagesTimeline.tsx | 5 ++++- 7 files changed, 41 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3e7eec1449e26..2e5c74205183c 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1494,6 +1494,9 @@ function ThreadDetailBody( threadTitle={props.selectedThread.title} latestRun={props.activityRun} activeWorkStartedAt={props.activeWorkStartedAt} + awaitingUser={ + props.activePendingApproval !== null || props.activePendingUserInput !== null + } runlessWorkActive={props.runlessWorkActive ?? false} listRef={listRef} freeze={freeze} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index dbbcaa1fd9cff..547f4cd7f72d3 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -310,6 +310,8 @@ export interface ThreadFeedHistoryControls { export interface ThreadFeedProps { readonly toolCallVisibility?: ToolCallVisibility; + /** A question or approval waits on the user. */ + readonly awaitingUser?: boolean; readonly worktreeSetup?: WorktreeSetupCardProps | null; readonly setupWorkingStartedAt?: string | null; readonly queuedMessages: ReadonlyArray; @@ -2586,7 +2588,11 @@ function useThreadFeedPresentation( props.activeWorkStartedAt, props.runlessWorkActive ?? false, feedCache, - { toolCallVisibility: props.toolCallVisibility ?? "showAll", revealedMessageId }, + { + toolCallVisibility: props.toolCallVisibility ?? "showAll", + revealedMessageId, + awaitingUser: props.awaitingUser === true, + }, ), props.feed, props.queuedMessages, diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index d810563f72660..921d63dd6b9da 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1362,6 +1362,7 @@ describe("buildThreadFeed", () => { items: ReadonlyArray, toolCallVisibility: "hideSuccessful" | "hideAll" | "zen", latestRun: ThreadFeedLatestRun | null = null, + awaitingUser = false, ) => deriveThreadFeedPresentation( buildThreadFeed(items.map((item, index) => projected(item, index))), @@ -1371,7 +1372,7 @@ describe("buildThreadFeed", () => { latestRun ? at(1) : null, false, undefined, - { toolCallVisibility, revealedMessageId: null }, + { toolCallVisibility, revealedMessageId: null, awaitingUser }, ).map((row) => row.type === "activity-group" ? row.activities.map((activity) => activity.projectedItem.item.id).join("+") @@ -1412,6 +1413,13 @@ describe("buildThreadFeed", () => { "thinking:step", ]); expect(rows([user, step, passed], "zen", running)).toEqual(["user", "thinking:step"]); + // A question keeps the run working; the reply written before it is what the user answers. + expect(rows([user, step, passed, final], "zen", running)).not.toContain("final"); + expect(rows([user, step, passed, final], "zen", running, true)).toEqual([ + "user", + "final", + "thinking:", + ]); expect(rows(turn, "zen", { ...running, turnEnded: true })).toEqual(["user", "final"]); const nextRun = { ...running, runId: RunId.make("run-2") }; const followUp = { ...say("follow-up", 6, true), runId: nextRun.runId }; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 16f16b200c8d1..ec141e2c3c682 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1379,6 +1379,7 @@ function presentationRestart( previous.runlessWorkActive !== runlessWorkActive || previous.display.toolCallVisibility !== display.toolCallVisibility || previous.display.revealedMessageId !== display.revealedMessageId || + previous.display.awaitingUser !== display.awaitingUser || !setsEqual(previous.expandedRunIds, expandedRunIds) || !setsEqual(previous.expandedWorkGroupIds, expandedWorkGroupIds) ) { @@ -1527,6 +1528,8 @@ function liveAnswerPreview(feed: ReadonlyArray): string | null export interface ThreadFeedDisplay { readonly toolCallVisibility: ToolCallVisibility; readonly revealedMessageId: MessageId | null; + /** A pending question or approval: the run waits on the user, so zen shows what it wrote. */ + readonly awaitingUser?: boolean; } const DEFAULT_THREAD_FEED_DISPLAY: ThreadFeedDisplay = { @@ -1579,7 +1582,8 @@ export function deriveThreadFeedPresentation( const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing"; const activeRunId = unsettledRunId(latestRun); const zen = toolCallVisibility === "zen"; - const answering = isWorking && latestRun?.turnEnded !== true; + // A run that waits on a question still works, but the text before the question is its answer. + const answering = isWorking && latestRun?.turnEnded !== true && display.awaitingUser !== true; const zenAnswerIds = zen ? zenAnswerMessageIds(feed, start, answering, answering ? activeRunId : null) : null; @@ -1748,7 +1752,7 @@ export function deriveThreadFeedPresentation( thinkingRow( activeWorkStartedAt, activeRunId, - zen ? liveAnswerPreview(feed) : null, + zen && display.awaitingUser !== true ? liveAnswerPreview(feed) : null, latestRun?.providerStartingLabel, ), ); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 09cbfe4e7775a..b42ac3a21f3f7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3037,6 +3037,7 @@ describe("v2 run and attempt history", () => { timelineEntries: ReadonlyArray, toolCallVisibility: "hideSuccessful" | "hideAll" | "zen", latestRun?: typeof ended | typeof running, + awaitingUser = false, ) => deriveMessagesTimelineRows({ timelineEntries, @@ -3046,6 +3047,7 @@ describe("v2 run and attempt history", () => { turnDiffSummaries: [], supportsConversationRollback: false, toolCallVisibility, + awaitingUser, }).map((row) => row.id); const turn = [user, step, passed, failed, final]; @@ -3077,6 +3079,10 @@ describe("v2 run and attempt history", () => { "live-activity-row", ]); expect(liveAnswerPreview([user, step, passed])).toBe("step"); + // A question keeps the run working; the reply written before it is what the user answers. + expect(rows([user, step, passed, final], "zen", running)).not.toContain("final"); + expect(rows([user, step, passed, final], "zen", running, true)).toContain("final"); + expect(rows([user, step, passed, final], "zen", running, true)).not.toContain("step"); expect(rows(turn, "zen", ended)).toEqual(["user", "final"]); const followUp = message("follow-up", "user", 30); expect(rows([...turn, followUp], "zen", ended)).toEqual([ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c422656647863..f56f0629ddfed 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1451,13 +1451,16 @@ function timelineRowEntries( | "latestRun" | "runningRunId" | "pendingApprovalRequestIds" + | "awaitingUser" >, ) { const visibility = input.toolCallVisibility ?? "showAll"; const entries = input.timelineEntries; let shown = entries; if (visibility === "zen") { - const answering = input.isWorking && input.latestRun?.turnEnded !== true; + // A run that waits on a question still works, but the text before the question is its answer. + const answering = + input.isWorking && input.latestRun?.turnEnded !== true && input.awaitingUser !== true; const answeringRunId = answering ? deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null) : null; @@ -1540,6 +1543,8 @@ export function deriveMessagesTimelineRows(input: { worktreeSetup?: WorktreeSetupSnapshot | null; toolCallVisibility?: ToolCallVisibility; revealedMessageId?: MessageId | null; + /** A pending question or approval: the run waits on the user, so zen shows what it wrote. */ + awaitingUser?: boolean; }): MessagesTimelineRow[] { const timelineEntries = timelineRowEntries(input); const embeddedResultRunIds = new Set(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3bbea6a44eb21..75ca0735de41a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -750,6 +750,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ worktreeSetup, toolCallVisibility, revealedMessageId, + awaitingUser: awaitingUser !== null, }); const rows = useStableRows(rawRows, listIdentityKey); const matchedApprovalRequestIds = new Set(); @@ -1168,7 +1169,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ providerStartingLabel, latestRunId: latestRun?.runId ?? null, liveAnswerPreview: - isWorking && toolCallVisibility === "zen" ? liveAnswerPreview(timelineEntries) : null, + isWorking && toolCallVisibility === "zen" && awaitingUser === null + ? liveAnswerPreview(timelineEntries) + : null, }; // Mounted in both the empty and filled timeline so a thread's first turn // starts from a baseline instead of a fresh, silent region. From b20c90802db536a81bf24215b8b0fa8b344cfe29 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:20:51 +0000 Subject: [PATCH 2/6] fix(clients): zen keeps the reply above a question after you answer Zen showed the reply before a question only while the question was pending. Answering keeps the run working, so the reply vanished again the moment you submitted, and the answered question stood without its context. Mobile also missed secret and link requests, which carry no runtime request. Zen now keys on the feed: the reply an agent wrote right before a question, secret, or link request stays, pending or answered. Reasoning and the one tool call that asks may sit between them; narration followed by other work stays hidden. The live preview only shows text newer than the request. Approvals keep the base behavior: their card carries the command, and narration before each one would clutter zen. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadDetailScreen.tsx | 3 - .../src/features/threads/ThreadFeed.tsx | 8 +- apps/mobile/src/lib/threadActivity.test.ts | 51 +++++++++++- apps/mobile/src/lib/threadActivity.ts | 69 +++++++++++++--- .../chat/MessagesTimeline.logic.test.ts | 81 ++++++++++++++++++- .../components/chat/MessagesTimeline.logic.ts | 43 ++++++++-- .../src/components/chat/MessagesTimeline.tsx | 5 +- 7 files changed, 222 insertions(+), 38 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 2e5c74205183c..3e7eec1449e26 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1494,9 +1494,6 @@ function ThreadDetailBody( threadTitle={props.selectedThread.title} latestRun={props.activityRun} activeWorkStartedAt={props.activeWorkStartedAt} - awaitingUser={ - props.activePendingApproval !== null || props.activePendingUserInput !== null - } runlessWorkActive={props.runlessWorkActive ?? false} listRef={listRef} freeze={freeze} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 547f4cd7f72d3..dbbcaa1fd9cff 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -310,8 +310,6 @@ export interface ThreadFeedHistoryControls { export interface ThreadFeedProps { readonly toolCallVisibility?: ToolCallVisibility; - /** A question or approval waits on the user. */ - readonly awaitingUser?: boolean; readonly worktreeSetup?: WorktreeSetupCardProps | null; readonly setupWorkingStartedAt?: string | null; readonly queuedMessages: ReadonlyArray; @@ -2588,11 +2586,7 @@ function useThreadFeedPresentation( props.activeWorkStartedAt, props.runlessWorkActive ?? false, feedCache, - { - toolCallVisibility: props.toolCallVisibility ?? "showAll", - revealedMessageId, - awaitingUser: props.awaitingUser === true, - }, + { toolCallVisibility: props.toolCallVisibility ?? "showAll", revealedMessageId }, ), props.feed, props.queuedMessages, diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 921d63dd6b9da..be9f5a5ed17f4 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1362,7 +1362,6 @@ describe("buildThreadFeed", () => { items: ReadonlyArray, toolCallVisibility: "hideSuccessful" | "hideAll" | "zen", latestRun: ThreadFeedLatestRun | null = null, - awaitingUser = false, ) => deriveThreadFeedPresentation( buildThreadFeed(items.map((item, index) => projected(item, index))), @@ -1372,7 +1371,7 @@ describe("buildThreadFeed", () => { latestRun ? at(1) : null, false, undefined, - { toolCallVisibility, revealedMessageId: null, awaitingUser }, + { toolCallVisibility, revealedMessageId: null }, ).map((row) => row.type === "activity-group" ? row.activities.map((activity) => activity.projectedItem.item.id).join("+") @@ -1414,12 +1413,58 @@ describe("buildThreadFeed", () => { ]); expect(rows([user, step, passed], "zen", running)).toEqual(["user", "thinking:step"]); // A question keeps the run working; the reply written before it is what the user answers. + const asking: OrchestrationV2TurnItem = { + ...base("asking", at(6), 5), + type: "dynamic_tool", + toolName: "AskUserQuestion", + status: "completed", + input: {}, + output: {}, + }; + const questionRequestId = RuntimeRequestId.make("question"); + const question = (answered: boolean): OrchestrationV2TurnItem => ({ + ...base("question", at(7), 6), + type: "user_input_request", + requestId: questionRequestId, + questions: [], + ...(answered + ? { + questionAnswer: { + requestId: questionRequestId, + answers: { color: "Blue" }, + attachmentsByQuestionId: {}, + }, + } + : {}), + }); expect(rows([user, step, passed, final], "zen", running)).not.toContain("final"); - expect(rows([user, step, passed, final], "zen", running, true)).toEqual([ + expect(rows([user, step, passed, final, asking, question(false)], "zen", running)).toEqual([ "user", "final", "thinking:", ]); + // Answering keeps the run working, and the reply stays above the answered question. + expect(rows([user, step, passed, final, asking, question(true)], "zen", running)).toEqual([ + "user", + "final", + "question", + "thinking:", + ]); + // Narration followed by other work is not what the question asks about. + expect(rows([user, step, passed, asking, question(true)], "zen", running)).toEqual([ + "user", + "question", + "thinking:", + ]); + const later: OrchestrationV2TurnItem = { + ...base("later", at(8), 7), + type: "reasoning", + streaming: false, + text: "later", + }; + expect( + rows([user, step, passed, final, asking, question(true), later], "zen", running), + ).toEqual(["user", "final", "question", "thinking:later"]); expect(rows(turn, "zen", { ...running, turnEnded: true })).toEqual(["user", "final"]); const nextRun = { ...running, runId: RunId.make("run-2") }; const followUp = { ...say("follow-up", 6, true), runId: nextRun.runId }; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index ec141e2c3c682..165a2219be744 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1379,7 +1379,6 @@ function presentationRestart( previous.runlessWorkActive !== runlessWorkActive || previous.display.toolCallVisibility !== display.toolCallVisibility || previous.display.revealedMessageId !== display.revealedMessageId || - previous.display.awaitingUser !== display.awaitingUser || !setsEqual(previous.expandedRunIds, expandedRunIds) || !setsEqual(previous.expandedWorkGroupIds, expandedWorkGroupIds) ) { @@ -1451,6 +1450,48 @@ function startsResponse(entry: ThreadFeedEntry): boolean { ); } +/** A question, secret, or link request: the agent asks the user, pending or answered. */ +function activityAsksUser(activity: ThreadFeedActivity): boolean { + const type = activity.projectedItem.item.type; + return type === "user_input_request" || type === "secret_request" || type === "link_request"; +} + +/** + * The reply an agent wrote right before it asked the user something: zen keeps it, since the + * run still works while it waits and after the answer. Reasoning and the one tool call that + * asks may sit between them. + */ +function repliesBeforeAsks( + feed: ReadonlyArray, + start: number, +): ReadonlySet { + const ids = new Set(); + let reply: MessageId | null = null; + let workSince = 0; + for (let index = start; index < feed.length; index += 1) { + const entry = feed[index]!; + if (entry.type === "message") { + reply = entry.message.role === "assistant" ? entry.message.id : null; + workSince = 0; + continue; + } + if (entry.type !== "activity-group") { + workSince += 1; + continue; + } + for (const activity of entry.activities) { + if (activity.workEntry.itemType === "reasoning") continue; + if (activityAsksUser(activity)) { + if (reply !== null && workSince <= 1) ids.add(reply); + reply = null; + } else { + workSince += 1; + } + } + } + return ids; +} + function zenAnswerMessageIds( feed: ReadonlyArray, start: number, @@ -1460,6 +1501,7 @@ function zenAnswerMessageIds( const replies: Array<{ readonly message: ThreadFeedMessage; readonly index: number }> = []; const lastReplyByResponse = new Map(); const steerAnswers = new Set(answersBeforeSteers(feed)); + const askReplies = repliesBeforeAsks(feed, start); let userMessages = 0; let awaitingReply = false; let candidate: MessageId | null = null; @@ -1495,6 +1537,7 @@ function zenAnswerMessageIds( .filter( ({ message, index }) => steerAnswers.has(message.id) || + askReplies.has(message.id) || (terminal.has(message.id) && index < answeringFrom && (answeringRunId === null || message.runId !== answeringRunId)), @@ -1507,18 +1550,25 @@ function liveAnswerPreview(feed: ReadonlyArray): string | null for (let index = feed.length - 1; index >= 0; index -= 1) { const entry = feed[index]!; if (startsResponse(entry)) return null; - const texts = + // The reply before a question shows in full; the preview only shows newer text. + const texts: Array = entry.type === "message" ? entry.message.role === "assistant" ? [entry.message.text] : [] : entry.type === "activity-group" - ? entry.activities - .filter((activity) => activity.workEntry.itemType === "reasoning") - .map((activity) => activity.workEntry.detail ?? "") + ? entry.activities.flatMap((activity) => + activityAsksUser(activity) + ? [null] + : activity.workEntry.itemType === "reasoning" + ? [activity.workEntry.detail ?? ""] + : [], + ) : []; for (let textIndex = texts.length - 1; textIndex >= 0; textIndex -= 1) { - const line = compactWorkEntryText(liveThoughtLine(texts[textIndex]!)); + const text = texts[textIndex] as string | null; + if (text === null) return null; + const line = compactWorkEntryText(liveThoughtLine(text)); if (line !== "") return line; } } @@ -1528,8 +1578,6 @@ function liveAnswerPreview(feed: ReadonlyArray): string | null export interface ThreadFeedDisplay { readonly toolCallVisibility: ToolCallVisibility; readonly revealedMessageId: MessageId | null; - /** A pending question or approval: the run waits on the user, so zen shows what it wrote. */ - readonly awaitingUser?: boolean; } const DEFAULT_THREAD_FEED_DISPLAY: ThreadFeedDisplay = { @@ -1582,8 +1630,7 @@ export function deriveThreadFeedPresentation( const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing"; const activeRunId = unsettledRunId(latestRun); const zen = toolCallVisibility === "zen"; - // A run that waits on a question still works, but the text before the question is its answer. - const answering = isWorking && latestRun?.turnEnded !== true && display.awaitingUser !== true; + const answering = isWorking && latestRun?.turnEnded !== true; const zenAnswerIds = zen ? zenAnswerMessageIds(feed, start, answering, answering ? activeRunId : null) : null; @@ -1752,7 +1799,7 @@ export function deriveThreadFeedPresentation( thinkingRow( activeWorkStartedAt, activeRunId, - zen && display.awaitingUser !== true ? liveAnswerPreview(feed) : null, + zen ? liveAnswerPreview(feed) : null, latestRun?.providerStartingLabel, ), ); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b42ac3a21f3f7..0115f8c0a79e7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3037,7 +3037,6 @@ describe("v2 run and attempt history", () => { timelineEntries: ReadonlyArray, toolCallVisibility: "hideSuccessful" | "hideAll" | "zen", latestRun?: typeof ended | typeof running, - awaitingUser = false, ) => deriveMessagesTimelineRows({ timelineEntries, @@ -3047,7 +3046,6 @@ describe("v2 run and attempt history", () => { turnDiffSummaries: [], supportsConversationRollback: false, toolCallVisibility, - awaitingUser, }).map((row) => row.id); const turn = [user, step, passed, failed, final]; @@ -3080,9 +3078,84 @@ describe("v2 run and attempt history", () => { ]); expect(liveAnswerPreview([user, step, passed])).toBe("step"); // A question keeps the run working; the reply written before it is what the user answers. + const asking: TimelineEntry = { + id: "asking", + kind: "work", + createdAt: at(21), + entry: { + id: "asking", + createdAt: at(21), + runId, + label: "AskUserQuestion", + tone: "tool", + itemType: "dynamic_tool", + toolLifecycleStatus: "completed", + }, + }; + const questionRequestId = RuntimeRequestId.make("question"); + const question = (answered: boolean): TimelineEntry => ({ + id: "question", + kind: "work", + createdAt: at(22), + entry: { + id: "question", + createdAt: at(22), + runId, + label: answered ? "Answered questions" : "Input requested", + tone: "info", + itemType: "user_input_request", + ...(answered + ? { + questionAnswer: { + requestId: questionRequestId, + answers: { color: "Blue" }, + attachmentsByQuestionId: {}, + }, + } + : {}), + }, + }); + const secret: TimelineEntry = { + id: "secret", + kind: "event", + createdAt: at(22), + projectedItem: { item: { type: "secret_request", runId } } as never, + }; expect(rows([user, step, passed, final], "zen", running)).not.toContain("final"); - expect(rows([user, step, passed, final], "zen", running, true)).toContain("final"); - expect(rows([user, step, passed, final], "zen", running, true)).not.toContain("step"); + expect(rows([user, step, passed, final, asking, question(false)], "zen", running)).toContain( + "final", + ); + // Answering keeps the run working, and the reply stays above the answered question. + expect(rows([user, step, passed, final, asking, question(true)], "zen", running)).toEqual([ + "user", + "working-indicator-row", + "final", + "question", + "live-activity-row", + ]); + expect(rows([user, step, passed, final, asking, secret], "zen", running)).toContain("final"); + // Narration followed by other work is not what the question asks about. + expect(rows([user, step, passed, asking, question(true)], "zen", running)).not.toContain( + "step", + ); + expect(liveAnswerPreview([user, step, passed, final, asking, question(true)])).toBeNull(); + const later: TimelineEntry = { + id: "later", + kind: "work", + createdAt: at(23), + entry: { + id: "later", + createdAt: at(23), + runId, + label: "Thinking", + tone: "thinking", + itemType: "reasoning", + detail: "later", + }, + }; + expect(liveAnswerPreview([user, step, passed, final, asking, question(true), later])).toBe( + "later", + ); expect(rows(turn, "zen", ended)).toEqual(["user", "final"]); const followUp = message("follow-up", "user", 30); expect(rows([...turn, followUp], "zen", ended)).toEqual([ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f56f0629ddfed..8632f68b997df 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1425,10 +1425,44 @@ function deriveInFlightSteerAnswerIds(entries: ReadonlyArray): Re return ids; } +/** A question, secret, or link request: the agent asks the user, pending or answered. */ +function timelineEntryAsksUser(entry: TimelineEntry): boolean { + if (entry.kind === "work") return entry.entry.itemType === "user_input_request"; + if (entry.kind !== "event") return false; + const type = entry.projectedItem.item.type; + return type === "secret_request" || type === "link_request"; +} + +/** + * The reply an agent wrote right before it asked the user something: zen keeps it, since the + * run still works while it waits and after the answer. Reasoning and the one tool call that + * asks may sit between them. + */ +function deriveRepliesBeforeAsks(entries: ReadonlyArray): ReadonlySet { + const ids = new Set(); + let reply: string | null = null; + let workSince = 0; + for (const entry of entries) { + if (entry.kind === "work" && entry.entry.itemType === "reasoning") continue; + if (timelineEntryAsksUser(entry)) { + if (reply !== null && workSince <= 1) ids.add(reply); + reply = null; + } else if (entry.kind === "message") { + reply = entry.message.role === "assistant" ? entry.message.id : null; + workSince = 0; + } else { + workSince += 1; + } + } + return ids; +} + export function liveAnswerPreview(entries: ReadonlyArray): string | null { const boundary = lastResponseBoundaryIndex(entries); for (let index = entries.length - 1; index > boundary; index -= 1) { const entry = entries[index]!; + // The reply before a question shows in full; the preview only shows newer text. + if (timelineEntryAsksUser(entry)) return null; const text = entry.kind === "message" && entry.message.role === "assistant" ? entry.message.text @@ -1451,22 +1485,20 @@ function timelineRowEntries( | "latestRun" | "runningRunId" | "pendingApprovalRequestIds" - | "awaitingUser" >, ) { const visibility = input.toolCallVisibility ?? "showAll"; const entries = input.timelineEntries; let shown = entries; if (visibility === "zen") { - // A run that waits on a question still works, but the text before the question is its answer. - const answering = - input.isWorking && input.latestRun?.turnEnded !== true && input.awaitingUser !== true; + const answering = input.isWorking && input.latestRun?.turnEnded !== true; const answeringRunId = answering ? deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null) : null; const answeringFrom = answering ? lastResponseBoundaryIndex(entries) + 1 : entries.length; const terminalIds = deriveTerminalAssistantMessageIds(entries); const steerAnswerIds = deriveInFlightSteerAnswerIds(entries); + const askReplyIds = deriveRepliesBeforeAsks(entries); const retainedReplyIds = retainedAssistantReplyEntryIds(entries); shown = entries.filter((entry, index) => { if (entry.kind !== "message") { @@ -1477,6 +1509,7 @@ function timelineRowEntries( message.role !== "assistant" || message.id === input.revealedMessageId || steerAnswerIds.has(message.id) || + askReplyIds.has(message.id) || retainedReplyIds.has(entry.id) || (terminalIds.has(message.id) && index < answeringFrom && @@ -1543,8 +1576,6 @@ export function deriveMessagesTimelineRows(input: { worktreeSetup?: WorktreeSetupSnapshot | null; toolCallVisibility?: ToolCallVisibility; revealedMessageId?: MessageId | null; - /** A pending question or approval: the run waits on the user, so zen shows what it wrote. */ - awaitingUser?: boolean; }): MessagesTimelineRow[] { const timelineEntries = timelineRowEntries(input); const embeddedResultRunIds = new Set(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 75ca0735de41a..3bbea6a44eb21 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -750,7 +750,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ worktreeSetup, toolCallVisibility, revealedMessageId, - awaitingUser: awaitingUser !== null, }); const rows = useStableRows(rawRows, listIdentityKey); const matchedApprovalRequestIds = new Set(); @@ -1169,9 +1168,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ providerStartingLabel, latestRunId: latestRun?.runId ?? null, liveAnswerPreview: - isWorking && toolCallVisibility === "zen" && awaitingUser === null - ? liveAnswerPreview(timelineEntries) - : null, + isWorking && toolCallVisibility === "zen" ? liveAnswerPreview(timelineEntries) : null, }; // Mounted in both the empty and filled timeline so a thread's first turn // starts from a baseline instead of a fresh, silent region. From 1a47b9480530e22e4862eca933dbde452e9ac3a8 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:22:03 +0000 Subject: [PATCH 3/6] fix(clients): zen keeps the last reply before a request, past any tool calls Real threads often run a command or a tool, such as a wait or an unwatch, between the reply and the question. Limiting the gap to one tool call kept those replies hidden, so zen now keeps the last reply written before the request in that response, whatever work sits between them. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/threadActivity.test.ts | 3 ++- apps/mobile/src/lib/threadActivity.ts | 25 +++++-------------- .../chat/MessagesTimeline.logic.test.ts | 12 +++++++-- .../components/chat/MessagesTimeline.logic.ts | 13 +++------- 4 files changed, 22 insertions(+), 31 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index be9f5a5ed17f4..c98d8250163f6 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1450,9 +1450,10 @@ describe("buildThreadFeed", () => { "question", "thinking:", ]); - // Narration followed by other work is not what the question asks about. + // Agents often run a command between the reply and the question; the reply still shows. expect(rows([user, step, passed, asking, question(true)], "zen", running)).toEqual([ "user", + "step", "question", "thinking:", ]); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 165a2219be744..92f836b9828fa 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1457,9 +1457,9 @@ function activityAsksUser(activity: ThreadFeedActivity): boolean { } /** - * The reply an agent wrote right before it asked the user something: zen keeps it, since the - * run still works while it waits and after the answer. Reasoning and the one tool call that - * asks may sit between them. + * The last reply an agent wrote before it asked the user something: zen keeps it, since the run + * still works while it waits and after the answer. Agents often run a command or a tool between + * the reply and the request, so only a user message ends the search. */ function repliesBeforeAsks( feed: ReadonlyArray, @@ -1467,26 +1467,13 @@ function repliesBeforeAsks( ): ReadonlySet { const ids = new Set(); let reply: MessageId | null = null; - let workSince = 0; for (let index = start; index < feed.length; index += 1) { const entry = feed[index]!; if (entry.type === "message") { reply = entry.message.role === "assistant" ? entry.message.id : null; - workSince = 0; - continue; - } - if (entry.type !== "activity-group") { - workSince += 1; - continue; - } - for (const activity of entry.activities) { - if (activity.workEntry.itemType === "reasoning") continue; - if (activityAsksUser(activity)) { - if (reply !== null && workSince <= 1) ids.add(reply); - reply = null; - } else { - workSince += 1; - } + } else if (entry.type === "activity-group" && entry.activities.some(activityAsksUser)) { + if (reply !== null) ids.add(reply); + reply = null; } } return ids; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 0115f8c0a79e7..239ee1c49ddfa 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3134,8 +3134,16 @@ describe("v2 run and attempt history", () => { "live-activity-row", ]); expect(rows([user, step, passed, final, asking, secret], "zen", running)).toContain("final"); - // Narration followed by other work is not what the question asks about. - expect(rows([user, step, passed, asking, question(true)], "zen", running)).not.toContain( + // Agents often run a command between the reply and the question; the reply still shows. + expect(rows([user, step, passed, asking, question(true)], "zen", running)).toEqual([ + "user", + "working-indicator-row", + "step", + "question", + "live-activity-row", + ]); + // Only the last reply before the question shows. + expect(rows([user, step, passed, final, asking, question(true)], "zen", running)).not.toContain( "step", ); expect(liveAnswerPreview([user, step, passed, final, asking, question(true)])).toBeNull(); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 8632f68b997df..e5758c62b5b5d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1434,24 +1434,19 @@ function timelineEntryAsksUser(entry: TimelineEntry): boolean { } /** - * The reply an agent wrote right before it asked the user something: zen keeps it, since the - * run still works while it waits and after the answer. Reasoning and the one tool call that - * asks may sit between them. + * The last reply an agent wrote before it asked the user something: zen keeps it, since the run + * still works while it waits and after the answer. Agents often run a command or a tool between + * the reply and the request, so only a user message ends the search. */ function deriveRepliesBeforeAsks(entries: ReadonlyArray): ReadonlySet { const ids = new Set(); let reply: string | null = null; - let workSince = 0; for (const entry of entries) { - if (entry.kind === "work" && entry.entry.itemType === "reasoning") continue; if (timelineEntryAsksUser(entry)) { - if (reply !== null && workSince <= 1) ids.add(reply); + if (reply !== null) ids.add(reply); reply = null; } else if (entry.kind === "message") { reply = entry.message.role === "assistant" ? entry.message.id : null; - workSince = 0; - } else { - workSince += 1; } } return ids; From b803f37acf5a50d250a440278b64bf54a24baa5d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:51:32 +0000 Subject: [PATCH 4/6] test(clients): zen keeps the reply above secret and link requests Cover the secret and link request cards on web and mobile: the reply before them stays, and the live preview does not repeat it. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/threadActivity.test.ts | 22 +++++++++++++++++++ .../chat/MessagesTimeline.logic.test.ts | 11 +++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index c98d8250163f6..b51af91641d2c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1457,6 +1457,28 @@ describe("buildThreadFeed", () => { "question", "thinking:", ]); + // Secret and link requests ask the user too; their cards keep the reply above them. + const secret: OrchestrationV2TurnItem = { + ...base("secret", at(7), 6), + type: "secret_request", + label: "API key", + reason: "Needed to call the API", + secretStatus: "pending", + }; + const link: OrchestrationV2TurnItem = { + ...base("link", at(7), 6), + type: "link_request", + reason: "Needed to run on the VPS", + linkStatus: "pending", + }; + for (const request of [secret, link]) { + expect(rows([user, step, passed, final, asking, request], "zen", running)).toEqual([ + "user", + "final", + request.id, + "thinking:", + ]); + } const later: OrchestrationV2TurnItem = { ...base("later", at(8), 7), type: "reasoning", diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 239ee1c49ddfa..1e01f8140d2e9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3133,7 +3133,16 @@ describe("v2 run and attempt history", () => { "question", "live-activity-row", ]); - expect(rows([user, step, passed, final, asking, secret], "zen", running)).toContain("final"); + const link: TimelineEntry = { + id: "link", + kind: "event", + createdAt: at(22), + projectedItem: { item: { type: "link_request", runId } } as never, + }; + for (const request of [secret, link]) { + expect(rows([user, step, passed, final, asking, request], "zen", running)).toContain("final"); + expect(liveAnswerPreview([user, step, passed, final, asking, request])).toBeNull(); + } // Agents often run a command between the reply and the question; the reply still shows. expect(rows([user, step, passed, asking, question(true)], "zen", running)).toEqual([ "user", From 104ab85fa73387176b0929b43807a055e0922bfe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 05:49:55 +0000 Subject: [PATCH 5/6] chore(clients): drop a redundant cast and assertion in zen reply rule Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/threadActivity.ts | 2 +- apps/web/src/components/chat/MessagesTimeline.logic.test.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index e835062f78bb1..ed99efe0dcea2 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1629,7 +1629,7 @@ function liveAnswerPreview(feed: ReadonlyArray): string | null ) : []; for (let textIndex = texts.length - 1; textIndex >= 0; textIndex -= 1) { - const text = texts[textIndex] as string | null; + const text = texts[textIndex]!; if (text === null) return null; const line = compactWorkEntryText(liveThoughtLine(text)); if (line !== "") return line; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index c289eaccd1e55..e0b93cf3a78c0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3155,10 +3155,6 @@ describe("v2 run and attempt history", () => { "question", "live-activity-row", ]); - // Only the last reply before the question shows. - expect(rows([user, step, passed, final, asking, question(true)], "zen", running)).not.toContain( - "step", - ); expect(liveAnswerPreview([user, step, passed, final, asking, question(true)])).toBeNull(); const later: TimelineEntry = { id: "later", From bec73603982d86d1d42359e3d8869940e2576cec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 05:53:53 +0000 Subject: [PATCH 6/6] fix(mobile): keep zen's final-answer set mutable after the merge Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/threadActivity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index ed99efe0dcea2..0690d836f09c0 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1563,7 +1563,7 @@ function zenAnswerMessageIds( ): ReadonlySet { const replies: Array<{ readonly message: ThreadFeedMessage; readonly index: number }> = []; const lastReplyByResponse = new Map(); - const keptAnswers = finalAnswerIds(feed); + const keptAnswers = new Set(finalAnswerIds(feed)); const askReplies = repliesBeforeAsks(feed, start); let userMessages = 0; let awaitingReply = false;