| 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,6 +2,7 @@ import * as Haptics from "expo-haptics"; |
| 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | import { type LegendListRef } from "@legendapp/list/react-native"; | 3 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| 4 | import type { | 4 | import 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 { |
| 17 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | 18 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 18 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; | 19 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 19 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 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 | import { | 26 | import { |
| 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 | } |
| 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 | function ThreadMarkdownImage(props: { | 574 | function 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 | }); | ||
| 581 | 582 | ||
| 582 | return ( | 583 | return ( |
| 583 | <ThreadMarkdownImageView | 584 | <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 | <ThreadMarkdownImage | 1991 | <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 | ); |
| 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,7 +17,11 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 17 | import { AppText as Text } from "../../components/AppText"; | 17 | import { AppText as Text } from "../../components/AppText"; |
| 18 | import { cn } from "../../lib/cn"; | 18 | import { cn } from "../../lib/cn"; |
| 19 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; | 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 | import Animated, { | 25 | import 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 | <ScrollView | 407 | <ScrollView |
| 397 | nestedScrollEnabled | 408 | nestedScrollEnabled |
| 398 | directionalLockEnabled | 409 | 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"; |
| 24 | import type { | 24 | import 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 | } |
| 1220 | 1221 | ||
| 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. */ |
| 1222 | const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(props: { | 1223 | export 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); |
| 1237 | 1234 | ||
| 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 | <img | 1255 | <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 | <ChatMarkdownWorkspaceImage | 2377 | <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 @@ |
| 1 | import * as Equal from "effect/Equal"; | 1 | import * as Equal from "effect/Equal"; |
| 2 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; | 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 | import { | 15 | import { |
| 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 | } |
| 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 | function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { | 282 | function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { |
| 422 | return entry.toolCallId | 283 | 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 { |
| 9 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | 9 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 10 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; | 10 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 11 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; | 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 | import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; | 16 | import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 13 | import { | 17 | import { |
| 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"; |
| 58 | import ChatMarkdown from "../ChatMarkdown"; | 62 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; |
| 59 | import { | 63 | import { |
| 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 @@ | ||
| 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,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 | export function isWorktreeSetupActivity(kind: string): boolean { | 11 | export 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 | } |
| 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 | function toolGroupActionCount( | 136 | function toolGroupActionCount( |
| 80 | action: ToolGroupAction, | 137 | action: ToolGroupAction, |
| 81 | entries: ReadonlyArray<WorkLogPresentationEntry>, | 138 | entries: ReadonlyArray<WorkLogPresentationEntry>, |