diff --git a/apps/api/src/thread-target.test.ts b/apps/api/src/thread-target.test.ts index 199eec9..72aca51 100644 --- a/apps/api/src/thread-target.test.ts +++ b/apps/api/src/thread-target.test.ts @@ -211,7 +211,13 @@ describe("threadSnapshot", () => { expect.objectContaining({ where: expect.objectContaining({ 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) => ({ + 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 () => { const run = { id: "run-failed", diff --git a/apps/api/src/thread-target.ts b/apps/api/src/thread-target.ts index 42b5b5d..3b8ce5b 100644 --- a/apps/api/src/thread-target.ts +++ b/apps/api/src/thread-target.ts @@ -491,6 +491,7 @@ export async function threadSnapshot( runId: currentRun.id, type: { in: [ + "thread.message.created", "thread.progress", "thread.subagent", "agent.tool.called", @@ -556,6 +557,7 @@ export async function threadSnapshot( runId: { in: activeRuns.map((run) => run.id) }, type: { in: [ + "thread.message.created", "thread.progress", "thread.subagent", "agent.tool.called", @@ -631,8 +633,12 @@ function messagesWithLiveEvents( persisted: ThreadSnapshot["messages"], liveEvents: Parameters[0], ) { + // Message events only clear the live text they promoted; the persisted rows already carry them. 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; } if (!message.id.startsWith("subagent:")) return false; diff --git a/apps/mobile/app/thread.tsx b/apps/mobile/app/thread.tsx index 4153099..efc92ba 100644 --- a/apps/mobile/app/thread.tsx +++ b/apps/mobile/app/thread.tsx @@ -23,6 +23,7 @@ import { type ComposerMention, cloudAgentHttpsUrl, deriveWorkspaceFocus, + foldRunNarration, isApprovalAskBlock, isRoutineEventMessage, isRunTerminalEvent, @@ -77,6 +78,7 @@ import { } from "../components/markdown-artifact-preview"; import { NativeSymbol } from "../components/native-symbol"; import { RoutineEventLine } from "../components/routine-event-line"; +import { RunSteps } from "../components/run-steps"; import { ThreadCard } from "../components/thread-card"; import type { MobileBot, @@ -343,7 +345,14 @@ function Thread() { ), [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 activePendingAttachments = attachmentsForThread(pendingAttachments, threadKey); const composerMentionTargets = useMemo( @@ -1484,6 +1493,7 @@ function Thread() { function renderMessageRow(message: MobileMessage, options?: { enableJump?: boolean }) { const actionProps = messageActionProps(message); const messageReactions = reactionView.reactions.get(message.id); + const foldedSteps = runNarration.steps.get(message.id); const activityBotId = !inGroup && message.role === "bot" && message.id.startsWith("progress:") ? (message.botId ?? botId) @@ -1543,6 +1553,7 @@ function Thread() { flexShrink: 1, }} > + {foldedSteps ? : null} + setOpen((current) => !current)} + hitSlop={8} + style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start" }} + > + + {label} + + {open ? ( + + {steps.map((step) => ( + + {step.blocks + .flatMap((block) => (block.kind === "text" ? [block.text] : [])) + .join("\n")} + + ))} + + ) : null} + + ); +} diff --git a/apps/web/e2e/run-steps.spec.ts b/apps/web/e2e/run-steps.spec.ts new file mode 100644 index 0000000..a836ff0 --- /dev/null +++ b/apps/web/e2e/run-steps.spec.ts @@ -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"); +}); diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index 6997118..46985b7 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -42,6 +42,11 @@ msgstr "{0, plural, one {# Modell} other {# Modelle}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" 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 #: src/components/heute/HeuteCoach.tsx msgid "{0, plural, one {# week} other {# weeks}}" diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index 634e39c..0236ddf 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -42,6 +42,11 @@ msgstr "{0, plural, one {# model} other {# models}}" msgid "{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 #: src/components/heute/HeuteCoach.tsx msgid "{0, plural, one {# week} other {# weeks}}" diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po index 74b2b28..0288d11 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -42,6 +42,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: steps.length +#: src/pages/shell/run-steps.tsx +msgid "{0, plural, one {# step} other {# steps}}" +msgstr "" + #. placeholder {0}: habit.streak #: src/components/heute/HeuteCoach.tsx msgid "{0, plural, one {# week} other {# weeks}}" diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po index 4e84a9c..4ab9c48 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -42,6 +42,11 @@ msgstr "{0, plural, one {# मॉडल} other {# मॉडल}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: steps.length +#: src/pages/shell/run-steps.tsx +msgid "{0, plural, one {# step} other {# steps}}" +msgstr "" + #. placeholder {0}: habit.streak #: src/components/heute/HeuteCoach.tsx msgid "{0, plural, one {# week} other {# weeks}}" diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po index af3d86f..ca2b666 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -42,6 +42,11 @@ msgstr "{0, plural, one {모델 #개} other {모델 #개}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: steps.length +#: src/pages/shell/run-steps.tsx +msgid "{0, plural, one {# step} other {# steps}}" +msgstr "" + #. placeholder {0}: habit.streak #: src/components/heute/HeuteCoach.tsx msgid "{0, plural, one {# week} other {# weeks}}" diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po index c09cadf..8350ce9 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -42,6 +42,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: steps.length +#: src/pages/shell/run-steps.tsx +msgid "{0, plural, one {# step} other {# steps}}" +msgstr "" + #. placeholder {0}: habit.streak #: src/components/heute/HeuteCoach.tsx msgid "{0, plural, one {# week} other {# weeks}}" diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po index b1abe9c..695c2e0 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -42,6 +42,11 @@ msgstr "{0, plural, one {# модель} few {# модели} many {# модел msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: steps.length +#: src/pages/shell/run-steps.tsx +msgid "{0, plural, one {# step} other {# steps}}" +msgstr "" + #. placeholder {0}: habit.streak #: src/components/heute/HeuteCoach.tsx msgid "{0, plural, one {# week} other {# weeks}}" diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po index 11b2fe2..efa9ae6 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -42,6 +42,11 @@ msgstr "{0, plural, one {# model} other {# model}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: steps.length +#: src/pages/shell/run-steps.tsx +msgid "{0, plural, one {# step} other {# steps}}" +msgstr "" + #. placeholder {0}: habit.streak #: src/components/heute/HeuteCoach.tsx msgid "{0, plural, one {# week} other {# weeks}}" diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po index 92e1a7e..c47f86f 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -42,6 +42,11 @@ msgstr "{0, plural, one {# 个模型} other {# 个模型}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: steps.length +#: src/pages/shell/run-steps.tsx +msgid "{0, plural, one {# step} other {# steps}}" +msgstr "" + #. placeholder {0}: habit.streak #: src/components/heute/HeuteCoach.tsx msgid "{0, plural, one {# week} other {# weeks}}" diff --git a/apps/web/src/pages/shell/run-steps.tsx b/apps/web/src/pages/shell/run-steps.tsx new file mode 100644 index 0000000..565ac04 --- /dev/null +++ b/apps/web/src/pages/shell/run-steps.tsx @@ -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 ( +
+ + {open ? ( +
    + {steps.map((step) => ( +
  1. + {step.blocks.map((block, index) => + block.kind === "text" ? ( + {block.text} + ) : null, + )} +
  2. + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/pages/shell/transcript.tsx b/apps/web/src/pages/shell/transcript.tsx index a19aa1d..aa458c4 100644 --- a/apps/web/src/pages/shell/transcript.tsx +++ b/apps/web/src/pages/shell/transcript.tsx @@ -5,6 +5,7 @@ import type { CardAct, MessageReaction, ThreadMessage } from "@rakazo/contracts" import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts"; import type { WorkspaceArtifact } from "@rakazo/core"; import { + foldRunNarration, isPeerReceiptBlocks, isRoutineEventMessage, isToolActivityBlock, @@ -56,6 +57,7 @@ import { } from "./message-cards"; import { previewMessageText } from "./message-preview"; import { RoutineEventLine } from "./routine-event-line"; +import { RunSteps } from "./run-steps"; import { FALLBACK_BOT_COLOR } from "./shell-types"; export const Transcript = memo(function Transcript({ @@ -137,6 +139,10 @@ export const Transcript = memo(function Transcript({ // Onboarding notes are worded in the UI language. [messages, i18n.locale], ); + const runNarration = useMemo( + () => foldRunNarration(reactionView.visibleMessages), + [reactionView.visibleMessages], + ); const transcriptSubordinate = encounterMode && voiceEncounterActive; const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined; const primaryRunStatus = workingBots[0]?.status; @@ -370,6 +376,8 @@ export const Transcript = memo(function Transcript({ > {reactionView.visibleMessages.map((message) => { 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); if (isRoutineEventMessage(message.blocks)) { return ( @@ -394,6 +402,7 @@ export const Transcript = memo(function Transcript({ data-quotable={message.id.includes(":") ? undefined : ""} className={peerReceipt ? "relative py-0.5" : "group/message relative hover:z-20"} > + {foldedSteps ? : null} {!peerReceipt && !message.id.startsWith("progress:") ? (