diff --git a/.github/pr-assets/7724-after.png b/.github/pr-assets/7724-after.png new file mode 100644 index 000000000000..515fed209f38 Binary files /dev/null and b/.github/pr-assets/7724-after.png differ diff --git a/.github/pr-assets/7724-before.png b/.github/pr-assets/7724-before.png new file mode 100644 index 000000000000..147d9a05e8d6 Binary files /dev/null and b/.github/pr-assets/7724-before.png differ diff --git a/.github/pr-assets/7724-mobile-after.png b/.github/pr-assets/7724-mobile-after.png new file mode 100644 index 000000000000..7c5b35985375 Binary files /dev/null and b/.github/pr-assets/7724-mobile-after.png differ diff --git a/.github/pr-assets/7724-mobile-before.png b/.github/pr-assets/7724-mobile-before.png new file mode 100644 index 000000000000..97486261e027 Binary files /dev/null and b/.github/pr-assets/7724-mobile-before.png differ diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 53b8a528ee00..44e8ff8d7012 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -982,6 +982,7 @@ function renderFeedEntry( readonly onPressImage: (uri: string, headers?: Record) => void; readonly onMarkdownLinkPress: (href: string) => void; readonly renderMarkdownImage: MarkdownImageRenderer; + readonly renderViewedWorkImage: (path: string) => ReactNode; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; @@ -1182,6 +1183,7 @@ function renderFeedEntry( iconSubtleColor={iconSubtleColor} onCopyRow={props.onCopyWorkRow} onToggleRow={props.onToggleWorkRow} + renderViewedImage={props.renderViewedWorkImage} /> ); } @@ -1624,6 +1626,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [props.environmentId, props.threadId, props.workspaceRoot], ); + const renderViewedWorkImage = useCallback( + (path: string) => ( + setExpandedImage({ uri })} + /> + ), + [props.environmentId, props.threadId], + ); const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage); const reviewCommentColors = useReviewCommentColors(); // LegendList does not invalidate visible rows when only the renderItem closure changes. @@ -2016,6 +2030,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onPressImage, onMarkdownLinkPress, renderMarkdownImage, + renderViewedWorkImage, iconSubtleColor, userBubbleColor, markdownStyles, @@ -2044,6 +2059,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.environmentId, props.skills, renderMarkdownImage, + renderViewedWorkImage, ], ); diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index a5adacb8d19b..4d49c58fd9d3 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1,4 +1,5 @@ import * as Haptics from "expo-haptics"; +import type { ReactNode } from "react"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; import { LayoutAnimation, Pressable, ScrollView, View } from "react-native"; @@ -127,6 +128,8 @@ export function ThreadWorkLog(props: { readonly iconSubtleColor: import("react-native").ColorValue; readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string) => void; + /** Renders the image a read/view entry looked at inside its expanded detail. */ + readonly renderViewedImage: (path: string) => ReactNode; }) { const pressedBackground = useThemeColor("--color-subtle"); const rows = visibleWorkLogActivities(props.activities).map((activity) => ({ @@ -250,6 +253,9 @@ export function ThreadWorkLog(props: { {fullDetail ? ( + {row.viewedImagePath ? ( + {props.renderViewedImage(row.viewedImagePath)} + ) : null} { summary: `Tool ${id}`, detail: null, canExpand: false, + viewedImagePath: null, getFullDetail: () => null, getCopyText: () => id, icon: "command", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9e0cb64ae8b3..609979b48bf7 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -12,6 +12,7 @@ import type { TurnId, UserInputQuestion, } from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import * as Arr from "effect/Array"; @@ -48,6 +49,8 @@ export interface ThreadFeedActivity { readonly summary: string; readonly detail: string | null; readonly canExpand: boolean; + /** Workspace path of the image a read/view entry looked at — the expanded row renders it. */ + readonly viewedImagePath: string | null; readonly getFullDetail: () => string | null; readonly getCopyText: () => string; readonly icon: @@ -680,6 +683,21 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { return blocks.length > 0 ? blocks.join("\n\n") : null; } +/** + * Workspace path of the image a read/view tool entry looked at. Non-null only + * when the entry's detail is a single image path the asset route can serve. + */ +function workEntryViewedImagePath(entry: WorkLogEntry): string | null { + const isReadEntry = + entry.requestKind === "file-read" || + entry.itemType === "image_view" || + (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File"); + if (!isReadEntry) return null; + const detail = entry.detail?.trim(); + if (!detail || detail.includes("\n") || !isWorkspaceImagePreviewPath(detail)) return null; + return detail; +} + function workEntryHasExpandedBody(entry: WorkLogEntry): boolean { return ( (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) || @@ -1592,6 +1610,7 @@ export function buildThreadFeed( summary, detail, canExpand: workEntryHasExpandedBody(entry), + viewedImagePath: workEntryViewedImagePath(entry), getFullDetail, getCopyText, icon: workEntryIcon(entry), diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 81c9fdacdb9a..24bff6efedbe 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -6,7 +6,54 @@ import { normalizeCompactToolLabel, resolveAssistantMessageCopyState, shouldPreserveAssistantLineBreaks, + workEntryViewedImagePath, } from "./MessagesTimeline.logic"; +import { type WorkLogEntry } from "../../session-logic"; + +describe("workEntryViewedImagePath", () => { + const readEntry = (overrides: Partial): WorkLogEntry => ({ + id: "e1", + createdAt: "2026-01-01T00:00:00Z", + label: "Image view", + tone: "tool", + itemType: "image_view", + ...overrides, + }); + + it("returns the detail path for image_view entries", () => { + const entry = readEntry({ detail: "/workspace/screenshots/after.png" }); + expect(workEntryViewedImagePath(entry)).toBe("/workspace/screenshots/after.png"); + }); + + it("returns the path for file-read entries that read an image", () => { + const entry: WorkLogEntry = { + id: "e2", + createdAt: "2026-01-01T00:00:00Z", + label: "Read file", + tone: "tool", + requestKind: "file-read", + detail: "assets/logo.webp", + }; + expect(workEntryViewedImagePath(entry)).toBe("assets/logo.webp"); + }); + + it("ignores non-image details", () => { + expect(workEntryViewedImagePath(readEntry({ detail: "src/index.ts" }))).toBeNull(); + }); + + it("ignores multi-line details", () => { + expect(workEntryViewedImagePath(readEntry({ detail: "a.png\nb.png" }))).toBeNull(); + }); + + it("ignores entries that are not reads", () => { + const entry = readEntry({ itemType: "command_execution", detail: "shot.png" }); + expect(workEntryViewedImagePath(entry)).toBeNull(); + }); + + it("ignores entries without detail", () => { + expect(workEntryViewedImagePath(readEntry({}))).toBeNull(); + }); +}); describe("shouldPreserveAssistantLineBreaks", () => { it("preserves Claude insight formatting without changing regular markdown", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index d398583430f5..af414f87854b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,4 +1,5 @@ import * as Equal from "effect/Equal"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { formatDuration, workEntryDisplayIndicatesToolFailure, @@ -307,6 +308,18 @@ export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { return "other"; } +/** + * Workspace path of the image a read/view tool entry looked at. Non-null only + * when the entry's detail is a single image path the asset route can serve — + * the expanded row then renders the image itself above the text detail. + */ +export function workEntryViewedImagePath(entry: WorkLogEntry): string | null { + if (toolGroupAction(entry) !== "read") return null; + const detail = entry.detail?.trim(); + if (!detail || detail.includes("\n") || !isWorkspaceImagePreviewPath(detail)) return null; + return detail; +} + function toolGroupActionCount( action: ToolGroupAction, entries: ReadonlyArray, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index af920c0d6156..63b47aaecd03 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -86,6 +86,7 @@ import { shouldPreserveAssistantLineBreaks, toolGroupAction, workEntryIsVisibleInGroup, + workEntryViewedImagePath, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -117,6 +118,8 @@ import { } from "./userMessageTerminalContexts"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; +import { useAssetUrlState } from "../../assets/assetUrls"; +import { Skeleton } from "../ui/skeleton"; import { buildReviewCommentRenderablePatch, formatReviewCommentFence, @@ -2457,6 +2460,52 @@ function buildToolCallExpandedBody( const toolCallExpandedBodyClassName = "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[length:var(--font-size-code,0.6875rem)] leading-relaxed select-text"; +/** + * The image a read/view tool entry looked at, loaded through a signed + * workspace-file asset URL. Mounted only while the row is expanded, so + * collapsed rows never fetch. Falls back to nothing on failure — the path + * stays visible in the text body underneath. + */ +const ToolCallExpandedImage = memo(function ToolCallExpandedImage(props: { + readonly threadRef: ScopedThreadRef; + readonly path: string; +}) { + const { onImageExpand } = use(TimelineRowCtx); + const assetUrl = useAssetUrlState(props.threadRef.environmentId, { + _tag: "workspace-file", + threadId: props.threadRef.threadId, + path: props.path, + }); + const [failedUrl, setFailedUrl] = useState(null); + + if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { + return null; + } + if (assetUrl._tag !== "Success") { + return ( + + ); + } + const name = props.path.split(/[\\/]/).pop() ?? props.path; + return ( + + ); +}); + function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || @@ -2620,6 +2669,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { isExpandedToolGroupEntry: boolean; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; + const { threadRef } = use(TimelineRowCtx); const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; @@ -2628,6 +2678,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry); const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); + const viewedImagePath = workEntryViewedImagePath(workEntry); const canExpand = expandedBody !== null; const showDestructiveRowStyle = showFailedIndicator && @@ -2718,7 +2769,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { className="mt-1 ms-7 cursor-default border-s border-border/45 ps-3 pt-0.5" onClick={stopRowToggle} onPointerDown={stopRowToggle} + // Keys pressed on the expanded body (e.g. Enter on the image + // preview button) must not reach the row's toggle handler — its + // preventDefault would also cancel the button's click activation. + onKeyDown={stopRowToggle} > + {viewedImagePath && threadRef ? ( + + ) : null}
{expandedBody}
) : null}