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} { expect(group.activities[0]?.getFullDetail()).toContain("repository.search"); }); + it("marks Cursor image reads with sentence-case titles for expanded previews", () => { + const thread = makeThread({ + id: ThreadId.make("thread-cursor-image-read"), + projectId: ProjectId.make("project-1"), + title: "Cursor image read", + activities: [ + makeActivity({ + id: EventId.make("cursor-image-read"), + kind: "tool.completed", + tone: "tool", + summary: "Read file", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { + title: "Read file", + itemType: "dynamic_tool_call", + detail: "artifacts/cursor-preview.png", + status: "completed", + }, + }), + ], + }); + + const group = buildThreadFeed(thread)[0]; + expect(group).toMatchObject({ type: "activity-group" }); + if (!group || group.type !== "activity-group") { + return; + } + + expect(group.activities[0]?.viewedImagePath).toBe("artifacts/cursor-preview.png"); + }); + it("defers large tool output expansion until a work row is opened or copied", () => { let serializedToolOutputs = 0; const activities = Array.from({ length: 5_000 }, (_, index) => @@ -764,6 +795,7 @@ describe("buildThreadFeed", () => { 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..0062637c663a 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 { workEntryViewedImagePath } from "@t3tools/client-runtime/work-log-images"; 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: @@ -1592,6 +1595,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.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index d398583430f5..bca4dd413172 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 { workEntryIsRead } from "@t3tools/client-runtime/work-log-images"; import { formatDuration, workEntryDisplayIndicatesToolFailure, @@ -285,13 +286,7 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean { } export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { - if ( - entry.requestKind === "file-read" || - entry.itemType === "image_view" || - (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File") - ) { - return "read"; - } + if (workEntryIsRead(entry)) return "read"; if ( entry.requestKind === "file-change" || entry.itemType === "file_change" || diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index af920c0d6156..e6846ec9d988 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; +import { workEntryViewedImagePath } from "@t3tools/client-runtime/work-log-images"; import { emptyAgentPanelModel, formatSubagentTokenCount, @@ -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 && @@ -2661,6 +2712,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { "aria-expanded": expanded, onClick: () => setExpanded((v) => !v), onKeyDown: (e: KeyboardEvent) => { + if (e.target !== e.currentTarget) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setExpanded((v) => !v); @@ -2719,6 +2771,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { onClick={stopRowToggle} onPointerDown={stopRowToggle} > + {viewedImagePath && threadRef ? ( + + ) : null}
{expandedBody}
) : null} diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index abed33998966..e079614de216 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -19,6 +19,10 @@ "types": "./src/markdownImages.ts", "default": "./src/markdownImages.ts" }, + "./work-log-images": { + "types": "./src/workLogImages.ts", + "default": "./src/workLogImages.ts" + }, "./errors": { "types": "./src/errors/index.ts", "default": "./src/errors/index.ts" diff --git a/packages/client-runtime/src/workLogImages.test.ts b/packages/client-runtime/src/workLogImages.test.ts new file mode 100644 index 000000000000..c2e9e1bcec8d --- /dev/null +++ b/packages/client-runtime/src/workLogImages.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + type ViewedImageWorkEntry, + workEntryIsRead, + workEntryViewedImagePath, +} from "./workLogImages.js"; + +describe("workEntryIsRead", () => { + it("uses one predicate for file reads, image views, and dynamic read tools", () => { + expect(workEntryIsRead({ requestKind: "file-read" })).toBe(true); + expect(workEntryIsRead({ itemType: "image_view" })).toBe(true); + expect(workEntryIsRead({ itemType: "dynamic_tool_call", toolTitle: " Read File " })).toBe(true); + expect(workEntryIsRead({ itemType: "command_execution" })).toBe(false); + }); +}); + +describe("workEntryViewedImagePath", () => { + const readEntry = (overrides: ViewedImageWorkEntry): ViewedImageWorkEntry => ({ + 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: ViewedImageWorkEntry = { + requestKind: "file-read", + detail: "assets/logo.webp", + }; + expect(workEntryViewedImagePath(entry)).toBe("assets/logo.webp"); + }); + + it("returns the path for dynamic reads with sentence-case titles", () => { + const entry = readEntry({ + itemType: "dynamic_tool_call", + toolTitle: "Read file", + detail: "artifacts/cursor-preview.png", + }); + expect(workEntryViewedImagePath(entry)).toBe("artifacts/cursor-preview.png"); + }); + + 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(); + expect(workEntryViewedImagePath(readEntry({ detail: "a.png\rb.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(); + }); +}); diff --git a/packages/client-runtime/src/workLogImages.ts b/packages/client-runtime/src/workLogImages.ts new file mode 100644 index 000000000000..a6d64385d9a6 --- /dev/null +++ b/packages/client-runtime/src/workLogImages.ts @@ -0,0 +1,27 @@ +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; + +export interface ViewedImageWorkEntry { + readonly requestKind?: string; + readonly itemType?: string; + readonly toolTitle?: string; + readonly detail?: string; +} + +/** Whether a work entry represents a supported file read or image view. */ +export function workEntryIsRead(entry: ViewedImageWorkEntry): boolean { + return ( + entry.requestKind === "file-read" || + entry.itemType === "image_view" || + (entry.itemType === "dynamic_tool_call" && + entry.toolTitle?.trim().toLowerCase() === "read file") + ); +} + +/** Returns the workspace image path viewed by a supported read entry. */ +export function workEntryViewedImagePath(entry: ViewedImageWorkEntry): string | null { + if (!workEntryIsRead(entry)) return null; + + const detail = entry.detail?.trim(); + if (!detail || /[\r\n]/.test(detail) || !isWorkspaceImagePreviewPath(detail)) return null; + return detail; +}