diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a076924202b56..9988debe2a0f3 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -240,7 +240,7 @@ import { pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "@t3tools/client-runtime/workspace-basename-lookup"; -import { useLiveThreadLinkLabels } from "../../state/entities"; +import { useLiveThreadLinkLabels, useQuietThreadShell } from "../../state/entities"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; @@ -969,33 +969,76 @@ function useMarkdownStyles(): MarkdownStyleSets { ]); } -function AgentMessageAttribution(props: { +/** Messages from other agents fold to one line so the user's own messages stay easy to find. */ +function AgentMessageFoldHeader(props: { readonly environmentId: EnvironmentId; readonly senderThreadId?: ThreadId; + readonly summary: string; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly iconSubtleColor: ColorValue; }) { - const navigation = useNavigation(); - const senderThreadId = props.senderThreadId; - const label = ( - - Sent by another agent - + const senderShell = useQuietThreadShell( + props.senderThreadId ? scopeThreadRef(props.environmentId, props.senderThreadId) : null, ); - return senderThreadId ? ( + const label = senderShell?.title.trim() || "Agent"; + return ( { + void Haptics.selectionAsync(); + props.onToggle(); + }} + > + + + {label} + + + {props.summary} + + + + ); +} + +function OpenSenderThreadButton(props: { + readonly environmentId: EnvironmentId; + readonly senderThreadId: ThreadId; +}) { + const navigation = useNavigation(); + return ( + navigation.navigate("Thread", { environmentId: String(props.environmentId), - threadId: String(senderThreadId), + threadId: String(props.senderThreadId), }) } > - {label} + Open thread - ) : ( - label ); } @@ -1360,6 +1403,9 @@ function renderFeedEntry( } if (isUser) { const enterAnimated = isFreshTimestamp(message.createdAt); + const foldRowId = presentation.attribution === "agent" ? `agent:${message.id}` : null; + const foldExpanded = foldRowId !== null && (props.expandedWorkRows[foldRowId] ?? false); + const bubbleBodyVisible = foldRowId === null || foldExpanded; const intentBadge = resolveUserMessageIntentBadge(message.inputIntent); const referenceIds = new Set( collectComposerContextReferences(message.text).map((reference) => reference.contextId), @@ -1383,169 +1429,193 @@ function renderFeedEntry( Sent by T3 Code - ) : presentation.attribution === "agent" ? ( - ) : null} - {entry.pendingMessage?.attachments.map((attachment) => - attachment.type === "image" && attachment.uploadedAttachmentId ? ( - - ) : attachment.type === "image" ? ( - - props.onPressPreview({ - kind: "image", - name: attachment.name, - uri: attachment.dataUrl ?? attachment.previewUri, - gallery: composerImageGallery(entry.pendingMessage?.attachments ?? []), - }) - } - > - - - ) : ( - - ), - )} - {/* An empty container still takes a gap, which pads every attachment-free bubble. */} - {visibleAttachments.length > 0 ? ( - - {visibleAttachments.map((attachment) => { - return isImageAttachment(attachment) ? ( + {foldRowId !== null ? ( + ref.label) + .split(/\r?\n/) + .find((line) => line.trim().length > 0) + ?.trim() ?? "" + } + expanded={foldExpanded} + onToggle={() => props.onToggleWorkRow(foldRowId, entry.id)} + iconSubtleColor={iconSubtleColor} + /> + ) : null} + {bubbleBodyVisible ? ( + + {entry.pendingMessage?.attachments.map((attachment) => + attachment.type === "image" && attachment.uploadedAttachmentId ? ( - ) : isFileAttachment(attachment) ? ( - + accessibilityRole="imagebutton" + accessibilityLabel={`Open ${attachment.name}`} + onPress={() => + props.onPressPreview({ + kind: "image", + name: attachment.name, + uri: attachment.dataUrl ?? attachment.previewUri, + gallery: composerImageGallery(entry.pendingMessage?.attachments ?? []), + }) + } + > + + ) : ( - ); - })} + ), + )} + {/* An empty container still takes a gap, which pads every attachment-free bubble. */} + {visibleAttachments.length > 0 ? ( + + {visibleAttachments.map((attachment) => { + return isImageAttachment(attachment) ? ( + + ) : isFileAttachment(attachment) ? ( + + ) : ( + + ); + })} + + ) : null} + {message.text.trim().length > 0 ? ( + + + + ) : null} ) : null} - {message.text.trim().length > 0 ? ( - - + {bubbleBodyVisible ? ( + + {foldRowId !== null && message.senderThreadId ? ( + - - ) : null} - - - {intentBadge ? ( - - - - {intentBadge.label} + ) : null} + {intentBadge ? ( + + + + {intentBadge.label} + + + ) : null} + {intentBadge ? ( + + · - - ) : null} - {intentBadge ? ( - - · + ) : null} + + {entry.pendingMessage && !entry.acknowledged ? "Pending" : timestampLabel} - ) : null} - - {entry.pendingMessage && !entry.acknowledged ? "Pending" : timestampLabel} - - {props.onEditPendingMessage !== null && - entry.pendingMessage && - !entry.acknowledged && - !entry.pendingMessage.creation && - entry.pendingMessage.messageId !== props.dispatchingMessageId ? ( - { - if (entry.pendingMessage && props.onEditPendingMessage) { - props.onEditPendingMessage(entry.pendingMessage); + {props.onEditPendingMessage !== null && + entry.pendingMessage && + !entry.acknowledged && + !entry.pendingMessage.creation && + entry.pendingMessage.messageId !== props.dispatchingMessageId ? ( + { + if (entry.pendingMessage && props.onEditPendingMessage) { + props.onEditPendingMessage(entry.pendingMessage); + } + }} + > + + + ) : null} + {presentation.text.trim().length > 0 ? ( + + writeComposerContextClipboard(message.text, { + version: 1, + source: { environmentId: props.environmentId, messageId: message.id }, + records: message.context!.records, + }) + : undefined } - }} - > - - - ) : null} - {presentation.text.trim().length > 0 ? ( - - writeComposerContextClipboard(message.text, { - version: 1, - source: { environmentId: props.environmentId, messageId: message.id }, - records: message.context!.records, - }) - : undefined - } - tintColor={iconSubtleColor} - /> - ) : null} - + tintColor={iconSubtleColor} + /> + ) : null} + + ) : null} ); } @@ -2974,13 +3044,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { renderItem={renderItem} viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG} keyExtractor={(entry) => entry.id} - getItemType={(entry) => - entry.type !== "message" - ? entry.type - : resolveUserMessagePresentation(entry.message).attribution === "automation" - ? "message:automation" - : `message:${entry.message.role}` - } + getItemType={(entry) => { + if (entry.type !== "message") return entry.type; + const { attribution } = resolveUserMessagePresentation(entry.message); + return attribution === "automation" || attribution === "agent" + ? `message:${attribution}` + : `message:${entry.message.role}`; + }} getFixedItemSize={getFixedItemSize} // Virtualized rows must move with their measurements. Native layout // transitions can retain stale positions during sync, even at duration 0. diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 227a0cda730a2..616650f5461e4 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, RunId, ScheduledTaskId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, RunId, ScheduledTaskId, ThreadId } from "@t3tools/contracts"; import type { LegendListRef } from "@legendapp/list/react"; import { createRef, Profiler, type ComponentProps, type ReactElement } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -310,6 +310,64 @@ describe("MessagesTimeline automation prompts", () => { }); }); +const SENDER_THREAD_ID = ThreadId.make("thread-sender"); +const AGENT_MESSAGE = "Review finished\n\nThe migration drops the index before backfilling it."; + +describe("MessagesTimeline agent messages", () => { + it("folds a message from another agent into a one-line preview", async () => { + setup(); + const onOpenThread = vi.fn(); + await using screen = await renderTimeline( + [message(AGENT_MESSAGE, { createdBy: "agent", senderThreadId: SENDER_THREAD_ID })], + { onOpenThread }, + ); + const row = screen.button(/^Agent/); + expect(row.textContent).toContain("Review finished"); + expect(row.getAttribute("aria-expanded")).toBe("false"); + expect(screen.text()).not.toContain("drops the index"); + expect(screen.buttons("Open thread")).toHaveLength(0); + + await click(row); + expect(screen.text()).toContain("The migration drops the index before backfilling it."); + await click(screen.button("Open thread")); + expect(onOpenThread).toHaveBeenCalledWith(SENDER_THREAD_ID); + + await click(screen.button(/^Agent/)); + expect(screen.text()).not.toContain("drops the index"); + }); + + it("leaves messages sent by T3 Code as full bubbles", async () => { + setup(); + await using screen = await renderTimeline([ + message(AGENT_MESSAGE, { createdBy: "agent", creationSource: "server" }), + ]); + expect(screen.buttons(/^Agent/)).toHaveLength(0); + expect(screen.text()).toContain("drops the index"); + }); + + it("reveals a find match inside a folded agent message", async () => { + setup(); + const text = `${"Checked the next file.\n\n".repeat(16)}The migration drops the index.`; + await using screen = await renderTimeline( + [message(text, { createdBy: "agent", senderThreadId: SENDER_THREAD_ID })], + { + findExpanded: true, + findQuery: "drops the index", + activeFindMatch: { entryId: "entry-message-1", runId: null, occurrence: 0 }, + }, + ); + await vi.waitFor(() => + expect(screen.button(/^Agent/).getAttribute("aria-expanded")).toBe("true"), + ); + const { range } = collectThreadFindRanges(screen.container, "drops the index")[0]!; + const body = screen.container.querySelector("[data-user-message-body]")!; + expect(range.getBoundingClientRect().height).toBeGreaterThan(0); + expect(range.getBoundingClientRect().bottom).toBeLessThanOrEqual( + body.getBoundingClientRect().bottom, + ); + }); +}); + 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 2f2f2063a4123..0fb9ac7f66c3b 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -2,6 +2,7 @@ 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 { useQuietThreadShell } from "../../state/entities"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; @@ -22,6 +23,8 @@ import { ChevronDownIcon, DownloadIcon, EyeIcon, + type LucideIcon, + MessagesSquareIcon, Redo2Icon, Undo2Icon, } from "lucide-react"; @@ -162,23 +165,13 @@ function AutomationMessageRow({ row }: { row: Extract - + setExpanded((value) => !value)} + /> {bodyMounted ? ( // `beforematch` does not bubble, so the reveal listener sits on the hidden fold itself.
void; +}) { + const Icon = props.icon; + return ( + + ); +} + function UserMessageProjectedStatus({ row }: { row: Extract }) { const status = row.projectedItem?.item.status; if ( @@ -272,6 +294,15 @@ function UserTimelineRow({ row }: { row: Extract !isImageAttachment(attachment) && !isFileAttachment(attachment), ); const userMessage = resolveUserMessagePresentation(row.message); + // Messages from other agents fold to one line so the user's own messages stay easy to find. + const folded = userMessage.attribution === "agent"; + const [expanded, setExpanded] = useState(false); + const revealFoldRef = findRevealRef(() => setExpanded(true)); + const senderShell = useQuietThreadShell( + folded && senderThreadId + ? { environmentId: ctx.activeThreadEnvironmentId, threadId: senderThreadId } + : null, + ); const resolvedContext = resolveUserMessageContext(row.message); const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-")); const revertTurnCount = row.revertTurnCount; @@ -291,6 +322,11 @@ function UserTimelineRow({ row }: { row: Extract reference.label, ); const promptLink = promptLinkFor(plainText); + const foldSummary = + plainText + .split(/\r?\n/) + .find((line) => line.trim().length > 0) + ?.trim() ?? ""; const contextClipboardFragment = resolvedContext.records.length === 0 ? null @@ -379,7 +415,10 @@ function UserTimelineRow({ row }: { row: Extract +
{userMessage.attribution === "t3code" ? (

Sent by T3 Code

- ) : userMessage.attribution === "agent" ? ( -

- {senderThreadId ? ( - ctx.onOpenThread(senderThreadId)} - tone="muted" - aria-label="Open sending thread" - > - Sent by another agent - - ) : ( - "Sent by another agent" - )} -

) : null} {row.message.inputIntent === "steer" || row.message.inputIntent === "promoted_queued_to_steer" ? ( ) : null} -
- You - {(regularImages.length > 0 || userVideos.length > 0) && ( -
- {regularImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} + {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {image.previewUrl && image.source?.kind === "snap-shot" ? ( + + ) : null}
- )} - {image.previewUrl && image.source?.kind === "snap-shot" ? ( - - ) : null} + ))} + {userVideos.map((file) => ( + + ))}
- ))} - {userVideos.map((file) => ( - - ))} -
- )} - {unchippedFiles.length > 0 || unknownAttachments.length > 0 ? ( -
- {unchippedFiles.map((file) => { - const fileIdentity = ( - <> - - {file.name} - - ); - if (file.downloadable !== false) { - return ( -
- + + ctx.onFileDownload(file)} + /> + } + > + + + Download {file.name} + +
+ ); + } + + return ( +
{fileIdentity} - - - - ctx.onFileDownload(file)} - /> - } - > - - - Download {file.name} - +
+ ); + })} + {unknownAttachments.map((attachment) => ( +
+ + {attachment.name}
- ); - } - - return ( -
- {fileIdentity} -
- ); - })} - {unknownAttachments.map((attachment) => ( -
- - {attachment.name} + ))}
- ))} + ) : null} +
+ +
) : null} -
- -
-
-
- - }> - {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} - - - {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} - - -
- {typeof revertTurnCount === "number" && ( - - )} - {resolvedContext.text && ( - - )} - {promptLink !== null && ( - - )} + {!folded || expanded ? ( +
+
+ {folded && senderThreadId ? ( + ctx.onOpenThread(senderThreadId)} tone="muted"> + Open thread + + ) : null} + + }> + {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} + + + {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} + + +
+ {typeof revertTurnCount === "number" && ( + + )} + {resolvedContext.text && ( + + )} + {promptLink !== null && ( + + )} +
-
+ ) : null}
); } @@ -630,13 +696,15 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode; skills: ReadonlyArray>; markdownCwd: string | undefined; + collapsible?: boolean; footer?: ReactNode; }) { const [expanded, setExpanded] = useState(false); const revealForFind = () => setExpanded(true); const revealRef = findRevealRef(revealForFind); const hasVisibleBody = props.text.trim().length > 0; - const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text); + const canCollapse = + props.collapsible !== false && hasVisibleBody && shouldCollapseUserMessage(props.text); const isCollapsed = canCollapse && !expanded; const showCollapseControl = canCollapse;