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";...@@ -2,6 +2,7 @@ import * as Haptics from "expo-haptics";
2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
3import { type LegendListRef } from "@legendapp/list/react-native";3import { type LegendListRef } from "@legendapp/list/react-native";
4import type {4import type {
5 AssetResource,
5 ChatAttachment,6 ChatAttachment,
6 ChatFileAttachment,7 ChatFileAttachment,
7 ChatImageAttachment,8 ChatImageAttachment,
...@@ -17,7 +18,11 @@ import {...@@ -17,7 +18,11 @@ import {
17import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";18import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
18import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";19import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
19import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";20import { 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";
21import {26import {
22 renderCodexFileCitationsAsMarkdown,27 renderCodexFileCitationsAsMarkdown,
23 splitCodexArtifactTemplateMarkdown,28 splitCodexArtifactTemplateMarkdown,
...@@ -565,24 +570,24 @@ function ThreadMarkdownImageRequest(props: {...@@ -565,24 +570,24 @@ function ThreadMarkdownImageRequest(props: {
565 );570 );
566}571}
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. */
569function ThreadMarkdownImage(props: {574function ThreadMarkdownImage(props: {
570 readonly environmentId: EnvironmentId;575 readonly environmentId: EnvironmentId;
571 readonly threadId: ThreadId;576 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;
572 readonly path: string;
573 readonly alt: string | null;577 readonly alt: string | null;
578 readonly srcFragment?: string;
574 readonly onPressPreview: (source: FilePreviewSource) => void;579 readonly onPressPreview: (source: FilePreviewSource) => void;
575}) {580}) {
576 const assetUrl = useAssetUrlState(props.environmentId, {581 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
577 _tag: "workspace-file",
578 threadId: props.threadId,
579 path: props.path,
580 });
581582
582 return (583 return (
583 <ThreadMarkdownImageView584 <ThreadMarkdownImageView
584 uri={assetUrl._tag === "Success" ? assetUrl.url : null}585 uri={assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null}
585 sourceKey={props.path}586 sourceKey={
587 props.resource._tag === "attachment"
588 ? `attachment:${props.resource.attachmentId}`
589 : `workspace:${props.resource.path}`
590 }
586 unavailable={assetUrl._tag === "Failure"}591 unavailable={assetUrl._tag === "Failure"}
587 alt={props.alt}592 alt={props.alt}
588 onPressPreview={props.onPressPreview}593 onPressPreview={props.onPressPreview}
...@@ -1333,6 +1338,7 @@ function renderFeedEntry(...@@ -1333,6 +1338,7 @@ function renderFeedEntry(
1333 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;1338 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
1334 readonly onMarkdownLinkPress: (href: string) => void;1339 readonly onMarkdownLinkPress: (href: string) => void;
1335 readonly renderMarkdownImage: MarkdownImageRenderer;1340 readonly renderMarkdownImage: MarkdownImageRenderer;
1341 readonly renderViewedImage: MarkdownImageRenderer;
1336 readonly iconSubtleColor: string | import("react-native").ColorValue;1342 readonly iconSubtleColor: string | import("react-native").ColorValue;
1337 readonly userBubbleColor: string | import("react-native").ColorValue;1343 readonly userBubbleColor: string | import("react-native").ColorValue;
1338 readonly markdownStyles: MarkdownStyleSets;1344 readonly markdownStyles: MarkdownStyleSets;
...@@ -1545,6 +1551,7 @@ function renderFeedEntry(...@@ -1545,6 +1551,7 @@ function renderFeedEntry(
1545 iconSubtleColor={iconSubtleColor}1551 iconSubtleColor={iconSubtleColor}
1546 onCopyRow={props.onCopyWorkRow}1552 onCopyRow={props.onCopyWorkRow}
1547 onToggleRow={props.onToggleWorkRow}1553 onToggleRow={props.onToggleWorkRow}
1554 renderImage={props.renderViewedImage}
1548 />1555 />
1549 );1556 );
1550}1557}
...@@ -1983,15 +1990,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1983,15 +1990,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1983 return (1990 return (
1984 <ThreadMarkdownImage1991 <ThreadMarkdownImage
1985 environmentId={props.environmentId}1992 environmentId={props.environmentId}
1986 threadId={props.threadId}1993 resource={{
1987 path={imageSource.path}1994 _tag: "workspace-file",
1995 threadId: props.threadId,
1996 path: imageSource.path,
1997 }}
1988 alt={image.alt}1998 alt={image.alt}
1999 srcFragment={markdownImageSourceFragment(image.href)}
1989 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}2000 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
1990 />2001 />
1991 );2002 );
1992 },2003 },
1993 [props.environmentId, props.threadId, props.workspaceRoot],2004 [props.environmentId, props.threadId, props.workspaceRoot],
1994 );2005 );
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 );
1995 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage);2024 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage);
1996 const reviewCommentColors = useReviewCommentColors();2025 const reviewCommentColors = useReviewCommentColors();
1997 // LegendList does not invalidate visible rows when only the renderItem closure changes.2026 // LegendList does not invalidate visible rows when only the renderItem closure changes.
...@@ -2443,6 +2472,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2443,6 +2472,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2443 onPressVideo,2472 onPressVideo,
2444 onMarkdownLinkPress,2473 onMarkdownLinkPress,
2445 renderMarkdownImage,2474 renderMarkdownImage,
2475 renderViewedImage,
2446 iconSubtleColor,2476 iconSubtleColor,
2447 userBubbleColor,2477 userBubbleColor,
2448 markdownStyles,2478 markdownStyles,
...@@ -2477,6 +2507,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2477,6 +2507,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2477 props.onUseArtifactTemplate,2507 props.onUseArtifactTemplate,
2478 props.skills,2508 props.skills,
2479 renderMarkdownImage,2509 renderMarkdownImage,
2510 renderViewedImage,
2480 ],2511 ],
2481 );2512 );
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";...@@ -17,7 +17,11 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
17import { AppText as Text } from "../../components/AppText";17import { AppText as Text } from "../../components/AppText";
18import { cn } from "../../lib/cn";18import { cn } from "../../lib/cn";
19import type { ThreadFeedActivity } from "../../lib/threadActivity";19import 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";
21import Animated, {25import Animated, {
22 cancelAnimation,26 cancelAnimation,
23 Easing,27 Easing,
...@@ -277,6 +281,7 @@ export function ThreadWorkLog(props: {...@@ -277,6 +281,7 @@ export function ThreadWorkLog(props: {
277 readonly iconSubtleColor: import("react-native").ColorValue;281 readonly iconSubtleColor: import("react-native").ColorValue;
278 readonly onCopyRow: (rowId: string, value: string) => void;282 readonly onCopyRow: (rowId: string, value: string) => void;
279 readonly onToggleRow: (rowId: string) => void;283 readonly onToggleRow: (rowId: string) => void;
284 readonly renderImage: MarkdownImageRenderer;
280}) {285}) {
281 const rows = props.activities.map((activity) => ({286 const rows = props.activities.map((activity) => ({
282 ...activity,287 ...activity,
...@@ -294,6 +299,7 @@ export function ThreadWorkLog(props: {...@@ -294,6 +299,7 @@ export function ThreadWorkLog(props: {
294 const expanded = props.expandedRows[row.id] ?? false;299 const expanded = props.expandedRows[row.id] ?? false;
295 const canExpand = row.canExpand;300 const canExpand = row.canExpand;
296 const fullDetail = expanded ? row.getFullDetail() : null;301 const fullDetail = expanded ? row.getFullDetail() : null;
302 const viewedImagePath = workEntryViewedImagePath(row.workEntry);
297 const displayText = row.detail ?? row.summary;303 const displayText = row.detail ?? row.summary;
298 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";304 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
299 const failed = row.status === "failure";305 const failed = row.status === "failure";
...@@ -393,6 +399,11 @@ export function ThreadWorkLog(props: {...@@ -393,6 +399,11 @@ export function ThreadWorkLog(props: {
393 layout={WORK_LOG_LAYOUT_TRANSITION}399 layout={WORK_LOG_LAYOUT_TRANSITION}
394 className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5"400 className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5"
395 >401 >
402 {viewedImagePath ? (
403 <View className="pb-1.5">
404 {props.renderImage({ href: viewedImagePath, alt: null, title: null })}
405 </View>
406 ) : null}
396 <ScrollView407 <ScrollView
397 nestedScrollEnabled408 nestedScrollEnabled
398 directionalLockEnabled409 directionalLockEnabled
apps/web/src/components/ChatMarkdown.tsx+16-15
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
22 type LucideIcon,22 type LucideIcon,
23} from "lucide-react";23} from "lucide-react";
24import type {24import type {
25 AssetResource,
25 EnvironmentId,26 EnvironmentId,
26 ScopedThreadRef,27 ScopedThreadRef,
27 ServerProviderSkill,28 ServerProviderSkill,
...@@ -1218,21 +1219,17 @@ function ChatMarkdownImageFallback(props: {...@@ -1218,21 +1219,17 @@ function ChatMarkdownImageFallback(props: {
1218 );1219 );
1219}1220}
12201221
1221/** Markdown images whose src is a workspace file path load through a signed asset URL. */1222/** Environment-hosted images load through a signed asset URL. */
1222const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(props: {1223export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: {
1223 readonly threadRef: ScopedThreadRef;1224 readonly environmentId: EnvironmentId;
1224 readonly path: string;1225 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;
1225 readonly alt: string;1226 readonly alt: string;
1226 readonly copyMarkdown: string;1227 readonly copyMarkdown?: string;
1227 readonly srcFragment: string;1228 readonly srcFragment?: string;
1228 readonly style?: CSSProperties | undefined;1229 readonly style?: CSSProperties | undefined;
1229 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;1230 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
1230}) {1231}) {
1231 const assetUrl = useAssetUrlState(props.threadRef.environmentId, {1232 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
1232 _tag: "workspace-file",
1233 threadId: props.threadRef.threadId,
1234 path: props.path,
1235 });
1236 const [failedUrl, setFailedUrl] = useState<string | null>(null);1233 const [failedUrl, setFailedUrl] = useState<string | null>(null);
12371234
1238 if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {1235 if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {
...@@ -1253,7 +1250,7 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop...@@ -1253,7 +1250,7 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop
1253 />1250 />
1254 );1251 );
1255 }1252 }
1256 const src = assetUrl.url + props.srcFragment;1253 const src = assetUrl.url + (props.srcFragment ?? "");
1257 return (1254 return (
1258 <img1255 <img
1259 src={src}1256 src={src}
...@@ -2377,9 +2374,13 @@ function ChatMarkdown({...@@ -2377,9 +2374,13 @@ function ChatMarkdown({
2377 }2374 }
2378 if (imageSource._tag === "WorkspaceFile" && threadRef) {2375 if (imageSource._tag === "WorkspaceFile" && threadRef) {
2379 return (2376 return (
2380 <ChatMarkdownWorkspaceImage2377 <ChatMarkdownAssetImage
2381 threadRef={threadRef}2378 environmentId={threadRef.environmentId}
2382 path={imageSource.path}2379 resource={{
2380 _tag: "workspace-file",
2381 threadId: threadRef.threadId,
2382 path: imageSource.path,
2383 }}
2383 alt={altText}2384 alt={altText}
2384 copyMarkdown={copyMarkdown}2385 copyMarkdown={copyMarkdown}
2385 srcFragment={markdownImageSourceFragment(classifiedSrc)}2386 srcFragment={markdownImageSourceFragment(classifiedSrc)}
apps/web/src/components/chat/MessagesTimeline.logic.ts+12-151
...@@ -1,5 +1,17 @@...@@ -1,5 +1,17 @@
1import * as Equal from "effect/Equal";1import * as Equal from "effect/Equal";
2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";2import { 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";
3import {15import {
4 formatDuration,16 formatDuration,
5 workEntryDisplayIndicatesToolFailure,17 workEntryDisplayIndicatesToolFailure,
...@@ -267,157 +279,6 @@ export function computeMessageDurationStart(...@@ -267,157 +279,6 @@ export function computeMessageDurationStart(
267 return result;279 return result;
268}280}
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
421function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string {282function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string {
422 return entry.toolCallId283 return entry.toolCallId
423 ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`284 ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`
apps/web/src/components/chat/MessagesTimeline.tsx+26-1
...@@ -9,6 +9,10 @@ import {...@@ -9,6 +9,10 @@ import {
9import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";9import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
10import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";10import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
11import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";11import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
12import {
13 resolveViewedImageAsset,
14 workEntryViewedImagePath,
15} from "@t3tools/client-runtime/work-log/presentation";
12import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";16import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";
13import {17import {
14 emptyAgentPanelModel,18 emptyAgentPanelModel,
...@@ -55,7 +59,7 @@ import {...@@ -55,7 +59,7 @@ import {
55 resolveDiffThemeName,59 resolveDiffThemeName,
56 resolveFileDiffPath,60 resolveFileDiffPath,
57} from "../../lib/diffRendering";61} from "../../lib/diffRendering";
58import ChatMarkdown from "../ChatMarkdown";62import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
59import {63import {
60 BotIcon,64 BotIcon,
61 CheckIcon,65 CheckIcon,
...@@ -2430,6 +2434,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2430,6 +2434,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2430 isExpandedToolGroupEntry: boolean;2434 isExpandedToolGroupEntry: boolean;
2431}) {2435}) {
2432 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;2436 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
2437 const { threadRef, onImageExpand } = use(TimelineRowCtx);
2433 const [expanded, setExpanded] = useState(false);2438 const [expanded, setExpanded] = useState(false);
2434 const iconConfig = workToneIcon(workEntry.tone);2439 const iconConfig = workToneIcon(workEntry.tone);
2435 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";2440 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
...@@ -2438,6 +2443,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2438,6 +2443,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2438 showWarningIndicator || showFailedIndicator ? "circle-alert" : workEntryIconName(workEntry);2443 showWarningIndicator || showFailedIndicator ? "circle-alert" : workEntryIconName(workEntry);
2439 const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);2444 const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
2440 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);2445 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;
2441 const canExpand = expandedBody !== null;2454 const canExpand = expandedBody !== null;
2442 const showDestructiveRowStyle =2455 const showDestructiveRowStyle =
2443 showFailedIndicator &&2456 showFailedIndicator &&
...@@ -2531,6 +2544,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2531,6 +2544,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2531 onClick={stopRowToggle}2544 onClick={stopRowToggle}
2532 onPointerDown={stopRowToggle}2545 onPointerDown={stopRowToggle}
2533 >2546 >
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}
2534 <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre>2559 <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre>
2535 </div>2560 </div>
2536 ) : null}2561 ) : 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 @@...@@ -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
3export function isWorktreeSetupActivity(kind: string): boolean {11export function isWorktreeSetupActivity(kind: string): boolean {
4 return kind === "setup-script.requested" || kind === "setup-script.started";12 return kind === "setup-script.requested" || kind === "setup-script.started";
...@@ -9,6 +17,7 @@ export interface WorkLogPresentationEntry {...@@ -9,6 +17,7 @@ export interface WorkLogPresentationEntry {
9 readonly toolTitle?: string;17 readonly toolTitle?: string;
10 readonly tone: "thinking" | "tool" | "info" | "error";18 readonly tone: "thinking" | "tool" | "info" | "error";
11 readonly command?: string;19 readonly command?: string;
20 readonly detail?: string;
12 readonly changedFiles?: ReadonlyArray<string>;21 readonly changedFiles?: ReadonlyArray<string>;
13 readonly itemType?: ToolLifecycleItemType;22 readonly itemType?: ToolLifecycleItemType;
14 readonly requestKind?: string;23 readonly requestKind?: string;
...@@ -57,7 +66,8 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio...@@ -57,7 +66,8 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio
57 if (66 if (
58 entry.requestKind === "file-read" ||67 entry.requestKind === "file-read" ||
59 entry.itemType === "image_view" ||68 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")
61 ) {71 ) {
62 return "read";72 return "read";
63 }73 }
...@@ -76,6 +86,53 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio...@@ -76,6 +86,53 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio
76 return workLogEntryIsToolLike(entry) ? "other" : "update";86 return workLogEntryIsToolLike(entry) ? "other" : "update";
77}87}
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
79function toolGroupActionCount(136function toolGroupActionCount(
80 action: ToolGroupAction,137 action: ToolGroupAction,
81 entries: ReadonlyArray<WorkLogPresentationEntry>,138 entries: ReadonlyArray<WorkLogPresentationEntry>,