From 0d9d0b72b35570cfcd5ab956365ff09b1c504d0d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 22:00:12 -0700 Subject: [PATCH 1/7] feat(web): show automation messages as compact timeline rows Automation prompts are long, repeated runbooks that dominated the thread as full user bubbles. Render them as a work-log style row labeled with the scheduled task title (falling back to the prompt's first line) that expands to the full prompt, a link to the automation, and a copy button. Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/MessagesTimeline.tsx | 89 ++++++++++++++----- 1 file changed, 68 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1e591b5683638..11dd4f2e8a173 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -40,6 +40,8 @@ import { useAtomValue } from "@effect/atom-react"; import { environmentThreadDetails } from "../../state/threads"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { Link } from "@tanstack/react-router"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; @@ -150,6 +152,7 @@ import { Code2Icon, XIcon, ZapIcon, + CalendarClockIcon, RotateCcwIcon, } from "lucide-react"; import { ChevronDown, ChevronRight, Link as LinkIconNode } from "lucide"; @@ -2155,11 +2158,75 @@ function UserMessageRow({ row }: { row: Extract ctx.onOpenThread(ThreadId.make(threadId))} /> + ) : resolveUserMessagePresentation(row.message).attribution === "automation" ? ( + ) : ( ); } +/** Automation prompts are long, repeated runbooks; a work-log row keeps them out of the way. */ +function AutomationMessageRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + const [expanded, setExpanded] = useState(false); + const revealRef = findRevealRef(() => setExpanded(true)); + const { text, scheduledTaskId } = resolveUserMessagePresentation(row.message); + const tasksQuery = useEnvironmentQuery( + scheduledTaskId + ? serverEnvironment.scheduledTasksLive({ + environmentId: ctx.activeThreadEnvironmentId, + input: {}, + }) + : null, + ); + const taskTitle = tasksQuery.data?.tasks.find((task) => task.id === scheduledTaskId)?.title; + const summary = taskTitle ?? text.trim().split(/\r?\n/, 1)[0] ?? ""; + + return ( + +
+ setExpanded((value) => !value)} + icon={} + label={ + + Automation + {summary} + + } + trailing={ + + } + /> + {expanded ? ( + +
+ +
+
+ {scheduledTaskId ? ( + + Open automation + + ) : null} + +
+
+ ) : null} +
+
+ ); +} + function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const { onImageExpand, onFileOpen } = ctx; @@ -2292,27 +2359,7 @@ function UserTimelineRow({ row }: { row: Extract - {userMessage.attribution === "automation" ? ( -

- {userMessage.scheduledTaskId ? ( - - Sent by automation - - ) : ( - "Sent by automation" - )} -

- ) : userMessage.attribution === "t3code" ? ( + {userMessage.attribution === "t3code" ? (

Date: Wed, 21 Oct 2026 05:11:26 +0000 Subject: [PATCH 2/7] test(web): takeover tests for automation timeline rows These are the takeover's tests for the automation work-log row: the title/first-line summary with the automation link and copy, ordinary messages unchanged, find revealing a folded prompt, and edit-from-here staying available. The last two fail on the current implementation. Co-Authored-By: Claude Opus 5.5 --- .../chat/MessagesTimeline.browser.test.tsx | 100 +++++++++++++++++- 1 file changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index df846a5e7bf5a..bebd8c6329123 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentId, MessageId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, RunId, ScheduledTaskId } from "@t3tools/contracts"; import type { LegendListRef } from "@legendapp/list/react"; import { createRef, type ComponentProps, type ReactElement } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -9,13 +9,16 @@ import { act, render } from "../../../test/render"; import { MessagesTimeline, remarkThoughtPreview } from "./MessagesTimeline"; import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath"; -const pullRequestQuery = vi.hoisted(() => ({ error: "Pull request not found" })); +const environmentQuery = vi.hoisted(() => ({ + error: "Pull request not found", + data: null as unknown, +})); vi.mock(import("~/state/query"), async (original) => ({ ...(await original()), useEnvironmentQuery: (() => ({ - data: null, + data: environmentQuery.data, dataUpdatedAt: 0, - error: pullRequestQuery.error, + error: environmentQuery.error, failure: null, isPending: false, isSuccess: false, @@ -116,6 +119,13 @@ async function click(element: HTMLElement) { await act(async () => element.click()); } +function setup({ scheduledTasks = [] as ReadonlyArray<{ id: string; title: string }> } = {}) { + environmentQuery.data = { tasks: scheduledTasks }; +} + +const TASK_ID = ScheduledTaskId.make("task-1"); +const AUTOMATION_PROMPT = "Triage the inbox\n\nLabel every new issue and close duplicates."; + describe("MessagesTimeline messages", () => { it("preserves XML-like tags and comparisons in user messages as text", async () => { const source = [ @@ -163,6 +173,88 @@ describe("MessagesTimeline messages", () => { }); }); +describe("MessagesTimeline automation prompts", () => { + it("folds an automation prompt into a work-log row titled by its task", async () => { + setup({ scheduledTasks: [{ id: TASK_ID, title: "Nightly triage" }] }); + await using screen = await renderTimeline([ + message(AUTOMATION_PROMPT, { scheduledTaskId: TASK_ID }), + ]); + const row = screen.button(/^Automation/); + expect(row.textContent).toContain("Nightly triage"); + expect(row.getAttribute("aria-expanded")).toBe("false"); + expect(screen.text()).not.toContain("close duplicates"); + + await click(row); + expect(screen.text()).toContain("Label every new issue and close duplicates."); + const link = [...screen.container.querySelectorAll("a")].find( + (anchor) => anchor.textContent === "Open automation", + ); + expect(link?.getAttribute("href")).toContain(`taskId=${TASK_ID}`); + expect(screen.buttons(/copy/i)).not.toHaveLength(0); + }); + + it("falls back to the prompt's first line without a task title", async () => { + setup(); + await using screen = await renderTimeline([ + message(AUTOMATION_PROMPT, { scheduledTaskId: TASK_ID }), + ]); + expect(screen.button(/^Automation/).textContent).toContain("Triage the inbox"); + }); + + it("leaves ordinary user messages as full bubbles", async () => { + setup(); + await using screen = await renderTimeline([message(AUTOMATION_PROMPT)]); + expect(screen.buttons(/^Automation/)).toHaveLength(0); + expect(screen.text()).toContain("Label every new issue and close duplicates."); + }); + + it("opens a folded automation prompt when find selects text inside it", async () => { + setup(); + await using screen = await renderTimeline( + [message(AUTOMATION_PROMPT, { scheduledTaskId: TASK_ID })], + { + findExpanded: true, + findQuery: "duplicates", + activeFindMatch: { entryId: "entry-message-1", runId: null, occurrence: 0 }, + }, + ); + await vi.waitFor(() => + expect(screen.button(/^Automation/).getAttribute("aria-expanded")).toBe("true"), + ); + }); + + it("keeps edit-from-here on automation prompts", async () => { + setup(); + const runId = RunId.make("run-1"); + await using screen = await renderTimeline( + [ + message( + AUTOMATION_PROMPT, + { scheduledTaskId: TASK_ID, runId, inputIntent: "turn_start" }, + "user-1", + ), + message("Done", { role: "assistant", runId }, "assistant-1"), + ], + { + supportsConversationRollback: true, + threadStartCheckpointReady: true, + turnDiffSummaries: [ + { + runId, + checkpointTurnCount: 1, + checkpointRef: "checkpoint-1" as never, + status: "ready", + files: [], + assistantMessageId: MessageId.make("assistant-1"), + completedAt: CREATED_AT, + }, + ], + }, + ); + expect(screen.buttons("Edit from here")).toHaveLength(1); + }); +}); + describe("compact reasoning math", () => { it("keeps formulas while flattening compact reasoning previews", () => { const tree = parseMarkdownWithMath("## Reasoning\n\nUse **norm** $|x|$ and \\[x^2\\]"); From 6905e21ce5154854a088fd25e42c7ba870e8086b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 05:15:06 +0000 Subject: [PATCH 3/7] fix(web): keep find, edit-from-here and steer state on automation rows A folded automation prompt now stays mounted but hidden while find is open, with the reveal listener on the hidden fold, so find can select text inside it. The row keeps the actions the message bubble had: edit-from-here, the steer marker, a failed or rejected status, and copy prompt link. Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/MessagesTimeline.tsx | 104 ++++++++++++------ 1 file changed, 72 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 11dd4f2e8a173..00c535009f14e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2182,28 +2182,51 @@ function AutomationMessageRow({ row }: { row: Extract task.id === scheduledTaskId)?.title; const summary = taskTitle ?? text.trim().split(/\r?\n/, 1)[0] ?? ""; + const revertTurnCount = row.revertTurnCount; + const promptLink = promptLinkFor(text); + // Find reads the DOM, so a folded prompt stays mounted but hidden while find is open. + const bodyMounted = expanded || ctx.findActive; + return ( -

- setExpanded((value) => !value)} - icon={} - label={ - - Automation - {summary} - - } - trailing={ - - } - /> - {expanded ? ( +
+
+ setExpanded((value) => !value)} + icon={} + label={ + + Automation + {summary} + + } + trailing={ + + } + /> +
+ {row.message.inputIntent === "steer" || + row.message.inputIntent === "promoted_queued_to_steer" ? ( + + ) : null} + + {typeof revertTurnCount === "number" && ( +
+ +
+ )} +
+ {bodyMounted ? ( + // `beforematch` does not bubble, so the reveal listener sits on the hidden fold itself. + +
+ ) : null} ); } +function UserMessageProjectedStatus({ row }: { row: Extract }) { + const status = row.projectedItem?.item.status; + if ( + status === undefined || + status === "completed" || + status === "pending" || + status === "waiting" + ) + return null; + return ( +
+ + {status} + +
+ ); +} + function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const { onImageExpand, onFileOpen } = ctx; @@ -2501,16 +2550,7 @@ function UserTimelineRow({ row }: { row: Extract - {row.projectedItem && - row.projectedItem.item.status !== "completed" && - row.projectedItem.item.status !== "pending" && - row.projectedItem.item.status !== "waiting" ? ( -
- - {row.projectedItem.item.status} - -
- ) : null} +
From c088ecb5052249c16e648bb64bc6fb61b089c6a8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 05:23:10 +0000 Subject: [PATCH 4/7] fix(web): reveal long automation prompts during find --- .../chat/MessagesTimeline.browser.test.tsx | 25 ++++++++++++------- .../src/components/chat/MessagesTimeline.tsx | 2 +- apps/web/src/components/chat/WorkLog.tsx | 7 +++++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index bebd8c6329123..025c0c686a052 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -8,6 +8,7 @@ import "~/index.css"; import { act, render } from "../../../test/render"; import { MessagesTimeline, remarkThoughtPreview } from "./MessagesTimeline"; import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath"; +import { collectThreadFindRanges } from "./threadFindHighlights"; const environmentQuery = vi.hoisted(() => ({ error: "Pull request not found", @@ -208,19 +209,25 @@ describe("MessagesTimeline automation prompts", () => { expect(screen.text()).toContain("Label every new issue and close duplicates."); }); - it("opens a folded automation prompt when find selects text inside it", async () => { + it("reveals a find match beyond the folded automation prompt's initial viewport", async () => { setup(); - await using screen = await renderTimeline( - [message(AUTOMATION_PROMPT, { scheduledTaskId: TASK_ID })], - { - findExpanded: true, - findQuery: "duplicates", - activeFindMatch: { entryId: "entry-message-1", runId: null, occurrence: 0 }, - }, - ); + const prompt = `${"Inspect the next item.\n\n".repeat(16)}Close duplicates.`; + await using screen = await renderTimeline([message(prompt, { scheduledTaskId: TASK_ID })], { + findExpanded: true, + findQuery: "duplicates", + activeFindMatch: { entryId: "entry-message-1", runId: null, occurrence: 0 }, + }); await vi.waitFor(() => expect(screen.button(/^Automation/).getAttribute("aria-expanded")).toBe("true"), ); + const { range } = collectThreadFindRanges(screen.container, "duplicates")[0]!; + const details = screen.container.querySelector("[data-thread-find-text]")!.parentElement!; + expect(range.getBoundingClientRect().top - details.getBoundingClientRect().top).toBeGreaterThan( + 384, + ); + expect(range.getBoundingClientRect().bottom).toBeLessThanOrEqual( + details.getBoundingClientRect().bottom, + ); }); it("keeps edit-from-here on automation prompts", async () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 00c535009f14e..4cf580211a068 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2227,7 +2227,7 @@ function AutomationMessageRow({ row }: { row: Extract