Fold a run's step messages behind its latest message (#188)

* Fold a run's step messages behind its latest message

Long tool runs posted every step as its own bubble, so the answer drowned in
narration and a summary written before saving a file appeared twice. Web and
mobile now fold a run's text-only step messages behind one "N steps" line
above the run's latest message. The thread snapshot also stops replaying live
narration that was already posted, which showed steps again after a refresh.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Keep a run's last step visible above a card

A card such as the peer-message receipt has no words of its own, so folding
the narration into it hid everything the bot said. Steps now fold only into
a written answer or the live row; before a card the last step stays visible.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-09 19:32:21 +02:00 committed by GitHub
parent dd177f7ef5
commit 5cf9d679b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 400 additions and 3 deletions

View File

@ -211,7 +211,13 @@ describe("threadSnapshot", () => {
expect.objectContaining({ expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
type: { type: {
in: ["thread.progress", "thread.subagent", "agent.tool.called", "agent.tool.completed"], in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
"agent.tool.completed",
],
}, },
}), }),
}), }),
@ -230,6 +236,69 @@ describe("threadSnapshot", () => {
]); ]);
}); });
it("does not reload live narration that was already posted as a message", async () => {
const run = {
id: "run-1",
botId: "bot-1",
threadId: "thread-1",
taskId: "task-1",
status: "running",
trigger: "user",
modelProvider: null,
modelId: null,
error: null,
startedAt: null,
completedAt: null,
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const at = new Date("2026-08-23T00:00:00.000Z");
const event = (seq: number, type: string, payload: Record<string, unknown>) => ({
id: `event-${seq}`,
threadId: "thread-1",
botId: "bot-1",
seq,
type,
runId: "run-1",
payload,
createdAt: at,
});
const tx = {
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 6 }),
findMany: vi.fn().mockResolvedValue([
event(4, "thread.progress", { text: "Opening the page." }),
event(5, "thread.message.created", {
messageId: "message-1",
role: "bot",
blocks: [{ kind: "text", text: "Opening the page." }],
}),
event(6, "thread.progress", { text: "Writing the summary." }),
]),
},
run: { findFirst: botRunFindFirst([run]) },
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
} as unknown as PrismaClient;
const target = {
kind: "bot",
botId: "bot-1",
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
const snapshot = await threadSnapshot({ prisma }, target);
expect(snapshot.messages).toEqual([
expect.objectContaining({
id: "progress:run-1",
blocks: [{ kind: "progress", text: "Writing the summary." }],
}),
]);
});
it("returns the latest failed run so the client can show its error", async () => { it("returns the latest failed run so the client can show its error", async () => {
const run = { const run = {
id: "run-failed", id: "run-failed",

View File

@ -491,6 +491,7 @@ export async function threadSnapshot(
runId: currentRun.id, runId: currentRun.id,
type: { type: {
in: [ in: [
"thread.message.created",
"thread.progress", "thread.progress",
"thread.subagent", "thread.subagent",
"agent.tool.called", "agent.tool.called",
@ -556,6 +557,7 @@ export async function threadSnapshot(
runId: { in: activeRuns.map((run) => run.id) }, runId: { in: activeRuns.map((run) => run.id) },
type: { type: {
in: [ in: [
"thread.message.created",
"thread.progress", "thread.progress",
"thread.subagent", "thread.subagent",
"agent.tool.called", "agent.tool.called",
@ -631,8 +633,12 @@ function messagesWithLiveEvents(
persisted: ThreadSnapshot["messages"], persisted: ThreadSnapshot["messages"],
liveEvents: Parameters<typeof projectMessages>[0], liveEvents: Parameters<typeof projectMessages>[0],
) { ) {
// Message events only clear the live text they promoted; the persisted rows already carry them.
const live = projectMessages(liveEvents).filter((message) => { const live = projectMessages(liveEvents).filter((message) => {
if (message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")) { if (
message.id.startsWith("progress:") &&
message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")
) {
return true; return true;
} }
if (!message.id.startsWith("subagent:")) return false; if (!message.id.startsWith("subagent:")) return false;

View File

@ -23,6 +23,7 @@ import {
type ComposerMention, type ComposerMention,
cloudAgentHttpsUrl, cloudAgentHttpsUrl,
deriveWorkspaceFocus, deriveWorkspaceFocus,
foldRunNarration,
isApprovalAskBlock, isApprovalAskBlock,
isRoutineEventMessage, isRoutineEventMessage,
isRunTerminalEvent, isRunTerminalEvent,
@ -77,6 +78,7 @@ import {
} from "../components/markdown-artifact-preview"; } from "../components/markdown-artifact-preview";
import { NativeSymbol } from "../components/native-symbol"; import { NativeSymbol } from "../components/native-symbol";
import { RoutineEventLine } from "../components/routine-event-line"; import { RoutineEventLine } from "../components/routine-event-line";
import { RunSteps } from "../components/run-steps";
import { ThreadCard } from "../components/thread-card"; import { ThreadCard } from "../components/thread-card";
import type { import type {
MobileBot, MobileBot,
@ -343,7 +345,14 @@ function Thread() {
), ),
[snap?.messages], [snap?.messages],
); );
const visibleMessages = reactionView.visibleMessages; const runNarration = useMemo(
() => foldRunNarration(reactionView.visibleMessages),
[reactionView.visibleMessages],
);
const visibleMessages = useMemo(
() => reactionView.visibleMessages.filter((message) => !runNarration.hidden.has(message.id)),
[reactionView.visibleMessages, runNarration],
);
const latestMessageId = visibleMessages.at(-1)?.id ?? null; const latestMessageId = visibleMessages.at(-1)?.id ?? null;
const activePendingAttachments = attachmentsForThread(pendingAttachments, threadKey); const activePendingAttachments = attachmentsForThread(pendingAttachments, threadKey);
const composerMentionTargets = useMemo( const composerMentionTargets = useMemo(
@ -1484,6 +1493,7 @@ function Thread() {
function renderMessageRow(message: MobileMessage, options?: { enableJump?: boolean }) { function renderMessageRow(message: MobileMessage, options?: { enableJump?: boolean }) {
const actionProps = messageActionProps(message); const actionProps = messageActionProps(message);
const messageReactions = reactionView.reactions.get(message.id); const messageReactions = reactionView.reactions.get(message.id);
const foldedSteps = runNarration.steps.get(message.id);
const activityBotId = const activityBotId =
!inGroup && message.role === "bot" && message.id.startsWith("progress:") !inGroup && message.role === "bot" && message.id.startsWith("progress:")
? (message.botId ?? botId) ? (message.botId ?? botId)
@ -1543,6 +1553,7 @@ function Thread() {
flexShrink: 1, flexShrink: 1,
}} }}
> >
{foldedSteps ? <RunSteps steps={foldedSteps} /> : null}
<Pressable accessible={false} onLongPress={actionProps.onLongPress}> <Pressable accessible={false} onLongPress={actionProps.onLongPress}>
<MessageBubble <MessageBubble
botId={botId ?? snap?.members?.[0]?.botId ?? ""} botId={botId ?? snap?.members?.[0]?.botId ?? ""}

View File

@ -0,0 +1,52 @@
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
import type { MobileMessage } from "../lib/api";
import { useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
import { NativeSymbol } from "./native-symbol";
/** A run's earlier step messages, folded behind one line above its latest message. */
export function RunSteps({ steps }: { steps: readonly MobileMessage[] }) {
const tokens = useMobileTokens();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const label = steps.length === 1 ? t("1 step") : t("{count} steps", { count: steps.length });
return (
<View style={{ marginBottom: 6 }}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: open }}
onPress={() => setOpen((current) => !current)}
hitSlop={8}
style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start" }}
>
<NativeSymbol
ios={open ? "chevron.down" : "chevron.right"}
android={open ? "chevron-down" : "chevron-forward"}
size={12}
color={tokens.mutedForeground}
/>
<Text style={{ color: tokens.mutedForeground, fontSize: 13.5 }}>{label}</Text>
</Pressable>
{open ? (
<View
style={{
marginTop: 4,
gap: 6,
borderLeftWidth: 1,
borderLeftColor: tokens.border,
paddingLeft: 10,
}}
>
{steps.map((step) => (
<Text key={step.id} style={{ color: tokens.mutedForeground, fontSize: 14 }}>
{step.blocks
.flatMap((block) => (block.kind === "text" ? [block.text] : []))
.join("\n")}
</Text>
))}
</View>
) : null}
</View>
);
}

View File

@ -0,0 +1,24 @@
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } from "./helpers";
test("a long run shows its answer with the earlier steps folded", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `run-steps-${stamp}@rakazo.test`, "password12", "Steps");
await completeOnboarding(page);
await waitForChatComposer(page);
const composer = page.getByRole("combobox", { name: /Message|Nachricht/i });
await composer.fill("Show your steps");
await composer.press("Enter");
const transcript = page.getByTestId("transcript");
await expect(transcript.getByText("Here is the summary.")).toBeVisible({ timeout: 60_000 });
const steps = transcript.getByTestId("run-steps");
await expect(steps).toHaveCount(1);
await expect(transcript.getByText("Opening the page.")).toHaveCount(0);
await captureScreenshot(page, testInfo, "run-steps-folded");
await steps.getByRole("button", { name: "2 steps" }).click();
await expect(steps.getByText("Opening the page.")).toBeVisible();
await expect(steps.getByText("Reading the article.")).toBeVisible();
await captureScreenshot(page, testInfo, "run-steps-open");
});

View File

@ -42,6 +42,11 @@ msgstr "{0, plural, one {# Modell} other {# Modelle}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr "{0, plural, one {# weiteres wartet} other {# weitere warten}}" msgstr "{0, plural, one {# weiteres wartet} other {# weitere warten}}"
#. placeholder {0}: steps.length
#: src/pages/shell/run-steps.tsx
msgid "{0, plural, one {# step} other {# steps}}"
msgstr "{0, plural, one {# Schritt} other {# Schritte}}"
#. placeholder {0}: habit.streak #. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx #: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}" msgid "{0, plural, one {# week} other {# weeks}}"

View File

@ -42,6 +42,11 @@ msgstr "{0, plural, one {# model} other {# models}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "{0, plural, one {# more waiting} other {# more waiting}}"
#. placeholder {0}: steps.length
#: src/pages/shell/run-steps.tsx
msgid "{0, plural, one {# step} other {# steps}}"
msgstr "{0, plural, one {# step} other {# steps}}"
#. placeholder {0}: habit.streak #. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx #: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}" msgid "{0, plural, one {# week} other {# weeks}}"

View File

@ -42,6 +42,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr "" msgstr ""
#. placeholder {0}: steps.length
#: src/pages/shell/run-steps.tsx
msgid "{0, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: habit.streak #. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx #: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}" msgid "{0, plural, one {# week} other {# weeks}}"

View File

@ -42,6 +42,11 @@ msgstr "{0, plural, one {# मॉडल} other {# मॉडल}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr "" msgstr ""
#. placeholder {0}: steps.length
#: src/pages/shell/run-steps.tsx
msgid "{0, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: habit.streak #. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx #: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}" msgid "{0, plural, one {# week} other {# weeks}}"

View File

@ -42,6 +42,11 @@ msgstr "{0, plural, one {모델 #개} other {모델 #개}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr "" msgstr ""
#. placeholder {0}: steps.length
#: src/pages/shell/run-steps.tsx
msgid "{0, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: habit.streak #. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx #: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}" msgid "{0, plural, one {# week} other {# weeks}}"

View File

@ -42,6 +42,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr "" msgstr ""
#. placeholder {0}: steps.length
#: src/pages/shell/run-steps.tsx
msgid "{0, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: habit.streak #. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx #: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}" msgid "{0, plural, one {# week} other {# weeks}}"

View File

@ -42,6 +42,11 @@ msgstr "{0, plural, one {# модель} few {# модели} many {# модел
msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr "" msgstr ""
#. placeholder {0}: steps.length
#: src/pages/shell/run-steps.tsx
msgid "{0, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: habit.streak #. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx #: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}" msgid "{0, plural, one {# week} other {# weeks}}"

View File

@ -42,6 +42,11 @@ msgstr "{0, plural, one {# model} other {# model}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr "" msgstr ""
#. placeholder {0}: steps.length
#: src/pages/shell/run-steps.tsx
msgid "{0, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: habit.streak #. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx #: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}" msgid "{0, plural, one {# week} other {# weeks}}"

View File

@ -42,6 +42,11 @@ msgstr "{0, plural, one {# 个模型} other {# 个模型}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr "" msgstr ""
#. placeholder {0}: steps.length
#: src/pages/shell/run-steps.tsx
msgid "{0, plural, one {# step} other {# steps}}"
msgstr ""
#. placeholder {0}: habit.streak #. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx #: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}" msgid "{0, plural, one {# week} other {# weeks}}"

View File

@ -0,0 +1,39 @@
import { Plural } from "@lingui/react/macro";
import { ChatMarkdown } from "@rakazo/chat-ui/web";
import type { ThreadMessage } from "@rakazo/contracts";
import { ChevronRight } from "lucide-react";
import { useState } from "react";
/** A run's earlier step messages, folded behind one line above its latest message. */
export function RunSteps({ steps }: { steps: readonly ThreadMessage[] }) {
const [open, setOpen] = useState(false);
return (
<div data-testid="run-steps" className="mb-2">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
className="inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-ui-body-sm text-muted-foreground hover:text-foreground"
>
<ChevronRight
aria-hidden
className={`size-icon-sm transition-transform ${open ? "rotate-90" : ""}`}
/>
<Plural value={steps.length} one="# step" other="# steps" />
</button>
{open ? (
<ol className="mt-1 space-y-1.5 border-s border-border ps-3 text-ui-body-sm text-muted-foreground">
{steps.map((step) => (
<li key={step.id} data-message-id={step.id} dir="auto">
{step.blocks.map((block, index) =>
block.kind === "text" ? (
<ChatMarkdown key={index}>{block.text}</ChatMarkdown>
) : null,
)}
</li>
))}
</ol>
) : null}
</div>
);
}

View File

@ -5,6 +5,7 @@ import type { CardAct, MessageReaction, ThreadMessage } from "@rakazo/contracts"
import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts"; import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts";
import type { WorkspaceArtifact } from "@rakazo/core"; import type { WorkspaceArtifact } from "@rakazo/core";
import { import {
foldRunNarration,
isPeerReceiptBlocks, isPeerReceiptBlocks,
isRoutineEventMessage, isRoutineEventMessage,
isToolActivityBlock, isToolActivityBlock,
@ -56,6 +57,7 @@ import {
} from "./message-cards"; } from "./message-cards";
import { previewMessageText } from "./message-preview"; import { previewMessageText } from "./message-preview";
import { RoutineEventLine } from "./routine-event-line"; import { RoutineEventLine } from "./routine-event-line";
import { RunSteps } from "./run-steps";
import { FALLBACK_BOT_COLOR } from "./shell-types"; import { FALLBACK_BOT_COLOR } from "./shell-types";
export const Transcript = memo(function Transcript({ export const Transcript = memo(function Transcript({
@ -137,6 +139,10 @@ export const Transcript = memo(function Transcript({
// Onboarding notes are worded in the UI language. // Onboarding notes are worded in the UI language.
[messages, i18n.locale], [messages, i18n.locale],
); );
const runNarration = useMemo(
() => foldRunNarration(reactionView.visibleMessages),
[reactionView.visibleMessages],
);
const transcriptSubordinate = encounterMode && voiceEncounterActive; const transcriptSubordinate = encounterMode && voiceEncounterActive;
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined; const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
const primaryRunStatus = workingBots[0]?.status; const primaryRunStatus = workingBots[0]?.status;
@ -370,6 +376,8 @@ export const Transcript = memo(function Transcript({
> >
{reactionView.visibleMessages.map((message) => { {reactionView.visibleMessages.map((message) => {
if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null; if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null;
if (runNarration.hidden.has(message.id)) return null;
const foldedSteps = runNarration.steps.get(message.id);
const peerReceipt = isPeerReceiptBlocks(message.blocks); const peerReceipt = isPeerReceiptBlocks(message.blocks);
if (isRoutineEventMessage(message.blocks)) { if (isRoutineEventMessage(message.blocks)) {
return ( return (
@ -394,6 +402,7 @@ export const Transcript = memo(function Transcript({
data-quotable={message.id.includes(":") ? undefined : ""} data-quotable={message.id.includes(":") ? undefined : ""}
className={peerReceipt ? "relative py-0.5" : "group/message relative hover:z-20"} className={peerReceipt ? "relative py-0.5" : "group/message relative hover:z-20"}
> >
{foldedSteps ? <RunSteps steps={foldedSteps} /> : null}
{!peerReceipt && !message.id.startsWith("progress:") ? ( {!peerReceipt && !message.id.startsWith("progress:") ? (
<time <time
dateTime={message.createdAt} dateTime={message.createdAt}

View File

@ -261,6 +261,15 @@ export function inferScript(
const coach = inferCoachScript(prompt); const coach = inferCoachScript(prompt);
if (coach) return coach; if (coach) return coach;
// Said in passing: the bot sets the routine up without asking first. // Said in passing: the bot sets the routine up without asking first.
// A run that narrates two steps before its answer, as long tool runs do.
if (lower.includes("show your steps")) {
const listSchedules = { name: "schedule_list", args: {} };
return [
{ assistant: "Opening the page.", toolCalls: [listSchedules] },
{ assistant: "Reading the article.", toolCalls: [listSchedules] },
{ assistant: "Here is the summary.", complete: true },
];
}
if (lower.includes("every sunday i plan the week")) { if (lower.includes("every sunday i plan the week")) {
return [ return [
{ {

View File

@ -40,6 +40,7 @@ export * from "./oo-rig.js";
export * from "./quiet-hours.js"; export * from "./quiet-hours.js";
export * from "./response-bytes.js"; export * from "./response-bytes.js";
export * from "./routine-events.js"; export * from "./routine-events.js";
export * from "./run-narration.js";
export * from "./run-state.js"; export * from "./run-state.js";
export * from "./sandbox-command.js"; export * from "./sandbox-command.js";
export * from "./screen-lease.js"; export * from "./screen-lease.js";

View File

@ -0,0 +1,58 @@
import type { ThreadMessage } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import { foldRunNarration } from "./run-narration.js";
function bot(
id: string,
runId: string | undefined,
blocks: ThreadMessage["blocks"],
): ThreadMessage {
return {
id,
threadId: "thread-1",
seq: 0,
role: "bot",
blocks,
botId: "bot-1",
...(runId ? { runId } : {}),
createdAt: "2026-10-09T00:00:00.000Z",
};
}
const text = (value: string): ThreadMessage["blocks"] => [{ kind: "text", text: value }];
describe("foldRunNarration", () => {
it("folds a run's steps into its final message", () => {
const { steps, hidden } = foldRunNarration([
{ ...bot("user-1", undefined, text("Summarize the page")), role: "user" },
bot("step-1", "run-1", text("Opening the page.")),
bot("tools", "run-1", [{ kind: "steps", steps: [{ label: "Browser", count: 2 }] }]),
bot("step-2", "run-1", text("Reading it.")),
bot("final", "run-1", text("Here is the summary.")),
]);
expect(steps.get("final")?.map((message) => message.id)).toEqual(["step-1", "step-2"]);
expect([...hidden]).toEqual(["step-1", "step-2"]);
expect(steps.has("step-2")).toBe(false);
});
it("folds finished steps into the live message while the run works", () => {
const { steps } = foldRunNarration([
bot("step-1", "run-1", text("Opening the page.")),
bot("progress:run-1", "run-1", [{ kind: "progress", text: "Reading" }]),
]);
expect(steps.get("progress:run-1")?.map((message) => message.id)).toEqual(["step-1"]);
});
it("keeps other runs, cards and lone answers as they are", () => {
const { steps, hidden } = foldRunNarration([
bot("answer-1", "run-1", text("First answer.")),
bot("answer-2", "run-2", text("Second answer.")),
bot("step-1", "run-3", text("Checking.")),
bot("step-2", "run-3", text("Messaging the researcher.")),
bot("card", "run-3", [{ kind: "ask", text: "Which file?" }]),
bot("after-card", "run-3", text("Done.")),
]);
// The step right before a card stays visible and carries the earlier one.
expect(hidden).toEqual(new Set(["step-1"]));
expect([...steps.keys()]).toEqual(["step-2"]);
});
});

View File

@ -0,0 +1,74 @@
import type { MessageBlock } from "@rakazo/contracts";
import { isToolActivityBlock } from "./tool-activity.js";
type RunMessage = {
id: string;
role: string;
runId?: string;
botId?: string;
blocks: readonly MessageBlock[];
};
/** A bot message that only narrates a step: text, no card, ask, file or computer. */
export function isStepNarration(message: RunMessage): boolean {
return (
message.role === "bot" &&
Boolean(message.runId) &&
!message.id.includes(":") &&
message.blocks.length > 0 &&
message.blocks.every((block) => block.kind === "text")
);
}
/**
* Folds a run's step narration into the message that follows it, so a long run reads as its
* latest message with the earlier steps one tap away instead of a wall of bubbles.
* Returns, per message id, the steps folded into it; folded messages are listed in `hidden`.
*/
export function foldRunNarration<Message extends RunMessage>(
messages: readonly Message[],
): {
steps: Map<string, Message[]>;
hidden: Set<string>;
} {
const steps = new Map<string, Message[]>();
const hidden = new Set<string>();
const fold = (carrier: Message, folded: Message[]) => {
if (folded.length === 0) return;
steps.set(carrier.id, folded);
for (const step of folded) hidden.add(step.id);
};
// The run's last narration carries the ones before it.
const flush = (pending: Message[]) => {
const carrier = pending.at(-1);
if (carrier) fold(carrier, pending.slice(0, -1));
};
let pending: Message[] = [];
for (const message of messages) {
// Tool-only rows are not shown, so they neither carry nor break a run's steps.
if (message.blocks.every(isToolActivityBlock)) continue;
const first = pending[0];
const sameRun =
first !== undefined &&
message.role === "bot" &&
message.runId === first.runId &&
message.botId === first.botId;
if (!sameRun) {
flush(pending);
pending = [];
}
if (isStepNarration(message)) {
pending = [...pending, message];
} else if (message.id.includes(":") || message.blocks.some((block) => block.kind === "text")) {
// The live row or a written answer carries the steps before it.
fold(message, pending);
pending = [];
} else {
// A card alone never hides the bot's words, so the last step stays visible above it.
flush(pending);
pending = [];
}
}
flush(pending);
return { steps, hidden };
}