authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-01 00:56:48-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 00:56:48-04:00
logce71c04f0aa9d2e5cd340e2a04cb1b0d5e24419d
treeb99f19d31c9a5a5286bcb28a5657250bb4864936
parentff93aba61dc1f7839713580bc8ee08da2a395f9a
signature Signed by PGP key B5690EEEBB952194

feat(client): render viewed images in work logs (#8936)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

7 files changed, 239 insertions(+), 183 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+44-13
......@@ -2,6 +2,7 @@ import * as Haptics from "expo-haptics";
22import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
33import { type LegendListRef } from "@legendapp/list/react-native";
44import type {
5 AssetResource,
56 ChatAttachment,
67 ChatFileAttachment,
78 ChatImageAttachment,
......@@ -17,7 +18,11 @@ import {
1718import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
1819import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
1920import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
20import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
21import {
22 classifyMarkdownImageSource,
23 markdownImageSourceFragment,
24} from "@t3tools/client-runtime/markdown-images";
25import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation";
2126import {
2227 renderCodexFileCitationsAsMarkdown,
2328 splitCodexArtifactTemplateMarkdown,
......@@ -565,24 +570,24 @@ function ThreadMarkdownImageRequest(props: {
565570 );
566571}
567572
568/** Markdown image whose src is a workspace file — loads through a signed asset URL. */
573/** Environment-hosted image that loads through a signed asset URL. */
569574function ThreadMarkdownImage(props: {
570575 readonly environmentId: EnvironmentId;
571 readonly threadId: ThreadId;
572 readonly path: string;
576 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;
573577 readonly alt: string | null;
578 readonly srcFragment?: string;
574579 readonly onPressPreview: (source: FilePreviewSource) => void;
575580}) {
576 const assetUrl = useAssetUrlState(props.environmentId, {
577 _tag: "workspace-file",
578 threadId: props.threadId,
579 path: props.path,
580 });
581 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
581582
582583 return (
583584 <ThreadMarkdownImageView
584 uri={assetUrl._tag === "Success" ? assetUrl.url : null}
585 sourceKey={props.path}
585 uri={assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null}
586 sourceKey={
587 props.resource._tag === "attachment"
588 ? `attachment:${props.resource.attachmentId}`
589 : `workspace:${props.resource.path}`
590 }
586591 unavailable={assetUrl._tag === "Failure"}
587592 alt={props.alt}
588593 onPressPreview={props.onPressPreview}
......@@ -1333,6 +1338,7 @@ function renderFeedEntry(
13331338 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
13341339 readonly onMarkdownLinkPress: (href: string) => void;
13351340 readonly renderMarkdownImage: MarkdownImageRenderer;
1341 readonly renderViewedImage: MarkdownImageRenderer;
13361342 readonly iconSubtleColor: string | import("react-native").ColorValue;
13371343 readonly userBubbleColor: string | import("react-native").ColorValue;
13381344 readonly markdownStyles: MarkdownStyleSets;
......@@ -1545,6 +1551,7 @@ function renderFeedEntry(
15451551 iconSubtleColor={iconSubtleColor}
15461552 onCopyRow={props.onCopyWorkRow}
15471553 onToggleRow={props.onToggleWorkRow}
1554 renderImage={props.renderViewedImage}
15481555 />
15491556 );
15501557}
......@@ -1983,15 +1990,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19831990 return (
19841991 <ThreadMarkdownImage
19851992 environmentId={props.environmentId}
1986 threadId={props.threadId}
1987 path={imageSource.path}
1993 resource={{
1994 _tag: "workspace-file",
1995 threadId: props.threadId,
1996 path: imageSource.path,
1997 }}
19881998 alt={image.alt}
1999 srcFragment={markdownImageSourceFragment(image.href)}
19892000 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
19902001 />
19912002 );
19922003 },
19932004 [props.environmentId, props.threadId, props.workspaceRoot],
19942005 );
2006 const renderViewedImage = useCallback<MarkdownImageRenderer>(
2007 (image) => {
2008 const viewedImage = resolveViewedImageAsset(image.href, {
2009 threadId: props.threadId,
2010 workspaceRoot: props.workspaceRoot,
2011 });
2012 return viewedImage ? (
2013 <ThreadMarkdownImage
2014 environmentId={props.environmentId}
2015 resource={viewedImage.resource}
2016 alt={viewedImage.alt}
2017 srcFragment={viewedImage.srcFragment}
2018 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
2019 />
2020 ) : null;
2021 },
2022 [props.environmentId, props.threadId, props.workspaceRoot],
2023 );
19952024 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage);
19962025 const reviewCommentColors = useReviewCommentColors();
19972026 // LegendList does not invalidate visible rows when only the renderItem closure changes.
......@@ -2443,6 +2472,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
24432472 onPressVideo,
24442473 onMarkdownLinkPress,
24452474 renderMarkdownImage,
2475 renderViewedImage,
24462476 iconSubtleColor,
24472477 userBubbleColor,
24482478 markdownStyles,
......@@ -2477,6 +2507,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
24772507 props.onUseArtifactTemplate,
24782508 props.skills,
24792509 renderMarkdownImage,
2510 renderViewedImage,
24802511 ],
24812512 );
24822513
apps/mobile/src/features/threads/thread-work-log.tsx+12-1
......@@ -17,7 +17,11 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
1717import { AppText as Text } from "../../components/AppText";
1818import { cn } from "../../lib/cn";
1919import type { ThreadFeedActivity } from "../../lib/threadActivity";
20import type { ToolGroupSummaryKind } from "@t3tools/client-runtime/work-log/presentation";
20import {
21 type ToolGroupSummaryKind,
22 workEntryViewedImagePath,
23} from "@t3tools/client-runtime/work-log/presentation";
24import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";
2125import Animated, {
2226 cancelAnimation,
2327 Easing,
......@@ -277,6 +281,7 @@ export function ThreadWorkLog(props: {
277281 readonly iconSubtleColor: import("react-native").ColorValue;
278282 readonly onCopyRow: (rowId: string, value: string) => void;
279283 readonly onToggleRow: (rowId: string) => void;
284 readonly renderImage: MarkdownImageRenderer;
280285}) {
281286 const rows = props.activities.map((activity) => ({
282287 ...activity,
......@@ -294,6 +299,7 @@ export function ThreadWorkLog(props: {
294299 const expanded = props.expandedRows[row.id] ?? false;
295300 const canExpand = row.canExpand;
296301 const fullDetail = expanded ? row.getFullDetail() : null;
302 const viewedImagePath = workEntryViewedImagePath(row.workEntry);
297303 const displayText = row.detail ?? row.summary;
298304 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
299305 const failed = row.status === "failure";
......@@ -393,6 +399,11 @@ export function ThreadWorkLog(props: {
393399 layout={WORK_LOG_LAYOUT_TRANSITION}
394400 className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5"
395401 >
402 {viewedImagePath ? (
403 <View className="pb-1.5">
404 {props.renderImage({ href: viewedImagePath, alt: null, title: null })}
405 </View>
406 ) : null}
396407 <ScrollView
397408 nestedScrollEnabled
398409 directionalLockEnabled
apps/web/src/components/ChatMarkdown.tsx+16-15
......@@ -22,6 +22,7 @@ import {
2222 type LucideIcon,
2323} from "lucide-react";
2424import type {
25 AssetResource,
2526 EnvironmentId,
2627 ScopedThreadRef,
2728 ServerProviderSkill,
......@@ -1218,21 +1219,17 @@ function ChatMarkdownImageFallback(props: {
12181219 );
12191220}
12201221
1221/** Markdown images whose src is a workspace file path load through a signed asset URL. */
1222const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(props: {
1223 readonly threadRef: ScopedThreadRef;
1224 readonly path: string;
1222/** Environment-hosted images load through a signed asset URL. */
1223export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: {
1224 readonly environmentId: EnvironmentId;
1225 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;
12251226 readonly alt: string;
1226 readonly copyMarkdown: string;
1227 readonly srcFragment: string;
1227 readonly copyMarkdown?: string;
1228 readonly srcFragment?: string;
12281229 readonly style?: CSSProperties | undefined;
12291230 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
12301231}) {
1231 const assetUrl = useAssetUrlState(props.threadRef.environmentId, {
1232 _tag: "workspace-file",
1233 threadId: props.threadRef.threadId,
1234 path: props.path,
1235 });
1232 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
12361233 const [failedUrl, setFailedUrl] = useState<string | null>(null);
12371234
12381235 if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {
......@@ -1253,7 +1250,7 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop
12531250 />
12541251 );
12551252 }
1256 const src = assetUrl.url + props.srcFragment;
1253 const src = assetUrl.url + (props.srcFragment ?? "");
12571254 return (
12581255 <img
12591256 src={src}
......@@ -2377,9 +2374,13 @@ function ChatMarkdown({
23772374 }
23782375 if (imageSource._tag === "WorkspaceFile" && threadRef) {
23792376 return (
2380 <ChatMarkdownWorkspaceImage
2381 threadRef={threadRef}
2382 path={imageSource.path}
2377 <ChatMarkdownAssetImage
2378 environmentId={threadRef.environmentId}
2379 resource={{
2380 _tag: "workspace-file",
2381 threadId: threadRef.threadId,
2382 path: imageSource.path,
2383 }}
23832384 alt={altText}
23842385 copyMarkdown={copyMarkdown}
23852386 srcFragment={markdownImageSourceFragment(classifiedSrc)}
apps/web/src/components/chat/MessagesTimeline.logic.ts+12-151
......@@ -1,5 +1,17 @@
11import * as Equal from "effect/Equal";
22import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
3import {
4 omitSupersededLifecycleMarkers,
5 summarizeToolGroup,
6 toolGroupSummaryKind,
7 type ToolGroupSummaryKind,
8} from "@t3tools/client-runtime/work-log/presentation";
9export {
10 normalizeCompactToolLabel,
11 summarizeToolGroup,
12 toolGroupAction,
13 workLogEntryIsLocalCodeSearch,
14} from "@t3tools/client-runtime/work-log/presentation";
315import {
416 formatDuration,
517 workEntryDisplayIndicatesToolFailure,
......@@ -267,157 +279,6 @@ export function computeMessageDurationStart(
267279 return result;
268280}
269281
270export function normalizeCompactToolLabel(value: string): string {
271 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
272}
273
274type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update";
275type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed";
276
277export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {
278 return (
279 entry.itemType === "web_search" &&
280 /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))
281 );
282}
283
284export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
285 if (
286 entry.requestKind === "file-read" ||
287 entry.itemType === "image_view" ||
288 (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File")
289 ) {
290 return "read";
291 }
292 if (
293 entry.requestKind === "file-change" ||
294 entry.itemType === "file_change" ||
295 (entry.changedFiles?.length ?? 0) > 0
296 ) {
297 return "edit";
298 }
299 if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) {
300 return "command";
301 }
302 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
303 if (entry.itemType === "web_search") return "search";
304 return workLogEntryIsToolLike(entry) ? "other" : "update";
305}
306
307function toolGroupActionCount(
308 action: ToolGroupAction,
309 entries: ReadonlyArray<WorkLogEntry>,
310): number {
311 if (action !== "edit") return entries.length;
312
313 const changedFiles = new Set<string>();
314 let editsWithoutFileDetails = 0;
315 for (const entry of entries) {
316 if (!entry.changedFiles || entry.changedFiles.length === 0) {
317 editsWithoutFileDetails += 1;
318 continue;
319 }
320 for (const file of entry.changedFiles) changedFiles.add(file);
321 }
322 return changedFiles.size + editsWithoutFileDetails;
323}
324
325function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
326 switch (action) {
327 case "read":
328 return `Read ${count} ${count === 1 ? "file" : "files"}`;
329 case "edit":
330 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
331 case "command":
332 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
333 case "search":
334 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
335 case "code-search":
336 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
337 case "other":
338 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
339 case "update":
340 return `Received ${count} ${count === 1 ? "update" : "updates"}`;
341 }
342}
343
344/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */
345export function summarizeToolGroup(entries: ReadonlyArray<WorkLogEntry>): string {
346 const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry);
347 const groupedEntries = new Map<ToolGroupAction, WorkLogEntry[]>();
348 for (const entry of summaryEntries) {
349 const action = toolGroupAction(entry);
350 const group = groupedEntries.get(action);
351 if (group) group.push(entry);
352 else groupedEntries.set(action, [entry]);
353 }
354 const labels = [...groupedEntries].map(([action, actionEntries]) =>
355 toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)),
356 );
357 const sentenceLabels = labels.map((label, index) =>
358 index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
359 );
360 if (sentenceLabels.length < 2) return sentenceLabels[0] ?? "";
361 if (sentenceLabels.length === 2) return sentenceLabels.join(" and ");
362 return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
363}
364
365function omitSupersededLifecycleMarkers<T>(
366 entries: readonly T[],
367 workEntryFor: (entry: T) => WorkLogEntry,
368): T[] {
369 const laterTerminalIdentities = new Set<string>();
370 const reversedEntries: T[] = [];
371
372 for (let index = entries.length - 1; index >= 0; index -= 1) {
373 const entry = entries[index]!;
374 const workEntry = workEntryFor(entry);
375 const normalizedLabel = normalizeCompactToolLabel(workEntry.toolTitle ?? workEntry.label);
376 const identity = [
377 workEntry.turnId ?? "no-turn",
378 workEntry.itemType ?? "",
379 normalizedLabel,
380 ].join("\u001f");
381 const isStatuslessIdlessMarker =
382 workEntry.toolCallId === undefined &&
383 workEntry.toolLifecycleStatus === undefined &&
384 (workEntry.sourceActivityKind === "tool.started" ||
385 workEntry.sourceActivityKind === "tool.updated");
386 if (isStatuslessIdlessMarker && laterTerminalIdentities.has(identity)) continue;
387
388 reversedEntries.push(entry);
389 if (
390 workEntry.sourceActivityKind === "tool.completed" ||
391 (workEntry.toolLifecycleStatus !== undefined &&
392 workEntry.toolLifecycleStatus !== "inProgress")
393 ) {
394 laterTerminalIdentities.add(identity);
395 }
396 }
397
398 return reversedEntries.toReversed();
399}
400
401function toolGroupSummaryKind(entries: ReadonlyArray<WorkLogEntry>): ToolGroupSummaryKind {
402 const actions = new Set(entries.map(toolGroupAction));
403 if (actions.size !== 1) return "mixed";
404
405 const action = actions.values().next().value!;
406 if (action !== "other") return action;
407
408 const fallbackKinds = new Set(
409 entries.map((entry): ToolGroupSummaryKind => {
410 if (entry.itemType === "mcp_tool_call") return "other";
411 if (entry.itemType === "dynamic_tool_call") return "dynamic-tool";
412 if (entry.itemType === "collab_agent_tool_call" || entry.taskId) return "agent-tool";
413 if (entry.tone === "thinking") return "agent-tool";
414 if (entry.tone === "tool") return "tone-tool";
415 return "other";
416 }),
417 );
418 return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed";
419}
420
421282function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string {
422283 return entry.toolCallId
423284 ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`
apps/web/src/components/chat/MessagesTimeline.tsx+26-1
......@@ -9,6 +9,10 @@ import {
99import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
1010import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
1111import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
12import {
13 resolveViewedImageAsset,
14 workEntryViewedImagePath,
15} from "@t3tools/client-runtime/work-log/presentation";
1216import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";
1317import {
1418 emptyAgentPanelModel,
......@@ -55,7 +59,7 @@ import {
5559 resolveDiffThemeName,
5660 resolveFileDiffPath,
5761} from "../../lib/diffRendering";
58import ChatMarkdown from "../ChatMarkdown";
62import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
5963import {
6064 BotIcon,
6165 CheckIcon,
......@@ -2430,6 +2434,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
24302434 isExpandedToolGroupEntry: boolean;
24312435}) {
24322436 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
2437 const { threadRef, onImageExpand } = use(TimelineRowCtx);
24332438 const [expanded, setExpanded] = useState(false);
24342439 const iconConfig = workToneIcon(workEntry.tone);
24352440 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
......@@ -2438,6 +2443,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
24382443 showWarningIndicator || showFailedIndicator ? "circle-alert" : workEntryIconName(workEntry);
24392444 const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
24402445 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);
2446 const viewedImagePath = workEntryViewedImagePath(workEntry);
2447 const viewedImage =
2448 viewedImagePath && threadRef
2449 ? resolveViewedImageAsset(viewedImagePath, {
2450 threadId: threadRef.threadId,
2451 workspaceRoot,
2452 })
2453 : null;
24412454 const canExpand = expandedBody !== null;
24422455 const showDestructiveRowStyle =
24432456 showFailedIndicator &&
......@@ -2531,6 +2544,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
25312544 onClick={stopRowToggle}
25322545 onPointerDown={stopRowToggle}
25332546 >
2547 {viewedImage && threadRef ? (
2548 <div className="mb-1.5">
2549 <ChatMarkdownAssetImage
2550 environmentId={threadRef.environmentId}
2551 resource={viewedImage.resource}
2552 alt={viewedImage.alt}
2553 srcFragment={viewedImage.srcFragment}
2554 style={{ maxHeight: "16rem" }}
2555 onImageExpand={onImageExpand}
2556 />
2557 </div>
2558 ) : null}
25342559 <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre>
25352560 </div>
25362561 ) : null}
packages/client-runtime/src/work-log/presentation.test.ts created+70
......@@ -0,0 +1,70 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { ThreadId } from "@t3tools/contracts";
4
5import { resolveViewedImageAsset, workEntryViewedImagePath } from "./presentation.js";
6
7describe("workEntryViewedImagePath", () => {
8 const entry = { label: "Read", tone: "tool" } as const;
9
10 it("returns a single image path from supported read entries", () => {
11 expect(
12 workEntryViewedImagePath({ ...entry, requestKind: "file-read", detail: " assets/a.png " }),
13 ).toBe("assets/a.png");
14 expect(
15 workEntryViewedImagePath({
16 ...entry,
17 itemType: "dynamic_tool_call",
18 toolTitle: "Read file",
19 detail: "C:\\workspace\\a.webp",
20 }),
21 ).toBe("C:\\workspace\\a.webp");
22 });
23
24 it("rejects non-image, multi-line, and non-read details", () => {
25 expect(
26 workEntryViewedImagePath({ ...entry, itemType: "image_view", detail: "a.txt" }),
27 ).toBeNull();
28 expect(
29 workEntryViewedImagePath({ ...entry, itemType: "image_view", detail: "a.png\nb.png" }),
30 ).toBeNull();
31 expect(workEntryViewedImagePath({ ...entry, detail: "a.png" })).toBeNull();
32 });
33});
34
35describe("resolveViewedImageAsset", () => {
36 const threadId = ThreadId.make("thread-1");
37
38 it("loads t3 attachment paths as attachments", () => {
39 const attachmentId =
40 "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222";
41 expect(
42 resolveViewedImageAsset(`/Users/demo/.t3/dev/attachments/${attachmentId}.png`, {
43 threadId,
44 workspaceRoot: "/workspace",
45 }),
46 ).toEqual({
47 resource: { _tag: "attachment", attachmentId },
48 alt: `${attachmentId}.png`,
49 srcFragment: "",
50 });
51 });
52
53 it("normalizes workspace image sources", () => {
54 expect(
55 resolveViewedImageAsset("screens/logo.svg?v=2#mark", {
56 threadId,
57 workspaceRoot: "/workspace",
58 }),
59 ).toEqual({
60 resource: {
61 _tag: "workspace-file",
62 threadId,
63 path: "/workspace/screens/logo.svg",
64 },
65 alt: "logo.svg",
66 srcFragment: "#mark",
67 });
68 expect(resolveViewedImageAsset("https://example.com/logo.png", { threadId })).toBeNull();
69 });
70});
packages/client-runtime/src/work-log/presentation.ts+59-2
......@@ -1,4 +1,12 @@
1import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts";
1import {
2 isToolLifecycleItemType,
3 type AssetResource,
4 type ThreadId,
5 type ToolLifecycleItemType,
6} from "@t3tools/contracts";
7import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
8
9import { classifyMarkdownImageSource, markdownImageSourceFragment } from "../markdownImages.js";
210
311export function isWorktreeSetupActivity(kind: string): boolean {
412 return kind === "setup-script.requested" || kind === "setup-script.started";
......@@ -9,6 +17,7 @@ export interface WorkLogPresentationEntry {
917 readonly toolTitle?: string;
1018 readonly tone: "thinking" | "tool" | "info" | "error";
1119 readonly command?: string;
20 readonly detail?: string;
1221 readonly changedFiles?: ReadonlyArray<string>;
1322 readonly itemType?: ToolLifecycleItemType;
1423 readonly requestKind?: string;
......@@ -57,7 +66,8 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio
5766 if (
5867 entry.requestKind === "file-read" ||
5968 entry.itemType === "image_view" ||
60 (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File")
69 (entry.itemType === "dynamic_tool_call" &&
70 entry.toolTitle?.trim().toLowerCase() === "read file")
6171 ) {
6272 return "read";
6373 }
......@@ -76,6 +86,53 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio
7686 return workLogEntryIsToolLike(entry) ? "other" : "update";
7787}
7888
89export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): string | null {
90 const detail = entry.detail?.trim();
91 return toolGroupAction(entry) === "read" &&
92 detail !== undefined &&
93 !/[\r\n]/.test(detail) &&
94 isWorkspaceImagePreviewPath(detail)
95 ? detail
96 : null;
97}
98
99export interface ViewedImageAsset {
100 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;
101 readonly alt: string;
102 readonly srcFragment: string;
103}
104
105const ABSOLUTE_IMAGE_SOURCE_PATTERN = /^(?:file:|[\\/]|[a-z]:[\\/])/i;
106const T3_ATTACHMENT_IMAGE_PATH_PATTERN =
107 /(?:^|[\\/])(?:dev|userdata)[\\/]attachments[\\/]([a-z0-9_-]{1,128})\.[a-z0-9]{1,10}$/i;
108
109export function resolveViewedImageAsset(
110 source: string,
111 input: {
112 readonly threadId: ThreadId;
113 readonly workspaceRoot?: string | null | undefined;
114 },
115): ViewedImageAsset | null {
116 const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? ".");
117 if (imageSource._tag !== "WorkspaceFile") return null;
118
119 const path =
120 input.workspaceRoot == null && imageSource.path.startsWith("./")
121 ? imageSource.path.slice(2)
122 : imageSource.path;
123 const attachmentId = ABSOLUTE_IMAGE_SOURCE_PATTERN.test(source)
124 ? (T3_ATTACHMENT_IMAGE_PATH_PATTERN.exec(path)?.[1] ?? null)
125 : null;
126
127 return {
128 resource: attachmentId
129 ? { _tag: "attachment", attachmentId }
130 : { _tag: "workspace-file", threadId: input.threadId, path },
131 alt: path.split(/[\\/]/).at(-1) ?? "image",
132 srcFragment: markdownImageSourceFragment(source),
133 };
134}
135
79136function toolGroupActionCount(
80137 action: ToolGroupAction,
81138 entries: ReadonlyArray<WorkLogPresentationEntry>,