diff --git a/apps/mobile/src/components/ui/AppSymbol.tsx b/apps/mobile/src/components/ui/AppSymbol.tsx index 7e5e95da3beb5..16a0f55d0a7fc 100644 --- a/apps/mobile/src/components/ui/AppSymbol.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.tsx @@ -9,6 +9,7 @@ import IconAlertCircle from "@tabler/icons-react-native/IconAlertCircle"; import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; import IconApps from "@tabler/icons-react-native/IconApps"; import IconArchive from "@tabler/icons-react-native/IconArchive"; +import IconCalendarClock from "@tabler/icons-react-native/IconCalendarClock"; import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; import IconArrowDown from "@tabler/icons-react-native/IconArrowDown"; import IconArrowForwardUp from "@tabler/icons-react-native/IconArrowForwardUp"; @@ -178,6 +179,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "circle.dashed": IconCircleDashed, "circle.inset.filled": IconCircleDot, "circle.dashed.inset.filled": IconCircleDot, + "calendar.badge.clock": IconCalendarClock, clock: IconClock, timer: IconClock, ticket: IconTicket, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 77729684a792e..8c07123dd3ae8 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -40,6 +40,7 @@ import { type MessageId, type OrchestrationV2ProjectedTurnItem, type RunId, + type ScheduledTaskId, } from "@t3tools/contracts"; import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; @@ -236,6 +237,8 @@ import { } from "@t3tools/client-runtime/workspace-basename-lookup"; import { useLiveThreadLinkLabels } from "../../state/entities"; import { useThreadSelection } from "../../state/use-thread-selection"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; import * as Option from "effect/Option"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -983,6 +986,125 @@ function AgentMessageAttribution(props: { ); } +/** Automation prompts are long, repeated runbooks; a folded bubble keeps them out of the way. */ +function ThreadAutomationMessageRow(props: { + readonly environmentId: EnvironmentId; + readonly text: string; + readonly context?: OrchestrationMessageContext; + readonly scheduledTaskId: ScheduledTaskId | undefined; + readonly timestampLabel: string; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly bubbleColor: ColorValue; + readonly bubbleMaxWidth: number | "85%"; + readonly contentMaxWidth: number; + readonly iconSubtleColor: ColorValue; + readonly markdownStyles: MarkdownStyleSet; + readonly reviewCommentColors: ReviewCommentColors; + readonly skills?: ReadonlyArray; + readonly linkHandlers: MarkdownLinkHandlers; + readonly renderImage: MarkdownImageRenderer; +}) { + const navigation = useNavigation(); + const tasks = useEnvironmentQuery( + props.scheduledTaskId + ? serverEnvironment.scheduledTasksLive({ environmentId: props.environmentId, input: {} }) + : null, + ); + const taskTitle = tasks.data?.tasks.find((task) => task.id === props.scheduledTaskId)?.title; + const summary = taskTitle ?? props.text.trim().split(/\r?\n/, 1)[0] ?? ""; + + return ( + + + { + void Haptics.selectionAsync(); + props.onToggle(); + }} + > + + + Automation + + + {summary} + + + + {props.expanded ? ( + + + + + + ) : null} + + {props.expanded ? ( + + {props.scheduledTaskId ? ( + + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsScheduledTasks" }, + }) + } + > + + Open automation + + + ) : null} + + {props.timestampLabel} + + + + ) : null} + + ); +} + function renderFeedEntry( info: { item: PendingThreadFeedEntry; index: number }, props: Pick< @@ -1200,6 +1322,29 @@ function renderFeedEntry( /> ); } + if (isUser && presentation.attribution === "automation") { + const rowId = `automation:${message.id}`; + return ( + props.onToggleWorkRow(rowId, entry.id)} + bubbleColor={userBubbleColor} + bubbleMaxWidth={props.duoColumns ? "85%" : props.userBubbleMaxWidth} + contentMaxWidth={props.userBubbleMaxWidth - USER_BUBBLE_HORIZONTAL_PADDING * 2} + iconSubtleColor={iconSubtleColor} + markdownStyles={markdownStyles.user} + reviewCommentColors={props.reviewCommentColors} + skills={props.skills} + linkHandlers={props.markdownLinkHandlers} + renderImage={props.renderMarkdownImage} + /> + ); + } if (isUser) { const enterAnimated = isFreshTimestamp(message.createdAt); const intentBadge = resolveUserMessageIntentBadge(message.inputIntent); @@ -1221,11 +1366,7 @@ function renderFeedEntry( className="mb-5 items-end" {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})} > - {presentation.attribution === "automation" ? ( - - Sent by automation - - ) : presentation.attribution === "t3code" ? ( + {presentation.attribution === "t3code" ? ( Sent by T3 Code @@ -2739,7 +2880,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG} keyExtractor={(entry) => entry.id} getItemType={(entry) => - entry.type === "message" ? `message:${entry.message.role}` : entry.type + entry.type !== "message" + ? entry.type + : resolveUserMessagePresentation(entry.message).attribution === "automation" + ? "message:automation" + : `message:${entry.message.role}` } getFixedItemSize={getFixedItemSize} // Virtualized rows must move with their measurements. Native layout diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index ddf8d87049faa..bc04aeeb4d502 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,14 +9,18 @@ import { act, render } from "../../../test/render"; import { MessagesTimeline } from "./MessagesTimeline"; import { remarkThoughtPreview } from "./TimelineWorkEntryRow.logic"; import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath"; +import { collectThreadFindRanges } from "./threadFindHighlights"; -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, @@ -117,6 +121,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 = [ @@ -164,6 +175,96 @@ describe("MessagesTimeline messages", () => { }); }); +describe("MessagesTimeline automation prompts", () => { + it("folds an automation prompt into a bubble 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("reveals a find match beyond the folded automation prompt's initial viewport", async () => { + setup(); + 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]")!; + expect(range.getBoundingClientRect().top - details.getBoundingClientRect().top).toBeGreaterThan( + 384, + ); + expect(range.getBoundingClientRect().bottom).toBeLessThanOrEqual( + details.getBoundingClientRect().bottom, + ); + }); + + it("shows edit-from-here once an automation prompt is expanded", 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(0); + await click(screen.button(/^Automation/)); + 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\\]"); diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 388562ef25eae..2f2f2063a4123 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -2,6 +2,8 @@ import { findRevealRef } from "./markdownFindContext"; import { type MessageId, type ServerProviderSkill, ThreadId } from "@t3tools/contracts"; 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 { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { memo, use, useState, type ReactNode } from "react"; import { @@ -15,7 +17,14 @@ import { isVideoAttachment, } from "../../types"; import ChatMarkdown from "../ChatMarkdown"; -import { DownloadIcon, EyeIcon, Redo2Icon, Undo2Icon } from "lucide-react"; +import { + CalendarClockIcon, + ChevronDownIcon, + DownloadIcon, + EyeIcon, + Redo2Icon, + Undo2Icon, +} from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; @@ -52,7 +61,11 @@ import { cn } from "~/lib/utils"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { WorkflowOpeningBlock } from "./WorkflowOpeningBlock"; import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext"; -import { MESSAGE_HEADING_LEVEL, MessageAuthorHeading } from "./TimelineRowParts"; +import { + MESSAGE_HEADING_LEVEL, + MessageAuthorHeading, + TimelineRowTimestamp, +} from "./TimelineRowParts"; import { UserMessageContextReferenceChip } from "./UserMessageContextChips"; import { resolvePreviewAnnotationImage } from "./TimelineUserMessageRow.logic"; @@ -108,11 +121,133 @@ export function UserMessageRow({ row }: { row: Extract ctx.onOpenThread(ThreadId.make(threadId))} /> + ) : resolveUserMessagePresentation(row.message).attribution === "automation" ? ( + ) : ( ); } +/** Automation prompts are long, repeated runbooks; a folded bubble 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] ?? ""; + + 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 ( +
+ {row.message.inputIntent === "steer" || + row.message.inputIntent === "promoted_queued_to_steer" ? ( + + ) : null} +
+ + {bodyMounted ? ( + // `beforematch` does not bubble, so the reveal listener sits on the hidden fold itself. + + ) : null} +
+ + {expanded ? ( +
+
+ {scheduledTaskId ? ( + + Open automation + + ) : null} + +
+ {typeof revertTurnCount === "number" && ( + + )} + + {promptLink !== null && ( + + )} +
+
+
+ ) : 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; @@ -245,27 +380,7 @@ function UserTimelineRow({ row }: { row: Extract - {userMessage.attribution === "automation" ? ( -

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

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

- {row.projectedItem && - row.projectedItem.item.status !== "completed" && - row.projectedItem.item.status !== "pending" && - row.projectedItem.item.status !== "waiting" ? ( -

- - {row.projectedItem.item.status} - -
- ) : null} +