Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/src/components/ui/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
157 changes: 151 additions & 6 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<SelectableMarkdownSkill>;
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 (
<View className="mb-5 items-end">
<View
className="min-w-0 rounded-[20px]"
style={{
backgroundColor: props.bubbleColor,
maxWidth: props.bubbleMaxWidth,
...(props.expanded ? { width: props.bubbleMaxWidth } : null),
}}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Automation: ${summary}`}
accessibilityHint={`Double tap to ${props.expanded ? "hide" : "show"} the prompt.`}
accessibilityState={{ expanded: props.expanded }}
hitSlop={4}
className="min-h-10 flex-row items-center gap-1.5 px-3.5 py-2"
onPress={() => {
void Haptics.selectionAsync();
props.onToggle();
}}
>
<SymbolView
name="calendar.badge.clock"
size={14}
weight="medium"
tintColorClassName="accent-user-bubble-foreground-muted"
type="monochrome"
/>
<Text className="font-t3-medium text-sm text-user-bubble-foreground-muted">
Automation
</Text>
<Text numberOfLines={1} className="min-w-0 shrink text-sm text-user-bubble-foreground">
{summary}
</Text>
<ThreadDisclosureChevron
expanded={props.expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
/>
</Pressable>
{props.expanded ? (
<View className="px-3.5 pb-2.5">
<MarkdownImageAvailableWidthContext value={props.contentMaxWidth}>
<UserMessageContent
text={renderAssistantCitationsAsText(props.text)}
environmentId={props.environmentId}
context={props.context}
markdownStyles={props.markdownStyles}
reviewCommentColors={props.reviewCommentColors}
skills={props.skills}
linkHandlers={props.linkHandlers}
renderImage={props.renderImage}
/>
</MarkdownImageAvailableWidthContext>
</View>
) : null}
</View>
{props.expanded ? (
<View className="mt-1 flex-row items-center justify-end gap-2 pr-0.5">
{props.scheduledTaskId ? (
<Pressable
accessibilityRole="link"
hitSlop={6}
onPress={() =>
navigation.navigate("SettingsSheet", {
screen: "SettingsContent",
params: { screen: "SettingsScheduledTasks" },
})
}
>
<Text className="font-t3-medium text-xs text-foreground-secondary">
Open automation
</Text>
</Pressable>
) : null}
<Text className="font-t3-medium text-xs tabular-nums text-foreground-secondary">
{props.timestampLabel}
</Text>
<UserMessageCopyButton
text={props.text}
onCopy={undefined}
tintColor={props.iconSubtleColor}
/>
</View>
) : null}
</View>
);
}

function renderFeedEntry(
info: { item: PendingThreadFeedEntry; index: number },
props: Pick<
Expand Down Expand Up @@ -1200,6 +1322,29 @@ function renderFeedEntry(
/>
);
}
if (isUser && presentation.attribution === "automation") {
const rowId = `automation:${message.id}`;
return (
<ThreadAutomationMessageRow
Comment thread
coderabbitai[bot] marked this conversation as resolved.
environmentId={props.environmentId}
text={presentation.text}
context={message.context}
scheduledTaskId={presentation.scheduledTaskId}
timestampLabel={timestampLabel}
expanded={props.expandedWorkRows[rowId] ?? false}
onToggle={() => 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);
Expand All @@ -1221,11 +1366,7 @@ function renderFeedEntry(
className="mb-5 items-end"
{...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})}
>
{presentation.attribution === "automation" ? (
<Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60">
Sent by automation
</Text>
) : presentation.attribution === "t3code" ? (
{presentation.attribution === "t3code" ? (
<Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60">
Sent by T3 Code
</Text>
Expand Down Expand Up @@ -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
Expand Down
109 changes: 105 additions & 4 deletions apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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\\]");
Expand Down
Loading
Loading