| author | |
| committer | |
| log | ce71c04f0aa9d2e5cd340e2a04cb1b0d5e24419d |
| tree | b99f19d31c9a5a5286bcb28a5657250bb4864936 |
| parent | ff93aba61dc1f7839713580bc8ee08da2a395f9a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | 3 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| 4 | 4 | import type { |
| 5 | AssetResource, | |
| 5 | 6 | ChatAttachment, |
| 6 | 7 | ChatFileAttachment, |
| 7 | 8 | ChatImageAttachment, |
| ... | ... | @@ -17,7 +18,11 @@ import { |
| 17 | 18 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 18 | 19 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 19 | 20 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 20 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | |
| 21 | import { | |
| 22 | classifyMarkdownImageSource, | |
| 23 | markdownImageSourceFragment, | |
| 24 | } from "@t3tools/client-runtime/markdown-images"; | |
| 25 | import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation"; | |
| 21 | 26 | import { |
| 22 | 27 | renderCodexFileCitationsAsMarkdown, |
| 23 | 28 | splitCodexArtifactTemplateMarkdown, |
| ... | ... | @@ -565,24 +570,24 @@ function ThreadMarkdownImageRequest(props: { |
| 565 | 570 | ); |
| 566 | 571 | } |
| 567 | 572 | |
| 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. */ | |
| 569 | 574 | function ThreadMarkdownImage(props: { |
| 570 | 575 | readonly environmentId: EnvironmentId; |
| 571 | readonly threadId: ThreadId; | |
| 572 | readonly path: string; | |
| 576 | readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>; | |
| 573 | 577 | readonly alt: string | null; |
| 578 | readonly srcFragment?: string; | |
| 574 | 579 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 575 | 580 | }) { |
| 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); | |
| 581 | 582 | |
| 582 | 583 | return ( |
| 583 | 584 | <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 | } | |
| 586 | 591 | unavailable={assetUrl._tag === "Failure"} |
| 587 | 592 | alt={props.alt} |
| 588 | 593 | onPressPreview={props.onPressPreview} |
| ... | ... | @@ -1333,6 +1338,7 @@ function renderFeedEntry( |
| 1333 | 1338 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; |
| 1334 | 1339 | readonly onMarkdownLinkPress: (href: string) => void; |
| 1335 | 1340 | readonly renderMarkdownImage: MarkdownImageRenderer; |
| 1341 | readonly renderViewedImage: MarkdownImageRenderer; | |
| 1336 | 1342 | readonly iconSubtleColor: string | import("react-native").ColorValue; |
| 1337 | 1343 | readonly userBubbleColor: string | import("react-native").ColorValue; |
| 1338 | 1344 | readonly markdownStyles: MarkdownStyleSets; |
| ... | ... | @@ -1545,6 +1551,7 @@ function renderFeedEntry( |
| 1545 | 1551 | iconSubtleColor={iconSubtleColor} |
| 1546 | 1552 | onCopyRow={props.onCopyWorkRow} |
| 1547 | 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 | 1990 | return ( |
| 1984 | 1991 | <ThreadMarkdownImage |
| 1985 | 1992 | 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 | }} | |
| 1988 | 1998 | alt={image.alt} |
| 1999 | srcFragment={markdownImageSourceFragment(image.href)} | |
| 1989 | 2000 | onPressPreview={(source) => setExpandedFile((current) => current ?? source)} |
| 1990 | 2001 | /> |
| 1991 | 2002 | ); |
| 1992 | 2003 | }, |
| 1993 | 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 | 2024 | const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage); |
| 1996 | 2025 | const reviewCommentColors = useReviewCommentColors(); |
| 1997 | 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 | 2472 | onPressVideo, |
| 2444 | 2473 | onMarkdownLinkPress, |
| 2445 | 2474 | renderMarkdownImage, |
| 2475 | renderViewedImage, | |
| 2446 | 2476 | iconSubtleColor, |
| 2447 | 2477 | userBubbleColor, |
| 2448 | 2478 | markdownStyles, |
| ... | ... | @@ -2477,6 +2507,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2477 | 2507 | props.onUseArtifactTemplate, |
| 2478 | 2508 | props.skills, |
| 2479 | 2509 | renderMarkdownImage, |
| 2510 | renderViewedImage, | |
| 2480 | 2511 | ], |
| 2481 | 2512 | ); |
| 2482 | 2513 |
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 | 17 | import { AppText as Text } from "../../components/AppText"; |
| 18 | 18 | import { cn } from "../../lib/cn"; |
| 19 | 19 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; |
| 20 | import type { ToolGroupSummaryKind } from "@t3tools/client-runtime/work-log/presentation"; | |
| 20 | import { | |
| 21 | type ToolGroupSummaryKind, | |
| 22 | workEntryViewedImagePath, | |
| 23 | } from "@t3tools/client-runtime/work-log/presentation"; | |
| 24 | import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; | |
| 21 | 25 | import Animated, { |
| 22 | 26 | cancelAnimation, |
| 23 | 27 | Easing, |
| ... | ... | @@ -277,6 +281,7 @@ export function ThreadWorkLog(props: { |
| 277 | 281 | readonly iconSubtleColor: import("react-native").ColorValue; |
| 278 | 282 | readonly onCopyRow: (rowId: string, value: string) => void; |
| 279 | 283 | readonly onToggleRow: (rowId: string) => void; |
| 284 | readonly renderImage: MarkdownImageRenderer; | |
| 280 | 285 | }) { |
| 281 | 286 | const rows = props.activities.map((activity) => ({ |
| 282 | 287 | ...activity, |
| ... | ... | @@ -294,6 +299,7 @@ export function ThreadWorkLog(props: { |
| 294 | 299 | const expanded = props.expandedRows[row.id] ?? false; |
| 295 | 300 | const canExpand = row.canExpand; |
| 296 | 301 | const fullDetail = expanded ? row.getFullDetail() : null; |
| 302 | const viewedImagePath = workEntryViewedImagePath(row.workEntry); | |
| 297 | 303 | const displayText = row.detail ?? row.summary; |
| 298 | 304 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; |
| 299 | 305 | const failed = row.status === "failure"; |
| ... | ... | @@ -393,6 +399,11 @@ export function ThreadWorkLog(props: { |
| 393 | 399 | layout={WORK_LOG_LAYOUT_TRANSITION} |
| 394 | 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 | 407 | <ScrollView |
| 397 | 408 | nestedScrollEnabled |
| 398 | 409 | directionalLockEnabled |
apps/web/src/components/ChatMarkdown.tsx+16-15| ... | ... | @@ -22,6 +22,7 @@ import { |
| 22 | 22 | type LucideIcon, |
| 23 | 23 | } from "lucide-react"; |
| 24 | 24 | import type { |
| 25 | AssetResource, | |
| 25 | 26 | EnvironmentId, |
| 26 | 27 | ScopedThreadRef, |
| 27 | 28 | ServerProviderSkill, |
| ... | ... | @@ -1218,21 +1219,17 @@ function ChatMarkdownImageFallback(props: { |
| 1218 | 1219 | ); |
| 1219 | 1220 | } |
| 1220 | 1221 | |
| 1221 | /** Markdown images whose src is a workspace file path load through a signed asset URL. */ | |
| 1222 | const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(props: { | |
| 1223 | readonly threadRef: ScopedThreadRef; | |
| 1224 | readonly path: string; | |
| 1222 | /** Environment-hosted images load through a signed asset URL. */ | |
| 1223 | export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { | |
| 1224 | readonly environmentId: EnvironmentId; | |
| 1225 | readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>; | |
| 1225 | 1226 | readonly alt: string; |
| 1226 | readonly copyMarkdown: string; | |
| 1227 | readonly srcFragment: string; | |
| 1227 | readonly copyMarkdown?: string; | |
| 1228 | readonly srcFragment?: string; | |
| 1228 | 1229 | readonly style?: CSSProperties | undefined; |
| 1229 | 1230 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; |
| 1230 | 1231 | }) { |
| 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); | |
| 1236 | 1233 | const [failedUrl, setFailedUrl] = useState<string | null>(null); |
| 1237 | 1234 | |
| 1238 | 1235 | if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { |
| ... | ... | @@ -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 | 1254 | return ( |
| 1258 | 1255 | <img |
| 1259 | 1256 | src={src} |
| ... | ... | @@ -2377,9 +2374,13 @@ function ChatMarkdown({ |
| 2377 | 2374 | } |
| 2378 | 2375 | if (imageSource._tag === "WorkspaceFile" && threadRef) { |
| 2379 | 2376 | 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 | }} | |
| 2383 | 2384 | alt={altText} |
| 2384 | 2385 | copyMarkdown={copyMarkdown} |
| 2385 | 2386 | srcFragment={markdownImageSourceFragment(classifiedSrc)} |
apps/web/src/components/chat/MessagesTimeline.logic.ts+12-151| ... | ... | @@ -1,5 +1,17 @@ |
| 1 | 1 | import * as Equal from "effect/Equal"; |
| 2 | 2 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; |
| 3 | import { | |
| 4 | omitSupersededLifecycleMarkers, | |
| 5 | summarizeToolGroup, | |
| 6 | toolGroupSummaryKind, | |
| 7 | type ToolGroupSummaryKind, | |
| 8 | } from "@t3tools/client-runtime/work-log/presentation"; | |
| 9 | export { | |
| 10 | normalizeCompactToolLabel, | |
| 11 | summarizeToolGroup, | |
| 12 | toolGroupAction, | |
| 13 | workLogEntryIsLocalCodeSearch, | |
| 14 | } from "@t3tools/client-runtime/work-log/presentation"; | |
| 3 | 15 | import { |
| 4 | 16 | formatDuration, |
| 5 | 17 | workEntryDisplayIndicatesToolFailure, |
| ... | ... | @@ -267,157 +279,6 @@ export function computeMessageDurationStart( |
| 267 | 279 | return result; |
| 268 | 280 | } |
| 269 | 281 | |
| 270 | export function normalizeCompactToolLabel(value: string): string { | |
| 271 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | |
| 272 | } | |
| 273 | ||
| 274 | type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update"; | |
| 275 | type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed"; | |
| 276 | ||
| 277 | export 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 | ||
| 284 | export 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 | ||
| 307 | function 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 | ||
| 325 | function 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. */ | |
| 345 | export 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 | ||
| 365 | function 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 | ||
| 401 | function 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 | ||
| 421 | 282 | function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { |
| 422 | 283 | return entry.toolCallId |
| 423 | 284 | ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` |
apps/web/src/components/chat/MessagesTimeline.tsx+26-1| ... | ... | @@ -9,6 +9,10 @@ import { |
| 9 | 9 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 10 | 10 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 11 | 11 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; |
| 12 | import { | |
| 13 | resolveViewedImageAsset, | |
| 14 | workEntryViewedImagePath, | |
| 15 | } from "@t3tools/client-runtime/work-log/presentation"; | |
| 12 | 16 | import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 13 | 17 | import { |
| 14 | 18 | emptyAgentPanelModel, |
| ... | ... | @@ -55,7 +59,7 @@ import { |
| 55 | 59 | resolveDiffThemeName, |
| 56 | 60 | resolveFileDiffPath, |
| 57 | 61 | } from "../../lib/diffRendering"; |
| 58 | import ChatMarkdown from "../ChatMarkdown"; | |
| 62 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; | |
| 59 | 63 | import { |
| 60 | 64 | BotIcon, |
| 61 | 65 | CheckIcon, |
| ... | ... | @@ -2430,6 +2434,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2430 | 2434 | isExpandedToolGroupEntry: boolean; |
| 2431 | 2435 | }) { |
| 2432 | 2436 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; |
| 2437 | const { threadRef, onImageExpand } = use(TimelineRowCtx); | |
| 2433 | 2438 | const [expanded, setExpanded] = useState(false); |
| 2434 | 2439 | const iconConfig = workToneIcon(workEntry.tone); |
| 2435 | 2440 | const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; |
| ... | ... | @@ -2438,6 +2443,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2438 | 2443 | showWarningIndicator || showFailedIndicator ? "circle-alert" : workEntryIconName(workEntry); |
| 2439 | 2444 | const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); |
| 2440 | 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 | 2454 | const canExpand = expandedBody !== null; |
| 2442 | 2455 | const showDestructiveRowStyle = |
| 2443 | 2456 | showFailedIndicator && |
| ... | ... | @@ -2531,6 +2544,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2531 | 2544 | onClick={stopRowToggle} |
| 2532 | 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 | 2559 | <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre> |
| 2535 | 2560 | </div> |
| 2536 | 2561 | ) : null} |
packages/client-runtime/src/work-log/presentation.test.ts created+70| ... | ... | @@ -0,0 +1,70 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { ThreadId } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | import { resolveViewedImageAsset, workEntryViewedImagePath } from "./presentation.js"; | |
| 6 | ||
| 7 | describe("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 | ||
| 35 | describe("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 | import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | isToolLifecycleItemType, | |
| 3 | type AssetResource, | |
| 4 | type ThreadId, | |
| 5 | type ToolLifecycleItemType, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; | |
| 8 | ||
| 9 | import { classifyMarkdownImageSource, markdownImageSourceFragment } from "../markdownImages.js"; | |
| 2 | 10 | |
| 3 | 11 | export function isWorktreeSetupActivity(kind: string): boolean { |
| 4 | 12 | return kind === "setup-script.requested" || kind === "setup-script.started"; |
| ... | ... | @@ -9,6 +17,7 @@ export interface WorkLogPresentationEntry { |
| 9 | 17 | readonly toolTitle?: string; |
| 10 | 18 | readonly tone: "thinking" | "tool" | "info" | "error"; |
| 11 | 19 | readonly command?: string; |
| 20 | readonly detail?: string; | |
| 12 | 21 | readonly changedFiles?: ReadonlyArray<string>; |
| 13 | 22 | readonly itemType?: ToolLifecycleItemType; |
| 14 | 23 | readonly requestKind?: string; |
| ... | ... | @@ -57,7 +66,8 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio |
| 57 | 66 | if ( |
| 58 | 67 | entry.requestKind === "file-read" || |
| 59 | 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 | 72 | return "read"; |
| 63 | 73 | } |
| ... | ... | @@ -76,6 +86,53 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio |
| 76 | 86 | return workLogEntryIsToolLike(entry) ? "other" : "update"; |
| 77 | 87 | } |
| 78 | 88 | |
| 89 | export 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 | ||
| 99 | export interface ViewedImageAsset { | |
| 100 | readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>; | |
| 101 | readonly alt: string; | |
| 102 | readonly srcFragment: string; | |
| 103 | } | |
| 104 | ||
| 105 | const ABSOLUTE_IMAGE_SOURCE_PATTERN = /^(?:file:|[\\/]|[a-z]:[\\/])/i; | |
| 106 | const T3_ATTACHMENT_IMAGE_PATH_PATTERN = | |
| 107 | /(?:^|[\\/])(?:dev|userdata)[\\/]attachments[\\/]([a-z0-9_-]{1,128})\.[a-z0-9]{1,10}$/i; | |
| 108 | ||
| 109 | export 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 | ||
| 79 | 136 | function toolGroupActionCount( |
| 80 | 137 | action: ToolGroupAction, |
| 81 | 138 | entries: ReadonlyArray<WorkLogPresentationEntry>, |