| author | |
| committer | |
| log | 6cf0c6ea55d281f65c80502ec1871b0adf472025 |
| tree | eac6154fde5aafc4d62185303678d41b4a12ad78 |
| parent | 0bc59bbae391ee45ab3a7cfb2dc703c8b27e5772 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>32 files changed, 2165 insertions(+), 32 deletions(-)
apps/mobile/src/components/AppSymbol.tsx+1| ... | @@ -185,6 +185,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record<string, Icon> = { | ... | @@ -185,6 +185,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record<string, Icon> = { |
| 185 | close: IconX, | 185 | close: IconX, |
| 186 | construction: IconHammer, | 186 | construction: IconHammer, |
| 187 | content_copy: IconCopy, | 187 | content_copy: IconCopy, |
| 188 | desktop_windows: IconDeviceDesktop, | ||
| 188 | edit: IconEdit, | 189 | edit: IconEdit, |
| 189 | error: IconAlertCircle, | 190 | error: IconAlertCircle, |
| 190 | folder: IconFolder, | 191 | folder: IconFolder, |
apps/mobile/src/features/threads/ThreadFeed.tsx+12| ... | @@ -1501,6 +1501,7 @@ function renderFeedEntry( | ... | @@ -1501,6 +1501,7 @@ function renderFeedEntry( |
| 1501 | readonly markdownStyles: MarkdownStyleSets; | 1501 | readonly markdownStyles: MarkdownStyleSets; |
| 1502 | readonly reviewCommentColors: ReviewCommentColors; | 1502 | readonly reviewCommentColors: ReviewCommentColors; |
| 1503 | readonly reviewCommentBubbleWidth: number; | 1503 | readonly reviewCommentBubbleWidth: number; |
| 1504 | readonly themeAppearance: "light" | "dark"; | ||
| 1504 | readonly userBubbleMaxWidth: number; | 1505 | readonly userBubbleMaxWidth: number; |
| 1505 | }, | 1506 | }, |
| 1506 | ) { | 1507 | ) { |
| ... | @@ -1538,12 +1539,16 @@ function renderFeedEntry( | ... | @@ -1538,12 +1539,16 @@ function renderFeedEntry( |
| 1538 | if (entry.type === "work-toggle") { | 1539 | if (entry.type === "work-toggle") { |
| 1539 | return ( | 1540 | return ( |
| 1540 | <ThreadWorkGroupToggle | 1541 | <ThreadWorkGroupToggle |
| 1542 | environmentId={props.environmentId} | ||
| 1541 | rowSizing={props.workRowSizing} | 1543 | rowSizing={props.workRowSizing} |
| 1542 | expanded={entry.expanded} | 1544 | expanded={entry.expanded} |
| 1543 | hiddenCount={entry.hiddenCount} | 1545 | hiddenCount={entry.hiddenCount} |
| 1544 | iconSubtleColor={iconSubtleColor} | 1546 | iconSubtleColor={iconSubtleColor} |
| 1545 | summary={entry.summary} | 1547 | summary={entry.summary} |
| 1546 | summaryKind={entry.summaryKind} | 1548 | summaryKind={entry.summaryKind} |
| 1549 | themeAppearance={props.themeAppearance} | ||
| 1550 | toolSurface={entry.toolSurface} | ||
| 1551 | toolIcon={entry.toolIcon} | ||
| 1547 | summaryToolIcon={entry.summaryToolIcon} | 1552 | summaryToolIcon={entry.summaryToolIcon} |
| 1548 | hasFailure={entry.hasFailure} | 1553 | hasFailure={entry.hasFailure} |
| 1549 | shimmer={entry.shimmer} | 1554 | shimmer={entry.shimmer} |
| ... | @@ -1716,12 +1721,14 @@ function renderFeedEntry( | ... | @@ -1716,12 +1721,14 @@ function renderFeedEntry( |
| 1716 | // Anchors/details live in ThreadFeed and survive this group-only remount. | 1721 | // Anchors/details live in ThreadFeed and survive this group-only remount. |
| 1717 | key={`${entry.id}:${props.workRowSizing.textSizeKey}`} | 1722 | key={`${entry.id}:${props.workRowSizing.textSizeKey}`} |
| 1718 | activities={entry.activities} | 1723 | activities={entry.activities} |
| 1724 | environmentId={props.environmentId} | ||
| 1719 | anchorKey={entry.id} | 1725 | anchorKey={entry.id} |
| 1720 | copiedRowId={props.copiedRowId} | 1726 | copiedRowId={props.copiedRowId} |
| 1721 | expandedRows={props.expandedWorkRows} | 1727 | expandedRows={props.expandedWorkRows} |
| 1722 | rowSizing={props.workRowSizing} | 1728 | rowSizing={props.workRowSizing} |
| 1723 | scrollPositions={props.workGroupScrollPositions} | 1729 | scrollPositions={props.workGroupScrollPositions} |
| 1724 | iconSubtleColor={iconSubtleColor} | 1730 | iconSubtleColor={iconSubtleColor} |
| 1731 | themeAppearance={props.themeAppearance} | ||
| 1725 | onCopyRow={props.onCopyWorkRow} | 1732 | onCopyRow={props.onCopyWorkRow} |
| 1726 | onToggleRow={props.onToggleWorkRow} | 1733 | onToggleRow={props.onToggleWorkRow} |
| 1727 | renderImage={props.renderViewedImage} | 1734 | renderImage={props.renderViewedImage} |
| ... | @@ -2001,6 +2008,7 @@ function ThreadFeedPlaceholder(props: { | ... | @@ -2001,6 +2008,7 @@ function ThreadFeedPlaceholder(props: { |
| 2001 | 2008 | ||
| 2002 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | 2009 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2003 | const navigation = useNavigation(); | 2010 | const navigation = useNavigation(); |
| 2011 | const { themeAppearance } = useAppearancePreferences(); | ||
| 2004 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 2012 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 2005 | const disclosureSettleFrameRef = useRef<number | null>(null); | 2013 | const disclosureSettleFrameRef = useRef<number | null>(null); |
| 2006 | const disclosureSettleSecondFrameRef = useRef<number | null>(null); | 2014 | const disclosureSettleSecondFrameRef = useRef<number | null>(null); |
| ... | @@ -2319,6 +2327,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2319,6 +2327,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2319 | iconSubtleColor, | 2327 | iconSubtleColor, |
| 2320 | markdownStyles, | 2328 | markdownStyles, |
| 2321 | reviewCommentColors, | 2329 | reviewCommentColors, |
| 2330 | themeAppearance, | ||
| 2322 | userBubbleColor, | 2331 | userBubbleColor, |
| 2323 | viewportWidth, | 2332 | viewportWidth, |
| 2324 | }), | 2333 | }), |
| ... | @@ -2329,6 +2338,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2329,6 +2338,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2329 | iconSubtleColor, | 2338 | iconSubtleColor, |
| 2330 | markdownStyles, | 2339 | markdownStyles, |
| 2331 | reviewCommentColors, | 2340 | reviewCommentColors, |
| 2341 | themeAppearance, | ||
| 2332 | userBubbleColor, | 2342 | userBubbleColor, |
| 2333 | viewportWidth, | 2343 | viewportWidth, |
| 2334 | ], | 2344 | ], |
| ... | @@ -2754,6 +2764,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2754,6 +2764,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2754 | markdownStyles, | 2764 | markdownStyles, |
| 2755 | reviewCommentColors, | 2765 | reviewCommentColors, |
| 2756 | reviewCommentBubbleWidth, | 2766 | reviewCommentBubbleWidth, |
| 2767 | themeAppearance, | ||
| 2757 | userBubbleMaxWidth, | 2768 | userBubbleMaxWidth, |
| 2758 | skills: props.skills, | 2769 | skills: props.skills, |
| 2759 | onUseArtifactTemplate: props.onUseArtifactTemplate, | 2770 | onUseArtifactTemplate: props.onUseArtifactTemplate, |
| ... | @@ -2774,6 +2785,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2774,6 +2785,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2774 | markdownStyles, | 2785 | markdownStyles, |
| 2775 | reviewCommentColors, | 2786 | reviewCommentColors, |
| 2776 | reviewCommentBubbleWidth, | 2787 | reviewCommentBubbleWidth, |
| 2788 | themeAppearance, | ||
| 2777 | userBubbleMaxWidth, | 2789 | userBubbleMaxWidth, |
| 2778 | onCopyWorkRow, | 2790 | onCopyWorkRow, |
| 2779 | markdownLinkHandlers, | 2791 | markdownLinkHandlers, |
apps/mobile/src/features/threads/thread-work-log.tsx+184-9| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import * as Haptics from "expo-haptics"; | 1 | import * as Haptics from "expo-haptics"; |
| 2 | import { Image } from "expo-image"; | ||
| 2 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; | 3 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; |
| 3 | import { MaskedView } from "@expo/ui/community/masked-view"; | 4 | import { MaskedView } from "@expo/ui/community/masked-view"; |
| 4 | import type { LegendListRef } from "@legendapp/list/react-native"; | 5 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| ... | @@ -26,6 +27,8 @@ import { | ... | @@ -26,6 +27,8 @@ import { |
| 26 | View, | 27 | View, |
| 27 | } from "react-native"; | 28 | } from "react-native"; |
| 28 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; | 29 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 30 | import type { EnvironmentId, ToolActivityIcon } from "@t3tools/contracts"; | ||
| 31 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; | ||
| 29 | 32 | ||
| 30 | import { AppText as Text } from "../../components/AppText"; | 33 | import { AppText as Text } from "../../components/AppText"; |
| 31 | import { T3Wordmark } from "../../components/T3Wordmark"; | 34 | import { T3Wordmark } from "../../components/T3Wordmark"; |
| ... | @@ -58,6 +61,7 @@ import Animated, { | ... | @@ -58,6 +61,7 @@ import Animated, { |
| 58 | withSequence, | 61 | withSequence, |
| 59 | withTiming, | 62 | withTiming, |
| 60 | } from "react-native-reanimated"; | 63 | } from "react-native-reanimated"; |
| 64 | import { useAssetUrl } from "../../state/assets"; | ||
| 61 | 65 | ||
| 62 | const SHIMMER_WIDTH = 72; | 66 | const SHIMMER_WIDTH = 72; |
| 63 | const SHIMMER_SWEEP_MS = 1_350; | 67 | const SHIMMER_SWEEP_MS = 1_350; |
| ... | @@ -134,17 +138,28 @@ export function ThreadDisclosureChevron(props: { | ... | @@ -134,17 +138,28 @@ export function ThreadDisclosureChevron(props: { |
| 134 | } | 138 | } |
| 135 | 139 | ||
| 136 | function ShimmerWorkContent(props: { | 140 | function ShimmerWorkContent(props: { |
| 141 | readonly environmentId?: EnvironmentId; | ||
| 137 | readonly highlighted: boolean; | 142 | readonly highlighted: boolean; |
| 138 | readonly icon: WorkContentIcon; | 143 | readonly icon: WorkContentIcon; |
| 139 | readonly iconSubtleColor: ColorValue; | 144 | readonly iconSubtleColor: ColorValue; |
| 140 | readonly label: string; | 145 | readonly label: string; |
| 141 | readonly onTextLayout?: ComponentProps<typeof Text>["onTextLayout"]; | 146 | readonly onTextLayout?: ComponentProps<typeof Text>["onTextLayout"]; |
| 142 | readonly showIcon: boolean; | 147 | readonly showIcon: boolean; |
| 148 | readonly themeAppearance?: "light" | "dark"; | ||
| 149 | readonly toolIcon?: ToolActivityIcon; | ||
| 143 | }) { | 150 | }) { |
| 144 | return ( | 151 | return ( |
| 145 | <View className="flex-row items-center gap-1.5"> | 152 | <View className="flex-row items-center gap-1.5"> |
| 146 | <View className="h-6 w-6 shrink-0 items-center justify-center"> | 153 | <View className="h-6 w-6 shrink-0 items-center justify-center"> |
| 147 | {props.showIcon ? ( | 154 | {props.showIcon && props.toolIcon && props.environmentId ? ( |
| 155 | <ToolActivityIconView | ||
| 156 | environmentId={props.environmentId} | ||
| 157 | icon={props.toolIcon} | ||
| 158 | fallback={props.icon} | ||
| 159 | fallbackColor={props.iconSubtleColor} | ||
| 160 | themeAppearance={props.themeAppearance ?? "light"} | ||
| 161 | /> | ||
| 162 | ) : props.showIcon ? ( | ||
| 148 | <WorkLogIcon | 163 | <WorkLogIcon |
| 149 | icon={props.icon} | 164 | icon={props.icon} |
| 150 | color={props.iconSubtleColor} | 165 | color={props.iconSubtleColor} |
| ... | @@ -167,10 +182,13 @@ function ShimmerWorkContent(props: { | ... | @@ -167,10 +182,13 @@ function ShimmerWorkContent(props: { |
| 167 | } | 182 | } |
| 168 | 183 | ||
| 169 | export function ShimmeringWorkContent(props: { | 184 | export function ShimmeringWorkContent(props: { |
| 185 | readonly environmentId?: EnvironmentId; | ||
| 170 | readonly icon: WorkContentIcon; | 186 | readonly icon: WorkContentIcon; |
| 171 | readonly iconSubtleColor: ColorValue; | 187 | readonly iconSubtleColor: ColorValue; |
| 172 | readonly label: string; | 188 | readonly label: string; |
| 173 | readonly showIcon: boolean; | 189 | readonly showIcon: boolean; |
| 190 | readonly themeAppearance?: "light" | "dark"; | ||
| 191 | readonly toolIcon?: ToolActivityIcon; | ||
| 174 | }) { | 192 | }) { |
| 175 | const [availableWidth, setAvailableWidth] = useState(0); | 193 | const [availableWidth, setAvailableWidth] = useState(0); |
| 176 | const [textWidth, setTextWidth] = useState(0); | 194 | const [textWidth, setTextWidth] = useState(0); |
| ... | @@ -235,11 +253,14 @@ export function ShimmeringWorkContent(props: { | ... | @@ -235,11 +253,14 @@ export function ShimmeringWorkContent(props: { |
| 235 | onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)} | 253 | onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)} |
| 236 | > | 254 | > |
| 237 | <ShimmerWorkContent | 255 | <ShimmerWorkContent |
| 256 | environmentId={props.environmentId} | ||
| 238 | highlighted={false} | 257 | highlighted={false} |
| 239 | icon={props.icon} | 258 | icon={props.icon} |
| 240 | iconSubtleColor={props.iconSubtleColor} | 259 | iconSubtleColor={props.iconSubtleColor} |
| 241 | label={props.label} | 260 | label={props.label} |
| 242 | showIcon={props.showIcon} | 261 | showIcon={props.showIcon} |
| 262 | themeAppearance={props.themeAppearance} | ||
| 263 | toolIcon={props.toolIcon} | ||
| 243 | onTextLayout={(event) => setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)} | 264 | onTextLayout={(event) => setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)} |
| 244 | /> | 265 | /> |
| 245 | {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? ( | 266 | {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? ( |
| ... | @@ -272,11 +293,14 @@ export function ShimmeringWorkContent(props: { | ... | @@ -272,11 +293,14 @@ export function ShimmeringWorkContent(props: { |
| 272 | > | 293 | > |
| 273 | <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}> | 294 | <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}> |
| 274 | <ShimmerWorkContent | 295 | <ShimmerWorkContent |
| 296 | environmentId={props.environmentId} | ||
| 275 | highlighted | 297 | highlighted |
| 276 | icon={props.icon} | 298 | icon={props.icon} |
| 277 | iconSubtleColor={props.iconSubtleColor} | 299 | iconSubtleColor={props.iconSubtleColor} |
| 278 | label={props.label} | 300 | label={props.label} |
| 279 | showIcon={props.showIcon} | 301 | showIcon={props.showIcon} |
| 302 | themeAppearance={props.themeAppearance} | ||
| 303 | toolIcon={props.toolIcon} | ||
| 280 | /> | 304 | /> |
| 281 | </Animated.View> | 305 | </Animated.View> |
| 282 | </MaskedView> | 306 | </MaskedView> |
| ... | @@ -307,10 +331,14 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { | ... | @@ -307,10 +331,14 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { |
| 307 | return { ios: "sparkles", android: "auto_awesome" }; | 331 | return { ios: "sparkles", android: "auto_awesome" }; |
| 308 | case "alert": | 332 | case "alert": |
| 309 | return { ios: "exclamationmark.triangle", android: "error" }; | 333 | return { ios: "exclamationmark.triangle", android: "error" }; |
| 334 | case "browser": | ||
| 335 | return { ios: "safari", android: "public" }; | ||
| 310 | case "check": | 336 | case "check": |
| 311 | return { ios: "checkmark", android: "check" }; | 337 | return { ios: "checkmark", android: "check" }; |
| 312 | case "command": | 338 | case "command": |
| 313 | return { ios: "terminal", android: "terminal" }; | 339 | return { ios: "terminal", android: "terminal" }; |
| 340 | case "computer": | ||
| 341 | return { ios: "desktopcomputer", android: "desktop_windows" }; | ||
| 314 | case "edit": | 342 | case "edit": |
| 315 | return { ios: "square.and.pencil", android: "edit" }; | 343 | return { ios: "square.and.pencil", android: "edit" }; |
| 316 | case "eye": | 344 | case "eye": |
| ... | @@ -369,11 +397,13 @@ export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActiv | ... | @@ -369,11 +397,13 @@ export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActiv |
| 369 | interface ThreadWorkLogProps { | 397 | interface ThreadWorkLogProps { |
| 370 | readonly activities: ReadonlyArray<ThreadFeedActivity>; | 398 | readonly activities: ReadonlyArray<ThreadFeedActivity>; |
| 371 | readonly anchorKey: string; | 399 | readonly anchorKey: string; |
| 400 | readonly environmentId: EnvironmentId; | ||
| 372 | readonly copiedRowId: string | null; | 401 | readonly copiedRowId: string | null; |
| 373 | readonly expandedRows: Readonly<Record<string, boolean>>; | 402 | readonly expandedRows: Readonly<Record<string, boolean>>; |
| 374 | readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; | 403 | readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; |
| 375 | readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>; | 404 | readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>; |
| 376 | readonly iconSubtleColor: ColorValue; | 405 | readonly iconSubtleColor: ColorValue; |
| 406 | readonly themeAppearance: "light" | "dark"; | ||
| 377 | readonly onCopyRow: (rowId: string, value: string) => void; | 407 | readonly onCopyRow: (rowId: string, value: string) => void; |
| 378 | readonly onToggleRow: (rowId: string, anchorKey: string) => void; | 408 | readonly onToggleRow: (rowId: string, anchorKey: string) => void; |
| 379 | readonly renderImage: MarkdownImageRenderer; | 409 | readonly renderImage: MarkdownImageRenderer; |
| ... | @@ -388,20 +418,24 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { | ... | @@ -388,20 +418,24 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { |
| 388 | anchorKey={props.anchorKey} | 418 | anchorKey={props.anchorKey} |
| 389 | copied={props.copiedRowId === row.id} | 419 | copied={props.copiedRowId === row.id} |
| 390 | expanded={props.expandedRows[row.id] ?? false} | 420 | expanded={props.expandedRows[row.id] ?? false} |
| 421 | environmentId={props.environmentId} | ||
| 391 | iconSubtleColor={props.iconSubtleColor} | 422 | iconSubtleColor={props.iconSubtleColor} |
| 392 | onCopyRow={props.onCopyRow} | 423 | onCopyRow={props.onCopyRow} |
| 393 | onToggleRow={props.onToggleRow} | 424 | onToggleRow={props.onToggleRow} |
| 394 | renderImage={props.renderImage} | 425 | renderImage={props.renderImage} |
| 426 | themeAppearance={props.themeAppearance} | ||
| 395 | /> | 427 | /> |
| 396 | ), | 428 | ), |
| 397 | [ | 429 | [ |
| 398 | props.anchorKey, | 430 | props.anchorKey, |
| 399 | props.copiedRowId, | 431 | props.copiedRowId, |
| 400 | props.expandedRows, | 432 | props.expandedRows, |
| 433 | props.environmentId, | ||
| 401 | props.iconSubtleColor, | 434 | props.iconSubtleColor, |
| 402 | props.onCopyRow, | 435 | props.onCopyRow, |
| 403 | props.onToggleRow, | 436 | props.onToggleRow, |
| 404 | props.renderImage, | 437 | props.renderImage, |
| 438 | props.themeAppearance, | ||
| 405 | ], | 439 | ], |
| 406 | ); | 440 | ); |
| 407 | 441 | ||
| ... | @@ -669,7 +703,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -669,7 +703,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 669 | !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; | 703 | !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; |
| 670 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; | 704 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; |
| 671 | const failed = row.status === "failure"; | 705 | const failed = row.status === "failure"; |
| 672 | const icon = toolPresentation?.icon ?? (failed ? "xmark" : workRowSymbolName(row.icon)); | 706 | const toolIcon = row.workEntry.toolIcon ?? row.workEntry.toolSource?.icon; |
| 707 | const hasSpecialToolIcon = | ||
| 708 | toolPresentation !== null || row.workEntry.toolSurface !== undefined || toolIcon !== undefined; | ||
| 709 | const icon = | ||
| 710 | toolPresentation?.icon ?? | ||
| 711 | (failed && !hasSpecialToolIcon ? "xmark" : workRowSymbolName(row.icon)); | ||
| 673 | 712 | ||
| 674 | return ( | 713 | return ( |
| 675 | <Animated.View | 714 | <Animated.View |
| ... | @@ -697,18 +736,31 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -697,18 +736,31 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 697 | <View className="min-h-8 flex-row items-center gap-1.5"> | 736 | <View className="min-h-8 flex-row items-center gap-1.5"> |
| 698 | {row.live ? ( | 737 | {row.live ? ( |
| 699 | <ShimmeringWorkContent | 738 | <ShimmeringWorkContent |
| 739 | environmentId={props.environmentId} | ||
| 700 | icon={icon} | 740 | icon={icon} |
| 701 | iconSubtleColor={props.iconSubtleColor} | 741 | iconSubtleColor={props.iconSubtleColor} |
| 702 | label={displayText} | 742 | label={displayText} |
| 703 | showIcon | 743 | showIcon |
| 744 | themeAppearance={props.themeAppearance} | ||
| 745 | toolIcon={toolIcon} | ||
| 704 | /> | 746 | /> |
| 705 | ) : ( | 747 | ) : ( |
| 706 | <> | 748 | <> |
| 707 | <View className="h-6 w-6 shrink-0 items-center justify-center"> | 749 | <View className="h-6 w-6 shrink-0 items-center justify-center"> |
| 708 | <WorkLogIcon | 750 | {failed && !hasSpecialToolIcon ? ( |
| 709 | icon={icon} | 751 | <WorkLogIcon |
| 710 | color={iconIsDestructive ? "#e11d48" : props.iconSubtleColor} | 752 | icon={icon} |
| 711 | /> | 753 | color={iconIsDestructive ? "#e11d48" : props.iconSubtleColor} |
| 754 | /> | ||
| 755 | ) : ( | ||
| 756 | <ToolActivityIconView | ||
| 757 | environmentId={props.environmentId} | ||
| 758 | icon={toolIcon} | ||
| 759 | fallback={icon} | ||
| 760 | fallbackColor={props.iconSubtleColor} | ||
| 761 | themeAppearance={props.themeAppearance} | ||
| 762 | /> | ||
| 763 | )} | ||
| 712 | </View> | 764 | </View> |
| 713 | <Text | 765 | <Text |
| 714 | className={cn( | 766 | className={cn( |
| ... | @@ -728,7 +780,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -728,7 +780,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 728 | Copied | 780 | Copied |
| 729 | </Text> | 781 | </Text> |
| 730 | ) : null} | 782 | ) : null} |
| 731 | {failed && toolPresentation ? ( | 783 | {failed && hasSpecialToolIcon ? ( |
| 732 | <View | 784 | <View |
| 733 | className="h-4 w-4 items-center justify-center" | 785 | className="h-4 w-4 items-center justify-center" |
| 734 | accessibilityElementsHidden | 786 | accessibilityElementsHidden |
| ... | @@ -786,6 +838,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -786,6 +838,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 786 | }); | 838 | }); |
| 787 | 839 | ||
| 788 | export function ThreadWorkGroupToggle(props: { | 840 | export function ThreadWorkGroupToggle(props: { |
| 841 | readonly environmentId: EnvironmentId; | ||
| 789 | readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; | 842 | readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; |
| 790 | readonly expanded: boolean; | 843 | readonly expanded: boolean; |
| 791 | readonly hiddenCount: number; | 844 | readonly hiddenCount: number; |
| ... | @@ -793,6 +846,9 @@ export function ThreadWorkGroupToggle(props: { | ... | @@ -793,6 +846,9 @@ export function ThreadWorkGroupToggle(props: { |
| 793 | readonly summary: string; | 846 | readonly summary: string; |
| 794 | readonly summaryKind: ToolGroupSummaryKind; | 847 | readonly summaryKind: ToolGroupSummaryKind; |
| 795 | readonly summaryToolIcon?: "browser" | "t3-code"; | 848 | readonly summaryToolIcon?: "browser" | "t3-code"; |
| 849 | readonly themeAppearance: "light" | "dark"; | ||
| 850 | readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; | ||
| 851 | readonly toolIcon?: ToolActivityIcon; | ||
| 796 | readonly hasFailure: boolean; | 852 | readonly hasFailure: boolean; |
| 797 | readonly shimmer: boolean; | 853 | readonly shimmer: boolean; |
| 798 | readonly onToggle: () => void; | 854 | readonly onToggle: () => void; |
| ... | @@ -800,7 +856,11 @@ export function ThreadWorkGroupToggle(props: { | ... | @@ -800,7 +856,11 @@ export function ThreadWorkGroupToggle(props: { |
| 800 | const accessibilityLabel = props.hasFailure | 856 | const accessibilityLabel = props.hasFailure |
| 801 | ? `${props.summary}, tool call failed` | 857 | ? `${props.summary}, tool call failed` |
| 802 | : props.summary; | 858 | : props.summary; |
| 803 | const icon = props.summaryToolIcon ?? toolGroupSummarySymbolName(props.summaryKind); | 859 | const icon = |
| 860 | props.summaryToolIcon ?? | ||
| 861 | (props.toolSurface | ||
| 862 | ? workRowSymbolName(props.toolSurface) | ||
| 863 | : toolGroupSummarySymbolName(props.summaryKind)); | ||
| 804 | 864 | ||
| 805 | return ( | 865 | return ( |
| 806 | <View className="-mx-1 px-1 py-0"> | 866 | <View className="-mx-1 px-1 py-0"> |
| ... | @@ -820,15 +880,24 @@ export function ThreadWorkGroupToggle(props: { | ... | @@ -820,15 +880,24 @@ export function ThreadWorkGroupToggle(props: { |
| 820 | {props.shimmer ? ( | 880 | {props.shimmer ? ( |
| 821 | <ShimmeringWorkContent | 881 | <ShimmeringWorkContent |
| 822 | key={props.rowSizing.textSizeKey} | 882 | key={props.rowSizing.textSizeKey} |
| 883 | environmentId={props.environmentId} | ||
| 823 | icon={icon} | 884 | icon={icon} |
| 824 | iconSubtleColor={props.iconSubtleColor} | 885 | iconSubtleColor={props.iconSubtleColor} |
| 825 | label={props.summary} | 886 | label={props.summary} |
| 826 | showIcon | 887 | showIcon |
| 888 | themeAppearance={props.themeAppearance} | ||
| 889 | toolIcon={props.toolIcon} | ||
| 827 | /> | 890 | /> |
| 828 | ) : ( | 891 | ) : ( |
| 829 | <> | 892 | <> |
| 830 | <View className="h-6 w-6 items-center justify-center"> | 893 | <View className="h-6 w-6 items-center justify-center"> |
| 831 | <WorkLogIcon icon={icon} color={props.iconSubtleColor} /> | 894 | <ToolActivityIconView |
| 895 | environmentId={props.environmentId} | ||
| 896 | icon={props.toolIcon} | ||
| 897 | fallback={icon} | ||
| 898 | fallbackColor={props.iconSubtleColor} | ||
| 899 | themeAppearance={props.themeAppearance} | ||
| 900 | /> | ||
| 832 | </View> | 901 | </View> |
| 833 | <Text | 902 | <Text |
| 834 | key={props.rowSizing.textSizeKey} | 903 | key={props.rowSizing.textSizeKey} |
| ... | @@ -850,6 +919,112 @@ export function ThreadWorkGroupToggle(props: { | ... | @@ -850,6 +919,112 @@ export function ThreadWorkGroupToggle(props: { |
| 850 | ); | 919 | ); |
| 851 | } | 920 | } |
| 852 | 921 | ||
| 922 | function ToolActivityIconView(props: { | ||
| 923 | readonly environmentId: EnvironmentId; | ||
| 924 | readonly icon?: ToolActivityIcon; | ||
| 925 | readonly fallback: WorkContentIcon; | ||
| 926 | readonly fallbackColor: ColorValue; | ||
| 927 | readonly themeAppearance: "light" | "dark"; | ||
| 928 | }) { | ||
| 929 | if (!props.icon) { | ||
| 930 | return <WorkLogIcon icon={props.fallback} color={props.fallbackColor} />; | ||
| 931 | } | ||
| 932 | if (props.icon._tag === "website") { | ||
| 933 | const source = toolActivityFaviconUrl(props.icon, props.themeAppearance, 32); | ||
| 934 | return source ? ( | ||
| 935 | <ToolActivityImage | ||
| 936 | key={source} | ||
| 937 | source={source} | ||
| 938 | fallback={props.fallback} | ||
| 939 | color={props.fallbackColor} | ||
| 940 | themeAppearance={props.themeAppearance} | ||
| 941 | /> | ||
| 942 | ) : ( | ||
| 943 | <WorkLogIcon icon={props.fallback} color={props.fallbackColor} /> | ||
| 944 | ); | ||
| 945 | } | ||
| 946 | if (props.icon._tag === "themed-logo") { | ||
| 947 | const source = | ||
| 948 | props.themeAppearance === "dark" | ||
| 949 | ? (props.icon.logoUrlDark ?? props.icon.logoUrl) | ||
| 950 | : props.icon.logoUrl; | ||
| 951 | return ( | ||
| 952 | <ToolActivityImage | ||
| 953 | key={source} | ||
| 954 | source={source} | ||
| 955 | fallback={props.fallback} | ||
| 956 | color={props.fallbackColor} | ||
| 957 | themeAppearance={props.themeAppearance} | ||
| 958 | /> | ||
| 959 | ); | ||
| 960 | } | ||
| 961 | return ( | ||
| 962 | <NativeAppToolActivityIcon | ||
| 963 | environmentId={props.environmentId} | ||
| 964 | app={props.icon.app} | ||
| 965 | fallback={props.fallback} | ||
| 966 | color={props.fallbackColor} | ||
| 967 | themeAppearance={props.themeAppearance} | ||
| 968 | /> | ||
| 969 | ); | ||
| 970 | } | ||
| 971 | |||
| 972 | function NativeAppToolActivityIcon(props: { | ||
| 973 | readonly environmentId: EnvironmentId; | ||
| 974 | readonly app: Extract<ToolActivityIcon, { readonly _tag: "native-app" }>["app"]; | ||
| 975 | readonly fallback: WorkContentIcon; | ||
| 976 | readonly color: ColorValue; | ||
| 977 | readonly themeAppearance: "light" | "dark"; | ||
| 978 | }) { | ||
| 979 | const source = useAssetUrl(props.environmentId, { | ||
| 980 | _tag: "native-app-icon", | ||
| 981 | app: props.app, | ||
| 982 | }); | ||
| 983 | return source ? ( | ||
| 984 | <ToolActivityImage | ||
| 985 | key={source} | ||
| 986 | source={source} | ||
| 987 | fallback={props.fallback} | ||
| 988 | color={props.color} | ||
| 989 | themeAppearance={props.themeAppearance} | ||
| 990 | /> | ||
| 991 | ) : ( | ||
| 992 | <WorkLogIcon icon={props.fallback} color={props.color} /> | ||
| 993 | ); | ||
| 994 | } | ||
| 995 | |||
| 996 | function ToolActivityImage(props: { | ||
| 997 | readonly source: string; | ||
| 998 | readonly fallback: WorkContentIcon; | ||
| 999 | readonly color: ColorValue; | ||
| 1000 | readonly themeAppearance: "light" | "dark"; | ||
| 1001 | }) { | ||
| 1002 | const [loaded, setLoaded] = useState(false); | ||
| 1003 | const [failed, setFailed] = useState(false); | ||
| 1004 | return ( | ||
| 1005 | <View className="h-4 w-4 items-center justify-center overflow-hidden rounded-[3px] bg-background"> | ||
| 1006 | {!loaded || failed ? <WorkLogIcon icon={props.fallback} color={props.color} /> : null} | ||
| 1007 | {!failed ? ( | ||
| 1008 | <View | ||
| 1009 | style={[ | ||
| 1010 | StyleSheet.absoluteFill, | ||
| 1011 | props.themeAppearance === "light" ? { filter: [{ brightness: 0.6 }] } : undefined, | ||
| 1012 | ]} | ||
| 1013 | > | ||
| 1014 | <Image | ||
| 1015 | source={props.source} | ||
| 1016 | cachePolicy="memory-disk" | ||
| 1017 | contentFit="contain" | ||
| 1018 | style={[StyleSheet.absoluteFill, { opacity: loaded ? 0.7 : 0 }]} | ||
| 1019 | onLoad={() => setLoaded(true)} | ||
| 1020 | onError={() => setFailed(true)} | ||
| 1021 | /> | ||
| 1022 | </View> | ||
| 1023 | ) : null} | ||
| 1024 | </View> | ||
| 1025 | ); | ||
| 1026 | } | ||
| 1027 | |||
| 853 | function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { | 1028 | function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { |
| 854 | switch (kind) { | 1029 | switch (kind) { |
| 855 | case "read": | 1030 | case "read": |
apps/mobile/src/lib/threadActivity.test.ts+43-3| ... | @@ -800,6 +800,20 @@ describe("buildThreadFeed", () => { | ... | @@ -800,6 +800,20 @@ describe("buildThreadFeed", () => { |
| 800 | payload: { | 800 | payload: { |
| 801 | title: "Call repository tool", | 801 | title: "Call repository tool", |
| 802 | itemType: "mcp_tool_call", | 802 | itemType: "mcp_tool_call", |
| 803 | toolSurface: "computer", | ||
| 804 | toolIcon: { | ||
| 805 | _tag: "native-app", | ||
| 806 | app: { _tag: "app-id", appId: "com.example.Editor" }, | ||
| 807 | }, | ||
| 808 | toolSource: { | ||
| 809 | key: "native-app:com.example.editor", | ||
| 810 | name: "Computer Use", | ||
| 811 | kind: "computer", | ||
| 812 | icon: { | ||
| 813 | _tag: "native-app", | ||
| 814 | app: { _tag: "app-id", appId: "com.example.Editor" }, | ||
| 815 | }, | ||
| 816 | }, | ||
| 803 | detail: "repository.search", | 817 | detail: "repository.search", |
| 804 | status: "completed", | 818 | status: "completed", |
| 805 | data: { | 819 | data: { |
| ... | @@ -820,7 +834,21 @@ describe("buildThreadFeed", () => { | ... | @@ -820,7 +834,21 @@ describe("buildThreadFeed", () => { |
| 820 | return; | 834 | return; |
| 821 | } | 835 | } |
| 822 | 836 | ||
| 823 | expect(group.activities[0]?.icon).toBe("wrench"); | 837 | expect(group.activities[0]?.icon).toBe("computer"); |
| 838 | expect(group.activities[0]?.workEntry.toolSurface).toBe("computer"); | ||
| 839 | expect(group.activities[0]?.workEntry.toolIcon).toEqual({ | ||
| 840 | _tag: "native-app", | ||
| 841 | app: { _tag: "app-id", appId: "com.example.Editor" }, | ||
| 842 | }); | ||
| 843 | expect(group.activities[0]?.workEntry.toolSource).toEqual({ | ||
| 844 | key: "native-app:com.example.editor", | ||
| 845 | name: "Computer Use", | ||
| 846 | kind: "computer", | ||
| 847 | icon: { | ||
| 848 | _tag: "native-app", | ||
| 849 | app: { _tag: "app-id", appId: "com.example.Editor" }, | ||
| 850 | }, | ||
| 851 | }); | ||
| 824 | expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"'); | 852 | expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"'); |
| 825 | expect(group.activities[0]?.getFullDetail()).toContain("repository.search"); | 853 | expect(group.activities[0]?.getFullDetail()).toContain("repository.search"); |
| 826 | }); | 854 | }); |
| ... | @@ -1600,6 +1628,8 @@ describe("buildThreadFeed", () => { | ... | @@ -1600,6 +1628,8 @@ describe("buildThreadFeed", () => { |
| 1600 | id: string, | 1628 | id: string, |
| 1601 | createdAt: string, | 1629 | createdAt: string, |
| 1602 | status: ThreadFeedActivity["status"] = "success", | 1630 | status: ThreadFeedActivity["status"] = "success", |
| 1631 | toolSurface?: "browser" | "computer", | ||
| 1632 | toolIcon?: import("@t3tools/contracts").ToolActivityIcon, | ||
| 1603 | ): ThreadFeedActivity => ({ | 1633 | ): ThreadFeedActivity => ({ |
| 1604 | id, | 1634 | id, |
| 1605 | createdAt, | 1635 | createdAt, |
| ... | @@ -1619,6 +1649,8 @@ describe("buildThreadFeed", () => { | ... | @@ -1619,6 +1649,8 @@ describe("buildThreadFeed", () => { |
| 1619 | label: `Tool ${id}`, | 1649 | label: `Tool ${id}`, |
| 1620 | command: `command ${id}`, | 1650 | command: `command ${id}`, |
| 1621 | tone: "tool", | 1651 | tone: "tool", |
| 1652 | ...(toolSurface ? { toolSurface } : {}), | ||
| 1653 | ...(toolIcon ? { toolIcon } : {}), | ||
| 1622 | }, | 1654 | }, |
| 1623 | }); | 1655 | }); |
| 1624 | const feed: ThreadFeedEntry[] = [ | 1656 | const feed: ThreadFeedEntry[] = [ |
| ... | @@ -1630,8 +1662,11 @@ describe("buildThreadFeed", () => { | ... | @@ -1630,8 +1662,11 @@ describe("buildThreadFeed", () => { |
| 1630 | activities: [ | 1662 | activities: [ |
| 1631 | activity("activity-1", "2026-04-01T00:00:01.000Z"), | 1663 | activity("activity-1", "2026-04-01T00:00:01.000Z"), |
| 1632 | activity("activity-neutral", "2026-04-01T00:00:02.000Z", "neutral"), | 1664 | activity("activity-neutral", "2026-04-01T00:00:02.000Z", "neutral"), |
| 1633 | activity("activity-2", "2026-04-01T00:00:03.000Z"), | 1665 | activity("activity-2", "2026-04-01T00:00:03.000Z", "success", "browser"), |
| 1634 | activity("activity-3", "2026-04-01T00:00:04.000Z"), | 1666 | activity("activity-3", "2026-04-01T00:00:04.000Z", "success", "computer", { |
| 1667 | _tag: "native-app", | ||
| 1668 | app: { _tag: "app-id", appId: "com.example.Editor" }, | ||
| 1669 | }), | ||
| 1635 | ], | 1670 | ], |
| 1636 | }, | 1671 | }, |
| 1637 | ]; | 1672 | ]; |
| ... | @@ -1644,6 +1679,11 @@ describe("buildThreadFeed", () => { | ... | @@ -1644,6 +1679,11 @@ describe("buildThreadFeed", () => { |
| 1644 | hiddenCount: 3, | 1679 | hiddenCount: 3, |
| 1645 | expanded: false, | 1680 | expanded: false, |
| 1646 | summary: "Ran 3 commands", | 1681 | summary: "Ran 3 commands", |
| 1682 | toolSurface: "computer", | ||
| 1683 | toolIcon: { | ||
| 1684 | _tag: "native-app", | ||
| 1685 | app: { _tag: "app-id", appId: "com.example.Editor" }, | ||
| 1686 | }, | ||
| 1647 | }); | 1687 | }); |
| 1648 | 1688 | ||
| 1649 | const expanded = deriveThreadFeedPresentation( | 1689 | const expanded = deriveThreadFeedPresentation( |
apps/mobile/src/lib/threadActivity.ts+48| ... | @@ -26,6 +26,7 @@ import { | ... | @@ -26,6 +26,7 @@ import { |
| 26 | toolGroupSummaryKind, | 26 | toolGroupSummaryKind, |
| 27 | type ToolGroupSummaryKind, | 27 | type ToolGroupSummaryKind, |
| 28 | } from "@t3tools/client-runtime/work-log/presentation"; | 28 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 29 | import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; | ||
| 29 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; | 30 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; |
| 30 | 31 | ||
| 31 | import * as Arr from "effect/Array"; | 32 | import * as Arr from "effect/Array"; |
| ... | @@ -67,8 +68,10 @@ export interface ThreadFeedActivity { | ... | @@ -67,8 +68,10 @@ export interface ThreadFeedActivity { |
| 67 | readonly icon: | 68 | readonly icon: |
| 68 | | "agent" | 69 | | "agent" |
| 69 | | "alert" | 70 | | "alert" |
| 71 | | "browser" | ||
| 70 | | "check" | 72 | | "check" |
| 71 | | "command" | 73 | | "command" |
| 74 | | "computer" | ||
| 72 | | "edit" | 75 | | "edit" |
| 73 | | "eye" | 76 | | "eye" |
| 74 | | "globe" | 77 | | "globe" |
| ... | @@ -99,6 +102,9 @@ export interface WorkLogEntry { | ... | @@ -99,6 +102,9 @@ export interface WorkLogEntry { |
| 99 | changedFiles?: ReadonlyArray<string>; | 102 | changedFiles?: ReadonlyArray<string>; |
| 100 | tone: "thinking" | "tool" | "info" | "error"; | 103 | tone: "thinking" | "tool" | "info" | "error"; |
| 101 | toolTitle?: string; | 104 | toolTitle?: string; |
| 105 | toolSurface?: import("@t3tools/contracts").ToolActivitySurface; | ||
| 106 | toolIcon?: import("@t3tools/contracts").ToolActivityIcon; | ||
| 107 | toolSource?: import("@t3tools/contracts").ToolActivitySource; | ||
| 102 | itemType?: ToolLifecycleItemType; | 108 | itemType?: ToolLifecycleItemType; |
| 103 | requestKind?: PendingApproval["requestKind"]; | 109 | requestKind?: PendingApproval["requestKind"]; |
| 104 | toolLifecycleStatus?: WorkLogToolLifecycleStatus; | 110 | toolLifecycleStatus?: WorkLogToolLifecycleStatus; |
| ... | @@ -149,6 +155,8 @@ export type ThreadFeedEntry = | ... | @@ -149,6 +155,8 @@ export type ThreadFeedEntry = |
| 149 | readonly expanded: boolean; | 155 | readonly expanded: boolean; |
| 150 | readonly summary: string; | 156 | readonly summary: string; |
| 151 | readonly summaryKind: ToolGroupSummaryKind; | 157 | readonly summaryKind: ToolGroupSummaryKind; |
| 158 | readonly toolSurface?: WorkLogEntry["toolSurface"]; | ||
| 159 | readonly toolIcon?: WorkLogEntry["toolIcon"]; | ||
| 152 | readonly summaryToolIcon?: "browser" | "t3-code"; | 160 | readonly summaryToolIcon?: "browser" | "t3-code"; |
| 153 | readonly hasFailure: boolean; | 161 | readonly hasFailure: boolean; |
| 154 | readonly live: boolean; | 162 | readonly live: boolean; |
| ... | @@ -398,6 +406,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -398,6 +406,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 398 | const commandPreview = extractToolCommand(payload); | 406 | const commandPreview = extractToolCommand(payload); |
| 399 | const changedFiles = extractChangedFiles(payload); | 407 | const changedFiles = extractChangedFiles(payload); |
| 400 | const title = extractToolTitle(payload); | 408 | const title = extractToolTitle(payload); |
| 409 | const toolPresentation = extractToolActivityPresentation(payload); | ||
| 401 | // task.updated included: terminal bypassed updates (Codex children's only | 410 | // task.updated included: terminal bypassed updates (Codex children's only |
| 402 | // terminal signal) must carry task identity so they collapse per child | 411 | // terminal signal) must carry task identity so they collapse per child |
| 403 | // instead of stacking anonymous "Task idle" rows. | 412 | // instead of stacking anonymous "Task idle" rows. |
| ... | @@ -479,6 +488,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -479,6 +488,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 479 | if (title) { | 488 | if (title) { |
| 480 | entry.toolTitle = title; | 489 | entry.toolTitle = title; |
| 481 | } | 490 | } |
| 491 | if (toolPresentation.toolSurface) { | ||
| 492 | entry.toolSurface = toolPresentation.toolSurface; | ||
| 493 | } | ||
| 494 | if (toolPresentation.toolIcon) { | ||
| 495 | entry.toolIcon = toolPresentation.toolIcon; | ||
| 496 | } | ||
| 497 | if (toolPresentation.toolSource) { | ||
| 498 | entry.toolSource = toolPresentation.toolSource; | ||
| 499 | } | ||
| 482 | if (itemType === "mcp_tool_call") { | 500 | if (itemType === "mcp_tool_call") { |
| 483 | const data = asRecord(payload?.data); | 501 | const data = asRecord(payload?.data); |
| 484 | const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; | 502 | const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; |
| ... | @@ -614,6 +632,9 @@ function mergeDerivedWorkLogEntries( | ... | @@ -614,6 +632,9 @@ function mergeDerivedWorkLogEntries( |
| 614 | const command = next.command ?? previous.command; | 632 | const command = next.command ?? previous.command; |
| 615 | const rawCommand = next.rawCommand ?? previous.rawCommand; | 633 | const rawCommand = next.rawCommand ?? previous.rawCommand; |
| 616 | const toolTitle = next.toolTitle ?? previous.toolTitle; | 634 | const toolTitle = next.toolTitle ?? previous.toolTitle; |
| 635 | const toolSurface = next.toolSurface ?? previous.toolSurface; | ||
| 636 | const toolIcon = next.toolIcon ?? previous.toolIcon; | ||
| 637 | const toolSource = next.toolSource ?? previous.toolSource; | ||
| 617 | const itemType = next.itemType ?? previous.itemType; | 638 | const itemType = next.itemType ?? previous.itemType; |
| 618 | const requestKind = next.requestKind ?? previous.requestKind; | 639 | const requestKind = next.requestKind ?? previous.requestKind; |
| 619 | const collapseKey = next.collapseKey ?? previous.collapseKey; | 640 | const collapseKey = next.collapseKey ?? previous.collapseKey; |
| ... | @@ -631,6 +652,9 @@ function mergeDerivedWorkLogEntries( | ... | @@ -631,6 +652,9 @@ function mergeDerivedWorkLogEntries( |
| 631 | ...(rawCommand ? { rawCommand } : {}), | 652 | ...(rawCommand ? { rawCommand } : {}), |
| 632 | ...(changedFiles.length > 0 ? { changedFiles } : {}), | 653 | ...(changedFiles.length > 0 ? { changedFiles } : {}), |
| 633 | ...(toolTitle ? { toolTitle } : {}), | 654 | ...(toolTitle ? { toolTitle } : {}), |
| 655 | ...(toolSurface ? { toolSurface } : {}), | ||
| 656 | ...(toolIcon ? { toolIcon } : {}), | ||
| 657 | ...(toolSource ? { toolSource } : {}), | ||
| 634 | ...(itemType ? { itemType } : {}), | 658 | ...(itemType ? { itemType } : {}), |
| 635 | ...(requestKind ? { requestKind } : {}), | 659 | ...(requestKind ? { requestKind } : {}), |
| 636 | ...(collapseKey ? { collapseKey } : {}), | 660 | ...(collapseKey ? { collapseKey } : {}), |
| ... | @@ -752,6 +776,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { | ... | @@ -752,6 +776,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { |
| 752 | return "message"; | 776 | return "message"; |
| 753 | } | 777 | } |
| 754 | if (entry.sourceActivityKind === "runtime.warning") return "warning"; | 778 | if (entry.sourceActivityKind === "runtime.warning") return "warning"; |
| 779 | if (entry.toolSurface) return entry.toolSurface; | ||
| 755 | if (entry.requestKind === "command") return "command"; | 780 | if (entry.requestKind === "command") return "command"; |
| 756 | if (entry.requestKind === "file-read") return "eye"; | 781 | if (entry.requestKind === "file-read") return "eye"; |
| 757 | if (entry.requestKind === "file-change") return "edit"; | 782 | if (entry.requestKind === "file-change") return "edit"; |
| ... | @@ -1537,6 +1562,27 @@ function appendToolGroupRows( | ... | @@ -1537,6 +1562,27 @@ function appendToolGroupRows( |
| 1537 | : singleActivity !== null && !singleActivity.toolLike | 1562 | : singleActivity !== null && !singleActivity.toolLike |
| 1538 | ? singleActivity.workEntry.label | 1563 | ? singleActivity.workEntry.label |
| 1539 | : summarizeToolGroup(activities.map((activity) => activity.workEntry)); | 1564 | : summarizeToolGroup(activities.map((activity) => activity.workEntry)); |
| 1565 | const primarySourceActivity = activities.find( | ||
| 1566 | (activity) => activity.workEntry.toolSource !== undefined, | ||
| 1567 | ); | ||
| 1568 | const primarySourceKey = primarySourceActivity?.workEntry.toolSource?.key; | ||
| 1569 | const primarySourceIcon = primarySourceKey | ||
| 1570 | ? (activities.find( | ||
| 1571 | (activity) => | ||
| 1572 | activity.workEntry.toolSource?.key === primarySourceKey && | ||
| 1573 | activity.workEntry.toolIcon !== undefined, | ||
| 1574 | )?.workEntry.toolIcon ?? primarySourceActivity?.workEntry.toolSource?.icon) | ||
| 1575 | : undefined; | ||
| 1576 | const groupToolSurface = | ||
| 1577 | primarySourceActivity?.workEntry.toolSurface ?? | ||
| 1578 | latestActivity.workEntry.toolSurface ?? | ||
| 1579 | activities.findLast((activity) => activity.workEntry.toolSurface !== undefined)?.workEntry | ||
| 1580 | .toolSurface; | ||
| 1581 | const groupToolIcon = | ||
| 1582 | primarySourceIcon ?? | ||
| 1583 | latestActivity.workEntry.toolIcon ?? | ||
| 1584 | activities.findLast((activity) => activity.workEntry.toolIcon !== undefined)?.workEntry | ||
| 1585 | .toolIcon; | ||
| 1540 | const summaryToolIcon = live | 1586 | const summaryToolIcon = live |
| 1541 | ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon | 1587 | ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon |
| 1542 | : singleActivity !== null && | 1588 | : singleActivity !== null && |
| ... | @@ -1556,6 +1602,8 @@ function appendToolGroupRows( | ... | @@ -1556,6 +1602,8 @@ function appendToolGroupRows( |
| 1556 | summaryKind: toolGroupSummaryKind( | 1602 | summaryKind: toolGroupSummaryKind( |
| 1557 | (live ? [latestActivity] : activities).map((activity) => activity.workEntry), | 1603 | (live ? [latestActivity] : activities).map((activity) => activity.workEntry), |
| 1558 | ), | 1604 | ), |
| 1605 | ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}), | ||
| 1606 | ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}), | ||
| 1559 | ...(summaryToolIcon ? { summaryToolIcon } : {}), | 1607 | ...(summaryToolIcon ? { summaryToolIcon } : {}), |
| 1560 | hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", | 1608 | hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", |
| 1561 | live, | 1609 | live, |
apps/server/src/assets/AssetAccess.test.ts+17| ... | @@ -22,6 +22,7 @@ import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; | ... | @@ -22,6 +22,7 @@ import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; |
| 22 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 22 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 23 | import { assetFileResponse } from "../http.ts"; | 23 | import { assetFileResponse } from "../http.ts"; |
| 24 | import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; | 24 | import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; |
| 25 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; | ||
| 25 | import { openMediaFile } from "./MediaFile.ts"; | 26 | import { openMediaFile } from "./MediaFile.ts"; |
| 26 | 27 | ||
| 27 | vi.mock("node:fs/promises", async (importOriginal) => { | 28 | vi.mock("node:fs/promises", async (importOriginal) => { |
| ... | @@ -40,6 +41,7 @@ const testLayer = Layer.mergeAll( | ... | @@ -40,6 +41,7 @@ const testLayer = Layer.mergeAll( |
| 40 | Layer.provide(WorkspacePaths.layer), | 41 | Layer.provide(WorkspacePaths.layer), |
| 41 | Layer.provide(T3ProjectFileLoader.layer), | 42 | Layer.provide(T3ProjectFileLoader.layer), |
| 42 | ), | 43 | ), |
| 44 | NativeAppIconResolver.layer.pipe(Layer.provide(configLayer)), | ||
| 43 | ServerSecretStore.layer.pipe(Layer.provide(configLayer)), | 45 | ServerSecretStore.layer.pipe(Layer.provide(configLayer)), |
| 44 | ).pipe(Layer.provideMerge(NodeServices.layer)); | 46 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 45 | 47 | ||
| ... | @@ -576,6 +578,21 @@ describe("AssetAccess", () => { | ... | @@ -576,6 +578,21 @@ describe("AssetAccess", () => { |
| 576 | }); | 578 | }); |
| 577 | }).pipe(Effect.provide(testLayer)), | 579 | }).pipe(Effect.provide(testLayer)), |
| 578 | ); | 580 | ); |
| 581 | it.effect("issues signed native application icon capabilities", () => | ||
| 582 | Effect.gen(function* () { | ||
| 583 | const result = yield* issueAssetUrl({ | ||
| 584 | resource: { | ||
| 585 | _tag: "native-app-icon", | ||
| 586 | app: { _tag: "app-id", appId: "com.example.Editor" }, | ||
| 587 | }, | ||
| 588 | }); | ||
| 589 | |||
| 590 | expect(result.relativeUrl).toMatch( | ||
| 591 | new RegExp(`^${ASSET_ROUTE_PREFIX}/[^/]+/native-app-icon\\.png$`, "u"), | ||
| 592 | ); | ||
| 593 | expect(result.expiresAt).toBeGreaterThan(0); | ||
| 594 | }).pipe(Effect.provide(testLayer)), | ||
| 595 | ); | ||
| 579 | 596 | ||
| 580 | it.effect("serves document attachments inline when a viewer requests it", () => | 597 | it.effect("serves document attachments inline when a viewer requests it", () => |
| 581 | Effect.gen(function* () { | 598 | Effect.gen(function* () { |
apps/server/src/assets/AssetAccess.ts+24| ... | @@ -12,6 +12,7 @@ import { | ... | @@ -12,6 +12,7 @@ import { |
| 12 | AssetWorkspacePathValidationError, | 12 | AssetWorkspacePathValidationError, |
| 13 | AssetWorkspaceResolutionError, | 13 | AssetWorkspaceResolutionError, |
| 14 | AssetWorkspaceRootNormalizationError, | 14 | AssetWorkspaceRootNormalizationError, |
| 15 | ToolActivityNativeAppReference, | ||
| 15 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 16 | import { | 17 | import { |
| 17 | hostPreviewMimeTypeFromExtension, | 18 | hostPreviewMimeTypeFromExtension, |
| ... | @@ -42,6 +43,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta | ... | @@ -42,6 +43,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta |
| 42 | import * as ServerConfig from "../config.ts"; | 43 | import * as ServerConfig from "../config.ts"; |
| 43 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; | 44 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 44 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 45 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 46 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; | ||
| 45 | import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts"; | 47 | import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts"; |
| 46 | 48 | ||
| 47 | export const ASSET_ROUTE_PREFIX = "/api/assets"; | 49 | export const ASSET_ROUTE_PREFIX = "/api/assets"; |
| ... | @@ -120,6 +122,12 @@ const AssetClaimsSchema = Schema.Union([ | ... | @@ -120,6 +122,12 @@ const AssetClaimsSchema = Schema.Union([ |
| 120 | filePath: Schema.String, | 122 | filePath: Schema.String, |
| 121 | expiresAt: Schema.Number, | 123 | expiresAt: Schema.Number, |
| 122 | }), | 124 | }), |
| 125 | Schema.Struct({ | ||
| 126 | version: Schema.Literal(1), | ||
| 127 | kind: Schema.Literal("native-app-icon"), | ||
| 128 | app: ToolActivityNativeAppReference, | ||
| 129 | expiresAt: Schema.Number, | ||
| 130 | }), | ||
| 123 | ]); | 131 | ]); |
| 124 | type AssetClaims = typeof AssetClaimsSchema.Type; | 132 | type AssetClaims = typeof AssetClaimsSchema.Type; |
| 125 | 133 | ||
| ... | @@ -504,6 +512,16 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -504,6 +512,16 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 504 | } | 512 | } |
| 505 | break; | 513 | break; |
| 506 | } | 514 | } |
| 515 | case "native-app-icon": { | ||
| 516 | claims = { | ||
| 517 | version: 1, | ||
| 518 | kind: "native-app-icon", | ||
| 519 | app: input.resource.app, | ||
| 520 | expiresAt, | ||
| 521 | }; | ||
| 522 | fileName = "native-app-icon.png"; | ||
| 523 | break; | ||
| 524 | } | ||
| 507 | } | 525 | } |
| 508 | 526 | ||
| 509 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | 527 | const secretStore = yield* ServerSecretStore.ServerSecretStore; |
| ... | @@ -603,6 +621,12 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( | ... | @@ -603,6 +621,12 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 603 | : null; | 621 | : null; |
| 604 | } | 622 | } |
| 605 | 623 | ||
| 624 | if (claims.kind === "native-app-icon") { | ||
| 625 | const nativeAppIconResolver = yield* NativeAppIconResolver.NativeAppIconResolver; | ||
| 626 | const iconPath = yield* nativeAppIconResolver.resolve(claims.app); | ||
| 627 | return iconPath ? ({ kind: "file", path: iconPath } satisfies ResolvedAsset) : null; | ||
| 628 | } | ||
| 629 | |||
| 606 | const decodedPath = decodeRelativePath(relativePath); | 630 | const decodedPath = decodeRelativePath(relativePath); |
| 607 | if (decodedPath === null) return null; | 631 | if (decodedPath === null) return null; |
| 608 | const path = yield* Path.Path; | 632 | const path = yield* Path.Path; |
apps/server/src/assets/NativeAppIconResolver.test.ts created+75| ... | @@ -0,0 +1,75 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Sink from "effect/Sink"; | ||
| 6 | import * as Stream from "effect/Stream"; | ||
| 7 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 9 | |||
| 10 | import * as ServerConfig from "../config.ts"; | ||
| 11 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; | ||
| 12 | |||
| 13 | function emptyProcessHandle() { | ||
| 14 | return ChildProcessSpawner.makeHandle({ | ||
| 15 | pid: ChildProcessSpawner.ProcessId(1), | ||
| 16 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), | ||
| 17 | isRunning: Effect.succeed(false), | ||
| 18 | kill: () => Effect.void, | ||
| 19 | unref: Effect.succeed(Effect.void), | ||
| 20 | stdin: Sink.drain, | ||
| 21 | stdout: Stream.empty, | ||
| 22 | stderr: Stream.empty, | ||
| 23 | all: Stream.empty, | ||
| 24 | getInputFd: () => Sink.drain, | ||
| 25 | getOutputFd: () => Stream.empty, | ||
| 26 | }); | ||
| 27 | } | ||
| 28 | |||
| 29 | describe("resolveNativeAppIcon", () => { | ||
| 30 | it.effect("escapes Spotlight wildcards and caches misses", () => { | ||
| 31 | const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = []; | ||
| 32 | const spawner = ChildProcessSpawner.make((command) => | ||
| 33 | Effect.sync(() => { | ||
| 34 | const input = command as unknown as { | ||
| 35 | readonly command: string; | ||
| 36 | readonly args: ReadonlyArray<string>; | ||
| 37 | }; | ||
| 38 | commands.push(input); | ||
| 39 | return emptyProcessHandle(); | ||
| 40 | }), | ||
| 41 | ); | ||
| 42 | const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { | ||
| 43 | prefix: "t3-native-app-icon-test-", | ||
| 44 | }); | ||
| 45 | const dependencies = Layer.mergeAll( | ||
| 46 | configLayer, | ||
| 47 | Layer.succeed(HostProcessPlatform, "darwin"), | ||
| 48 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 49 | ).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 50 | const testLayer = NativeAppIconResolver.layer.pipe(Layer.provide(dependencies)); | ||
| 51 | const app = { _tag: "display-name", displayName: "Review * App" } as const; | ||
| 52 | |||
| 53 | return Effect.gen(function* () { | ||
| 54 | const resolver = yield* NativeAppIconResolver.NativeAppIconResolver; | ||
| 55 | expect(yield* resolver.resolve(app)).toBeNull(); | ||
| 56 | expect(yield* resolver.resolve(app)).toBeNull(); | ||
| 57 | |||
| 58 | expect(commands).toHaveLength(1); | ||
| 59 | expect(commands[0]).toMatchObject({ command: "/usr/bin/mdfind" }); | ||
| 60 | expect(commands[0]?.args[0]).toContain("Review \\* App"); | ||
| 61 | |||
| 62 | for (let index = 0; index < 256; index += 1) { | ||
| 63 | expect( | ||
| 64 | yield* resolver.resolve({ | ||
| 65 | _tag: "display-name", | ||
| 66 | displayName: `Missing Review App ${index}`, | ||
| 67 | }), | ||
| 68 | ).toBeNull(); | ||
| 69 | } | ||
| 70 | expect(commands).toHaveLength(257); | ||
| 71 | expect(yield* resolver.resolve(app)).toBeNull(); | ||
| 72 | expect(commands).toHaveLength(258); | ||
| 73 | }).pipe(Effect.provide(testLayer)); | ||
| 74 | }); | ||
| 75 | }); | ||
apps/server/src/assets/NativeAppIconResolver.ts created+268| ... | @@ -0,0 +1,268 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | import type { ToolActivityNativeAppReference } from "@t3tools/contracts"; | ||
| 3 | import * as Cache from "effect/Cache"; | ||
| 4 | import * as Cause from "effect/Cause"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Clock from "effect/Clock"; | ||
| 7 | import * as Context from "effect/Context"; | ||
| 8 | import * as Duration from "effect/Duration"; | ||
| 9 | import * as Exit from "effect/Exit"; | ||
| 10 | import * as FileSystem from "effect/FileSystem"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Path from "effect/Path"; | ||
| 14 | import * as PlatformError from "effect/PlatformError"; | ||
| 15 | import * as Semaphore from "effect/Semaphore"; | ||
| 16 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 17 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 18 | |||
| 19 | import * as ServerConfig from "../config.ts"; | ||
| 20 | |||
| 21 | const ICON_SIZE = 64; | ||
| 22 | const COMMAND_TIMEOUT = "5 seconds"; | ||
| 23 | const RESOLUTION_CACHE_TTL = Duration.hours(1); | ||
| 24 | const RESOLUTION_CACHE_MAX_ENTRIES = 256; | ||
| 25 | |||
| 26 | /** Resolves and caches macOS application icons without exposing host paths to clients. */ | ||
| 27 | export class NativeAppIconResolver extends Context.Service< | ||
| 28 | NativeAppIconResolver, | ||
| 29 | { | ||
| 30 | /** Returns a cached PNG path for the application, or `null` when no icon is available. */ | ||
| 31 | readonly resolve: (app: ToolActivityNativeAppReference) => Effect.Effect<string | null>; | ||
| 32 | } | ||
| 33 | >()("t3/assets/NativeAppIconResolver") {} | ||
| 34 | |||
| 35 | function appCacheKey(app: ToolActivityNativeAppReference): string { | ||
| 36 | return JSON.stringify(app); | ||
| 37 | } | ||
| 38 | |||
| 39 | function appFromCacheKey(key: string): ToolActivityNativeAppReference { | ||
| 40 | return JSON.parse(key) as ToolActivityNativeAppReference; | ||
| 41 | } | ||
| 42 | |||
| 43 | const existingFile = Effect.fn("NativeAppIconResolver.existingFile")(function* (filePath: string) { | ||
| 44 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 45 | const info = yield* fileSystem.stat(filePath).pipe( | ||
| 46 | Effect.map(Option.some), | ||
| 47 | Effect.catchTags({ | ||
| 48 | PlatformError: (error) => | ||
| 49 | error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), | ||
| 50 | }), | ||
| 51 | ); | ||
| 52 | return Option.isSome(info) && info.value.type === "File" ? filePath : null; | ||
| 53 | }); | ||
| 54 | |||
| 55 | const commandOutput = Effect.fn("NativeAppIconResolver.commandOutput")(function* ( | ||
| 56 | command: string, | ||
| 57 | args: ReadonlyArray<string>, | ||
| 58 | ) { | ||
| 59 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 60 | return yield* spawner | ||
| 61 | .string(ChildProcess.make(command, args, { stdin: "ignore", stderr: "ignore" })) | ||
| 62 | .pipe(Effect.timeout(COMMAND_TIMEOUT)); | ||
| 63 | }); | ||
| 64 | |||
| 65 | const plistValue = Effect.fn("NativeAppIconResolver.plistValue")(function* ( | ||
| 66 | infoPlistPath: string, | ||
| 67 | key: string, | ||
| 68 | ) { | ||
| 69 | return yield* commandOutput("/usr/bin/plutil", [ | ||
| 70 | "-extract", | ||
| 71 | key, | ||
| 72 | "raw", | ||
| 73 | "-o", | ||
| 74 | "-", | ||
| 75 | infoPlistPath, | ||
| 76 | ]).pipe( | ||
| 77 | Effect.map((value) => value.trim()), | ||
| 78 | Effect.orElseSucceed(() => ""), | ||
| 79 | ); | ||
| 80 | }); | ||
| 81 | |||
| 82 | function escapeSpotlightString(value: string): string { | ||
| 83 | return value.replace(/([\\'*?])/gu, "\\$1"); | ||
| 84 | } | ||
| 85 | |||
| 86 | function containsControlCharacter(value: string): boolean { | ||
| 87 | return [...value].some((character) => { | ||
| 88 | const codePoint = character.codePointAt(0) ?? 0; | ||
| 89 | return codePoint <= 31 || codePoint === 127; | ||
| 90 | }); | ||
| 91 | } | ||
| 92 | |||
| 93 | const resolveApplicationPath = Effect.fn("NativeAppIconResolver.resolveApplicationPath")(function* ( | ||
| 94 | app: ToolActivityNativeAppReference, | ||
| 95 | ) { | ||
| 96 | const path = yield* Path.Path; | ||
| 97 | const query = | ||
| 98 | app._tag === "app-id" | ||
| 99 | ? `kMDItemCFBundleIdentifier == '${app.appId}'` | ||
| 100 | : `kMDItemContentType == 'com.apple.application-bundle' && kMDItemDisplayName == '${escapeSpotlightString(app.displayName)}'`; | ||
| 101 | const spotlightOutput = yield* commandOutput("/usr/bin/mdfind", [query]); | ||
| 102 | const candidates = spotlightOutput | ||
| 103 | .split(/\r?\n/u) | ||
| 104 | .map((value) => value.trim()) | ||
| 105 | .filter((value) => value.endsWith(".app")); | ||
| 106 | const matchingCandidates = | ||
| 107 | app._tag === "app-id" | ||
| 108 | ? candidates | ||
| 109 | : candidates.filter( | ||
| 110 | (value) => | ||
| 111 | path.basename(value, ".app").toLocaleLowerCase() === | ||
| 112 | app.displayName.toLocaleLowerCase(), | ||
| 113 | ); | ||
| 114 | const rankedCandidates = matchingCandidates.length > 0 ? matchingCandidates : candidates; | ||
| 115 | let mostRecentlyUsed: { readonly path: string; readonly lastUsed: string } | null = null; | ||
| 116 | for (const candidate of rankedCandidates) { | ||
| 117 | const lastUsed = yield* commandOutput("/usr/bin/mdls", [ | ||
| 118 | "-raw", | ||
| 119 | "-name", | ||
| 120 | "kMDItemLastUsedDate", | ||
| 121 | candidate, | ||
| 122 | ]).pipe( | ||
| 123 | Effect.map((value) => value.trim()), | ||
| 124 | Effect.orElseSucceed(() => ""), | ||
| 125 | ); | ||
| 126 | if (!mostRecentlyUsed || lastUsed > mostRecentlyUsed.lastUsed) { | ||
| 127 | mostRecentlyUsed = { path: candidate, lastUsed }; | ||
| 128 | } | ||
| 129 | } | ||
| 130 | return mostRecentlyUsed?.path ?? null; | ||
| 131 | }); | ||
| 132 | |||
| 133 | const resolveNativeAppIconUncached = Effect.fn("NativeAppIconResolver.resolveUncached")(function* ( | ||
| 134 | app: ToolActivityNativeAppReference, | ||
| 135 | ) { | ||
| 136 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 137 | const path = yield* Path.Path; | ||
| 138 | const config = yield* ServerConfig.ServerConfig; | ||
| 139 | const appPath = yield* resolveApplicationPath(app); | ||
| 140 | if (!appPath) return null; | ||
| 141 | |||
| 142 | const canonicalAppPath = yield* fileSystem.realPath(appPath); | ||
| 143 | const infoPlistPath = path.join(canonicalAppPath, "Contents", "Info.plist"); | ||
| 144 | const resourcesDirectory = path.join(canonicalAppPath, "Contents", "Resources"); | ||
| 145 | const iconName = | ||
| 146 | (yield* plistValue(infoPlistPath, "CFBundleIconFile")) || | ||
| 147 | (yield* plistValue(infoPlistPath, "CFBundleIconName")); | ||
| 148 | if (iconName && path.basename(iconName) !== iconName) return null; | ||
| 149 | const iconFileName = iconName ? (path.extname(iconName) ? iconName : `${iconName}.icns`) : null; | ||
| 150 | const resourceEntries = yield* fileSystem | ||
| 151 | .readDirectory(resourcesDirectory) | ||
| 152 | .pipe(Effect.orElseSucceed(() => [])); | ||
| 153 | const sourceIconCandidate = | ||
| 154 | (iconFileName ? yield* existingFile(path.join(resourcesDirectory, iconFileName)) : null) ?? | ||
| 155 | (yield* existingFile(path.join(resourcesDirectory, "AppIcon.icns"))) ?? | ||
| 156 | (resourceEntries.find((entry) => entry.toLowerCase().endsWith(".icns")) | ||
| 157 | ? yield* existingFile( | ||
| 158 | path.join( | ||
| 159 | resourcesDirectory, | ||
| 160 | resourceEntries.find((entry) => entry.toLowerCase().endsWith(".icns"))!, | ||
| 161 | ), | ||
| 162 | ) | ||
| 163 | : null); | ||
| 164 | if (!sourceIconCandidate) return null; | ||
| 165 | const sourceIconPath = yield* fileSystem.realPath(sourceIconCandidate); | ||
| 166 | const relativeSource = path.relative(resourcesDirectory, sourceIconPath); | ||
| 167 | if ( | ||
| 168 | relativeSource === ".." || | ||
| 169 | relativeSource.startsWith(`..${path.sep}`) || | ||
| 170 | path.isAbsolute(relativeSource) | ||
| 171 | ) { | ||
| 172 | return null; | ||
| 173 | } | ||
| 174 | |||
| 175 | const appVersion = | ||
| 176 | (yield* plistValue(infoPlistPath, "CFBundleVersion")) || | ||
| 177 | (yield* plistValue(infoPlistPath, "CFBundleShortVersionString")); | ||
| 178 | const cacheKey = NodeCrypto.createHash("sha256") | ||
| 179 | .update(`${canonicalAppPath}\0${appVersion}\0${sourceIconPath}`) | ||
| 180 | .digest("hex"); | ||
| 181 | const cacheDirectory = path.join(config.providerStatusCacheDir, "native-app-icons"); | ||
| 182 | const cachePath = path.join(cacheDirectory, `${cacheKey}.png`); | ||
| 183 | if (yield* existingFile(cachePath)) return cachePath; | ||
| 184 | |||
| 185 | yield* fileSystem.makeDirectory(cacheDirectory, { recursive: true }); | ||
| 186 | const temporaryPath = path.join( | ||
| 187 | cacheDirectory, | ||
| 188 | `.${cacheKey}-${process.pid}-${(yield* Clock.currentTimeMillis).toString(36)}-${NodeCrypto.randomUUID()}.png`, | ||
| 189 | ); | ||
| 190 | yield* commandOutput("/usr/bin/sips", [ | ||
| 191 | "-z", | ||
| 192 | String(ICON_SIZE), | ||
| 193 | String(ICON_SIZE), | ||
| 194 | "-s", | ||
| 195 | "format", | ||
| 196 | "png", | ||
| 197 | sourceIconPath, | ||
| 198 | "--out", | ||
| 199 | temporaryPath, | ||
| 200 | ]).pipe( | ||
| 201 | Effect.tap(() => fileSystem.rename(temporaryPath, cachePath)), | ||
| 202 | Effect.ensuring( | ||
| 203 | fileSystem.remove(temporaryPath).pipe(Effect.catchTags({ PlatformError: () => Effect.void })), | ||
| 204 | ), | ||
| 205 | ); | ||
| 206 | return yield* existingFile(cachePath); | ||
| 207 | }); | ||
| 208 | |||
| 209 | export const make = Effect.gen(function* () { | ||
| 210 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 211 | const hostPlatform = yield* HostProcessPlatform; | ||
| 212 | const resolutionSemaphore = yield* Semaphore.make(2); | ||
| 213 | const resolutionCache: Cache.Cache< | ||
| 214 | string, | ||
| 215 | string | null, | ||
| 216 | PlatformError.PlatformError | Cause.TimeoutError | ||
| 217 | > = yield* Cache.makeWith( | ||
| 218 | (key: string) => | ||
| 219 | resolutionSemaphore.withPermits(1)(resolveNativeAppIconUncached(appFromCacheKey(key))), | ||
| 220 | { | ||
| 221 | capacity: RESOLUTION_CACHE_MAX_ENTRIES, | ||
| 222 | timeToLive: Exit.match({ | ||
| 223 | onSuccess: () => RESOLUTION_CACHE_TTL, | ||
| 224 | onFailure: () => Duration.zero, | ||
| 225 | }), | ||
| 226 | }, | ||
| 227 | ); | ||
| 228 | |||
| 229 | const cachedFileExists = (filePath: string) => | ||
| 230 | fileSystem.stat(filePath).pipe( | ||
| 231 | Effect.map((info) => info.type === "File"), | ||
| 232 | Effect.catchTags({ | ||
| 233 | PlatformError: (error) => | ||
| 234 | error.reason._tag === "NotFound" ? Effect.succeed(false) : Effect.fail(error), | ||
| 235 | }), | ||
| 236 | ); | ||
| 237 | |||
| 238 | const resolveAttempt = Effect.fn("NativeAppIconResolver.resolve")(function* ( | ||
| 239 | app: ToolActivityNativeAppReference, | ||
| 240 | ) { | ||
| 241 | if ( | ||
| 242 | hostPlatform !== "darwin" || | ||
| 243 | (app._tag === "display-name" && containsControlCharacter(app.displayName)) | ||
| 244 | ) { | ||
| 245 | return null; | ||
| 246 | } | ||
| 247 | |||
| 248 | const key = appCacheKey(app); | ||
| 249 | const cached = yield* Cache.get(resolutionCache, key); | ||
| 250 | if (cached === null) return null; | ||
| 251 | if (yield* cachedFileExists(cached)) return cached; | ||
| 252 | |||
| 253 | yield* Cache.invalidate(resolutionCache, key); | ||
| 254 | return yield* Cache.get(resolutionCache, key); | ||
| 255 | }); | ||
| 256 | |||
| 257 | const resolve: NativeAppIconResolver["Service"]["resolve"] = (app) => | ||
| 258 | resolveAttempt(app).pipe( | ||
| 259 | Effect.tapError((cause) => | ||
| 260 | Effect.logDebug("Failed to resolve native application icon.", { app, cause }), | ||
| 261 | ), | ||
| 262 | Effect.orElseSucceed(() => null), | ||
| 263 | ); | ||
| 264 | |||
| 265 | return NativeAppIconResolver.of({ resolve }); | ||
| 266 | }); | ||
| 267 | |||
| 268 | export const layer = Layer.effect(NativeAppIconResolver, make); | ||
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+21| ... | @@ -2844,6 +2844,16 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2844,6 +2844,16 @@ describe("ProviderRuntimeIngestion", () => { |
| 2844 | itemType: "command_execution", | 2844 | itemType: "command_execution", |
| 2845 | status: "inProgress", | 2845 | status: "inProgress", |
| 2846 | title: "Command run", | 2846 | title: "Command run", |
| 2847 | toolSurface: "computer", | ||
| 2848 | toolIcon: { | ||
| 2849 | _tag: "native-app", | ||
| 2850 | app: { _tag: "app-id", appId: "com.apple.Terminal" }, | ||
| 2851 | }, | ||
| 2852 | toolSource: { | ||
| 2853 | key: "native-app:com.apple.terminal", | ||
| 2854 | name: "Terminal", | ||
| 2855 | kind: "computer", | ||
| 2856 | }, | ||
| 2847 | detail: "Bash: vp test run", | 2857 | detail: "Bash: vp test run", |
| 2848 | data: { | 2858 | data: { |
| 2849 | toolName: "Bash", | 2859 | toolName: "Bash", |
| ... | @@ -2871,6 +2881,17 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2871,6 +2881,17 @@ describe("ProviderRuntimeIngestion", () => { |
| 2871 | itemType: "command_execution", | 2881 | itemType: "command_execution", |
| 2872 | toolCallId: "tool-call-9", | 2882 | toolCallId: "tool-call-9", |
| 2873 | status: "inProgress", | 2883 | status: "inProgress", |
| 2884 | title: "Command run", | ||
| 2885 | toolSurface: "computer", | ||
| 2886 | toolIcon: { | ||
| 2887 | _tag: "native-app", | ||
| 2888 | app: { _tag: "app-id", appId: "com.apple.Terminal" }, | ||
| 2889 | }, | ||
| 2890 | toolSource: { | ||
| 2891 | key: "native-app:com.apple.terminal", | ||
| 2892 | name: "Terminal", | ||
| 2893 | kind: "computer", | ||
| 2894 | }, | ||
| 2874 | detail: "Bash: vp test run", | 2895 | detail: "Bash: vp test run", |
| 2875 | data: { | 2896 | data: { |
| 2876 | toolName: "Bash", | 2897 | toolName: "Bash", |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+12| ... | @@ -812,7 +812,11 @@ export function runtimeEventToActivities( | ... | @@ -812,7 +812,11 @@ export function runtimeEventToActivities( |
| 812 | itemType: event.payload.itemType, | 812 | itemType: event.payload.itemType, |
| 813 | ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), | 813 | ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), |
| 814 | ...(event.payload.status ? { status: event.payload.status } : {}), | 814 | ...(event.payload.status ? { status: event.payload.status } : {}), |
| 815 | ...(event.payload.title ? { title: event.payload.title } : {}), | ||
| 815 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), | 816 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 817 | ...(event.payload.toolSurface ? { toolSurface: event.payload.toolSurface } : {}), | ||
| 818 | ...(event.payload.toolIcon ? { toolIcon: event.payload.toolIcon } : {}), | ||
| 819 | ...(event.payload.toolSource ? { toolSource: event.payload.toolSource } : {}), | ||
| 816 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), | 820 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), |
| 817 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), | 821 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), |
| 818 | ...(event.payload.parentToolUseId | 822 | ...(event.payload.parentToolUseId |
| ... | @@ -840,7 +844,11 @@ export function runtimeEventToActivities( | ... | @@ -840,7 +844,11 @@ export function runtimeEventToActivities( |
| 840 | itemType: event.payload.itemType, | 844 | itemType: event.payload.itemType, |
| 841 | ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), | 845 | ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), |
| 842 | ...(event.payload.status ? { status: event.payload.status } : {}), | 846 | ...(event.payload.status ? { status: event.payload.status } : {}), |
| 847 | ...(event.payload.title ? { title: event.payload.title } : {}), | ||
| 843 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), | 848 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 849 | ...(event.payload.toolSurface ? { toolSurface: event.payload.toolSurface } : {}), | ||
| 850 | ...(event.payload.toolIcon ? { toolIcon: event.payload.toolIcon } : {}), | ||
| 851 | ...(event.payload.toolSource ? { toolSource: event.payload.toolSource } : {}), | ||
| 844 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), | 852 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), |
| 845 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), | 853 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), |
| 846 | ...(event.payload.parentToolUseId | 854 | ...(event.payload.parentToolUseId |
| ... | @@ -868,7 +876,11 @@ export function runtimeEventToActivities( | ... | @@ -868,7 +876,11 @@ export function runtimeEventToActivities( |
| 868 | itemType: event.payload.itemType, | 876 | itemType: event.payload.itemType, |
| 869 | ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), | 877 | ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), |
| 870 | ...(event.payload.status ? { status: event.payload.status } : {}), | 878 | ...(event.payload.status ? { status: event.payload.status } : {}), |
| 879 | ...(event.payload.title ? { title: event.payload.title } : {}), | ||
| 871 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), | 880 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 881 | ...(event.payload.toolSurface ? { toolSurface: event.payload.toolSurface } : {}), | ||
| 882 | ...(event.payload.toolIcon ? { toolIcon: event.payload.toolIcon } : {}), | ||
| 883 | ...(event.payload.toolSource ? { toolSource: event.payload.toolSource } : {}), | ||
| 872 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), | 884 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), |
| 873 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), | 885 | ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), |
| 874 | ...(event.payload.parentToolUseId | 886 | ...(event.payload.parentToolUseId |
apps/server/src/provider/Layers/CodexAdapter.test.ts+241| ... | @@ -800,9 +800,207 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -800,9 +800,207 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 800 | }), | 800 | }), |
| 801 | ); | 801 | ); |
| 802 | 802 | ||
| 803 | it.effect("presents browser and computer-use calls with Codex-style titles and sources", () => | ||
| 804 | Effect.gen(function* () { | ||
| 805 | const { adapter, runtime } = yield* startLifecycleRuntime(); | ||
| 806 | const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe( | ||
| 807 | Effect.forkChild, | ||
| 808 | ); | ||
| 809 | const longIntentTitle = ` ${"a".repeat(39)} ${"a".repeat(38)}😀bc `; | ||
| 810 | const serializedOverContractUrl = `https://example.com/?query=${"😀".repeat(400)}`; | ||
| 811 | |||
| 812 | yield* runtime.emit({ | ||
| 813 | id: asEventId("evt-computer-start"), | ||
| 814 | kind: "notification", | ||
| 815 | provider: ProviderDriverKind.make("codex"), | ||
| 816 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 817 | method: "item/started", | ||
| 818 | threadId: asThreadId("thread-1"), | ||
| 819 | turnId: asTurnId("turn-1"), | ||
| 820 | itemId: asItemId("computer_1"), | ||
| 821 | payload: { | ||
| 822 | startedAtMs: 1_778_000_000_000, | ||
| 823 | threadId: "thread-1", | ||
| 824 | turnId: "turn-1", | ||
| 825 | item: { | ||
| 826 | type: "mcpToolCall", | ||
| 827 | id: "computer_1", | ||
| 828 | server: "node_repl", | ||
| 829 | tool: "js", | ||
| 830 | arguments: { | ||
| 831 | code: 'await sky.click({ app: "Finder", x: 10, y: 20 })', | ||
| 832 | title: longIntentTitle, | ||
| 833 | }, | ||
| 834 | durationMs: null, | ||
| 835 | error: null, | ||
| 836 | result: { | ||
| 837 | _meta: { | ||
| 838 | "codex/toolSurface": { | ||
| 839 | kind: "computerUse", | ||
| 840 | app: { kind: "appId", appId: "com.apple.finder" }, | ||
| 841 | }, | ||
| 842 | }, | ||
| 843 | content: [], | ||
| 844 | }, | ||
| 845 | status: "inProgress", | ||
| 846 | }, | ||
| 847 | }, | ||
| 848 | }); | ||
| 849 | yield* runtime.emit({ | ||
| 850 | id: asEventId("evt-browser-complete"), | ||
| 851 | kind: "notification", | ||
| 852 | provider: ProviderDriverKind.make("codex"), | ||
| 853 | createdAt: "2026-01-01T00:00:01.000Z", | ||
| 854 | method: "item/completed", | ||
| 855 | threadId: asThreadId("thread-1"), | ||
| 856 | turnId: asTurnId("turn-1"), | ||
| 857 | itemId: asItemId("browser_1"), | ||
| 858 | payload: { | ||
| 859 | completedAtMs: 1_778_000_001_000, | ||
| 860 | threadId: "thread-1", | ||
| 861 | turnId: "turn-1", | ||
| 862 | item: { | ||
| 863 | type: "mcpToolCall", | ||
| 864 | id: "browser_1", | ||
| 865 | server: "node_repl", | ||
| 866 | tool: "js", | ||
| 867 | arguments: { code: "await tab.playwright.domSnapshot()", title: "Inspect checkout" }, | ||
| 868 | durationMs: 12, | ||
| 869 | error: null, | ||
| 870 | result: { | ||
| 871 | _meta: { | ||
| 872 | "codex/toolSurface": { | ||
| 873 | kind: "browserUse", | ||
| 874 | backend: "chrome", | ||
| 875 | openTabs: [ | ||
| 876 | { | ||
| 877 | pageUrl: "https://www.mathworks.com/help/matlab/", | ||
| 878 | faviconUrl: "https://www.mathworks.com/favicon.ico", | ||
| 879 | faviconUrlDark: "https://www.mathworks.com/favicon-dark.ico", | ||
| 880 | url: "https://www.mathworks.com/help/matlab/", | ||
| 881 | }, | ||
| 882 | ], | ||
| 883 | }, | ||
| 884 | browser_use: { url: serializedOverContractUrl }, | ||
| 885 | }, | ||
| 886 | content: [], | ||
| 887 | }, | ||
| 888 | status: "completed", | ||
| 889 | }, | ||
| 890 | }, | ||
| 891 | }); | ||
| 892 | yield* runtime.emit({ | ||
| 893 | id: asEventId("evt-computer-use-complete"), | ||
| 894 | kind: "notification", | ||
| 895 | provider: ProviderDriverKind.make("codex"), | ||
| 896 | createdAt: "2026-01-01T00:00:02.000Z", | ||
| 897 | method: "item/completed", | ||
| 898 | threadId: asThreadId("thread-1"), | ||
| 899 | turnId: asTurnId("turn-1"), | ||
| 900 | itemId: asItemId("computer_2"), | ||
| 901 | payload: { | ||
| 902 | completedAtMs: 1_778_000_002_000, | ||
| 903 | threadId: "thread-1", | ||
| 904 | turnId: "turn-1", | ||
| 905 | item: { | ||
| 906 | type: "mcpToolCall", | ||
| 907 | id: "computer_2", | ||
| 908 | server: "computer-use", | ||
| 909 | tool: "type_text", | ||
| 910 | arguments: { text: "Hello world", app: "TextEdit" }, | ||
| 911 | durationMs: 12, | ||
| 912 | error: null, | ||
| 913 | result: { | ||
| 914 | _meta: { | ||
| 915 | "codex/toolSurface": { | ||
| 916 | kind: "computerUse", | ||
| 917 | app: { kind: "displayName", displayName: "TextEdit" }, | ||
| 918 | }, | ||
| 919 | }, | ||
| 920 | content: [], | ||
| 921 | }, | ||
| 922 | status: "completed", | ||
| 923 | }, | ||
| 924 | }, | ||
| 925 | }); | ||
| 926 | |||
| 927 | const events = Array.from(yield* Fiber.join(eventsFiber)); | ||
| 928 | NodeAssert.deepStrictEqual( | ||
| 929 | events.map((event) => ({ | ||
| 930 | type: event.type, | ||
| 931 | title: "title" in event.payload ? event.payload.title : undefined, | ||
| 932 | toolSurface: "toolSurface" in event.payload ? event.payload.toolSurface : undefined, | ||
| 933 | toolIcon: "toolIcon" in event.payload ? event.payload.toolIcon : undefined, | ||
| 934 | toolSource: "toolSource" in event.payload ? event.payload.toolSource : undefined, | ||
| 935 | })), | ||
| 936 | [ | ||
| 937 | { | ||
| 938 | type: "item.started", | ||
| 939 | title: `${"a".repeat(39)} ${"a".repeat(38)}😀…`, | ||
| 940 | toolSurface: "computer", | ||
| 941 | toolIcon: { | ||
| 942 | _tag: "native-app", | ||
| 943 | app: { _tag: "app-id", appId: "com.apple.finder" }, | ||
| 944 | }, | ||
| 945 | toolSource: { | ||
| 946 | key: "native-app:com.apple.finder", | ||
| 947 | name: "Finder", | ||
| 948 | kind: "computer", | ||
| 949 | icon: { | ||
| 950 | _tag: "native-app", | ||
| 951 | app: { _tag: "app-id", appId: "com.apple.finder" }, | ||
| 952 | }, | ||
| 953 | }, | ||
| 954 | }, | ||
| 955 | { | ||
| 956 | type: "item.completed", | ||
| 957 | title: "Inspect checkout", | ||
| 958 | toolSurface: "browser", | ||
| 959 | toolIcon: { | ||
| 960 | _tag: "website", | ||
| 961 | pageUrl: "https://www.mathworks.com/help/matlab/", | ||
| 962 | faviconUrl: "https://www.mathworks.com/favicon.ico", | ||
| 963 | faviconUrlDark: "https://www.mathworks.com/favicon-dark.ico", | ||
| 964 | }, | ||
| 965 | toolSource: { | ||
| 966 | key: "browser-use:chrome", | ||
| 967 | name: "Chrome", | ||
| 968 | kind: "integration", | ||
| 969 | icon: { | ||
| 970 | _tag: "native-app", | ||
| 971 | app: { _tag: "display-name", displayName: "Google Chrome" }, | ||
| 972 | }, | ||
| 973 | }, | ||
| 974 | }, | ||
| 975 | { | ||
| 976 | type: "item.completed", | ||
| 977 | title: "Typed text in TextEdit", | ||
| 978 | toolSurface: "computer", | ||
| 979 | toolIcon: { | ||
| 980 | _tag: "native-app", | ||
| 981 | app: { _tag: "display-name", displayName: "TextEdit" }, | ||
| 982 | }, | ||
| 983 | toolSource: { | ||
| 984 | key: "native-app-name:textedit", | ||
| 985 | name: "TextEdit", | ||
| 986 | kind: "computer", | ||
| 987 | icon: { | ||
| 988 | _tag: "native-app", | ||
| 989 | app: { _tag: "display-name", displayName: "TextEdit" }, | ||
| 990 | }, | ||
| 991 | }, | ||
| 992 | }, | ||
| 993 | ], | ||
| 994 | ); | ||
| 995 | }), | ||
| 996 | ); | ||
| 997 | |||
| 803 | it.effect("preserves failed and declined outcomes on completed tool items", () => | 998 | it.effect("preserves failed and declined outcomes on completed tool items", () => |
| 804 | Effect.gen(function* () { | 999 | Effect.gen(function* () { |
| 805 | const { adapter, runtime } = yield* startLifecycleRuntime(); | 1000 | const { adapter, runtime } = yield* startLifecycleRuntime(); |
| 1001 | const maxLengthAppId = `com.${"a".repeat(508)}`; | ||
| 1002 | const collidingMaxLengthAppId = `com.${"a".repeat(507)}b`; | ||
| 1003 | const longAppSourceKeys: string[] = []; | ||
| 806 | const items = [ | 1004 | const items = [ |
| 807 | { | 1005 | { |
| 808 | type: "commandExecution", | 1006 | type: "commandExecution", |
| ... | @@ -822,6 +1020,42 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -822,6 +1020,42 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 822 | error: { message: "Build failed" }, | 1020 | error: { message: "Build failed" }, |
| 823 | status: "failed", | 1021 | status: "failed", |
| 824 | }, | 1022 | }, |
| 1023 | { | ||
| 1024 | type: "mcpToolCall", | ||
| 1025 | id: "failed-computer", | ||
| 1026 | server: "computer-use", | ||
| 1027 | tool: "click", | ||
| 1028 | arguments: { app: "Finder" }, | ||
| 1029 | error: { message: "Click failed" }, | ||
| 1030 | result: { | ||
| 1031 | _meta: { | ||
| 1032 | "codex/toolSurface": { | ||
| 1033 | kind: "computerUse", | ||
| 1034 | app: { kind: "appId", appId: maxLengthAppId }, | ||
| 1035 | }, | ||
| 1036 | }, | ||
| 1037 | content: [], | ||
| 1038 | }, | ||
| 1039 | status: "failed", | ||
| 1040 | }, | ||
| 1041 | { | ||
| 1042 | type: "mcpToolCall", | ||
| 1043 | id: "failed-computer-collision", | ||
| 1044 | server: "computer-use", | ||
| 1045 | tool: "click", | ||
| 1046 | arguments: { app: "Other" }, | ||
| 1047 | error: { message: "Click failed" }, | ||
| 1048 | result: { | ||
| 1049 | _meta: { | ||
| 1050 | "codex/toolSurface": { | ||
| 1051 | kind: "computerUse", | ||
| 1052 | app: { kind: "appId", appId: collidingMaxLengthAppId }, | ||
| 1053 | }, | ||
| 1054 | }, | ||
| 1055 | content: [], | ||
| 1056 | }, | ||
| 1057 | status: "failed", | ||
| 1058 | }, | ||
| 825 | { | 1059 | { |
| 826 | type: "fileChange", | 1060 | type: "fileChange", |
| 827 | id: "declined-change", | 1061 | id: "declined-change", |
| ... | @@ -856,7 +1090,14 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -856,7 +1090,14 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 856 | return; | 1090 | return; |
| 857 | } | 1091 | } |
| 858 | NodeAssert.equal(firstEvent.value.payload.status, item.status); | 1092 | NodeAssert.equal(firstEvent.value.payload.status, item.status); |
| 1093 | if (item.id.startsWith("failed-computer")) { | ||
| 1094 | NodeAssert.equal(firstEvent.value.payload.title, "computer-use · click"); | ||
| 1095 | const sourceKey = firstEvent.value.payload.toolSource?.key; | ||
| 1096 | NodeAssert.equal(sourceKey?.length, 512); | ||
| 1097 | if (sourceKey) longAppSourceKeys.push(sourceKey); | ||
| 1098 | } | ||
| 859 | } | 1099 | } |
| 1100 | NodeAssert.equal(new Set(longAppSourceKeys).size, 2); | ||
| 860 | }), | 1101 | }), |
| 861 | ); | 1102 | ); |
| 862 | 1103 |
apps/server/src/provider/Layers/CodexAdapter.ts+313-2| ... | @@ -17,6 +17,9 @@ import { | ... | @@ -17,6 +17,9 @@ import { |
| 17 | type ProviderRuntimeEvent, | 17 | type ProviderRuntimeEvent, |
| 18 | type ProviderRequestKind, | 18 | type ProviderRequestKind, |
| 19 | type ThreadTokenUsageSnapshot, | 19 | type ThreadTokenUsageSnapshot, |
| 20 | type ToolActivityIcon, | ||
| 21 | type ToolActivityNativeAppReference, | ||
| 22 | type ToolActivitySource, | ||
| 20 | type ProviderUserInputAnswers, | 23 | type ProviderUserInputAnswers, |
| 21 | RuntimeItemId, | 24 | RuntimeItemId, |
| 22 | RuntimeRequestId, | 25 | RuntimeRequestId, |
| ... | @@ -27,6 +30,7 @@ import { | ... | @@ -27,6 +30,7 @@ import { |
| 27 | ProviderSendTurnInput, | 30 | ProviderSendTurnInput, |
| 28 | } from "@t3tools/contracts"; | 31 | } from "@t3tools/contracts"; |
| 29 | import * as Effect from "effect/Effect"; | 32 | import * as Effect from "effect/Effect"; |
| 33 | import * as NodeCrypto from "node:crypto"; | ||
| 30 | import * as Crypto from "effect/Crypto"; | 34 | import * as Crypto from "effect/Crypto"; |
| 31 | import * as Exit from "effect/Exit"; | 35 | import * as Exit from "effect/Exit"; |
| 32 | import * as Fiber from "effect/Fiber"; | 36 | import * as Fiber from "effect/Fiber"; |
| ... | @@ -150,6 +154,236 @@ function trimText(value: string | undefined | null): string | undefined { | ... | @@ -150,6 +154,236 @@ function trimText(value: string | undefined | null): string | undefined { |
| 150 | return trimmed && trimmed.length > 0 ? trimmed : undefined; | 154 | return trimmed && trimmed.length > 0 ? trimmed : undefined; |
| 151 | } | 155 | } |
| 152 | 156 | ||
| 157 | function asUnknownRecord(value: unknown): Record<string, unknown> | undefined { | ||
| 158 | return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined; | ||
| 159 | } | ||
| 160 | |||
| 161 | function normalizeMcpIntentTitle(value: unknown): string | undefined { | ||
| 162 | if (typeof value !== "string") return undefined; | ||
| 163 | const normalized = value.trim().replace(/\s+/gu, " "); | ||
| 164 | if (!normalized) return undefined; | ||
| 165 | const characters = Array.from(normalized); | ||
| 166 | return characters.length <= 80 ? normalized : `${characters.slice(0, 79).join("")}…`; | ||
| 167 | } | ||
| 168 | |||
| 169 | function normalizedHttpUrl(value: unknown): string | undefined { | ||
| 170 | if (typeof value !== "string" || value.length > 4096) return undefined; | ||
| 171 | try { | ||
| 172 | const url = new URL(value); | ||
| 173 | const href = url.href; | ||
| 174 | return (url.protocol === "http:" || url.protocol === "https:") && href.length <= 4096 | ||
| 175 | ? href | ||
| 176 | : undefined; | ||
| 177 | } catch { | ||
| 178 | return undefined; | ||
| 179 | } | ||
| 180 | } | ||
| 181 | |||
| 182 | function normalizedImageUrl(value: unknown): string | undefined { | ||
| 183 | if (typeof value !== "string" || value.length > 4096) return undefined; | ||
| 184 | try { | ||
| 185 | const url = new URL(value); | ||
| 186 | return url.protocol === "http:" || url.protocol === "https:" || url.protocol === "data:" | ||
| 187 | ? url.href | ||
| 188 | : undefined; | ||
| 189 | } catch { | ||
| 190 | return undefined; | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | function normalizedAppId(value: unknown): string | undefined { | ||
| 195 | if (typeof value !== "string") return undefined; | ||
| 196 | const appId = value.trim(); | ||
| 197 | return appId.length > 0 && appId.length <= 512 && /^[A-Za-z0-9._-]+$/u.test(appId) | ||
| 198 | ? appId | ||
| 199 | : undefined; | ||
| 200 | } | ||
| 201 | |||
| 202 | function normalizedDisplayName(value: unknown): string | undefined { | ||
| 203 | if (typeof value !== "string") return undefined; | ||
| 204 | const displayName = value.trim().replace(/\s+/gu, " "); | ||
| 205 | return displayName && displayName.length <= 160 ? displayName : undefined; | ||
| 206 | } | ||
| 207 | |||
| 208 | function normalizedSourceKeyPart(value: string): string { | ||
| 209 | return value.trim().toLowerCase(); | ||
| 210 | } | ||
| 211 | |||
| 212 | function nativeAppSourceKey(appId: string): string { | ||
| 213 | const key = `native-app:${appId.toLowerCase()}`; | ||
| 214 | if (key.length <= 512) return key; | ||
| 215 | const digest = NodeCrypto.createHash("sha256").update(key).digest("hex"); | ||
| 216 | return `${key.slice(0, 512 - digest.length - 1)}:${digest}`; | ||
| 217 | } | ||
| 218 | |||
| 219 | function browserDisplayName(value: unknown): string | undefined { | ||
| 220 | const normalized = normalizedDisplayName(value)?.toLowerCase(); | ||
| 221 | if (!normalized) return undefined; | ||
| 222 | if (normalized.includes("chrome") || normalized === "chromium") return "Chrome"; | ||
| 223 | if (normalized.includes("edge")) return "Microsoft Edge"; | ||
| 224 | if (normalized.includes("firefox")) return "Firefox"; | ||
| 225 | if (normalized.includes("safari")) return "Safari"; | ||
| 226 | if (normalized.includes("arc")) return "Arc"; | ||
| 227 | if (normalized === "iab" || normalized.includes("in-app")) return "Browser"; | ||
| 228 | return normalizedDisplayName(value); | ||
| 229 | } | ||
| 230 | |||
| 231 | function browserNativeAppReference(name: string): ToolActivityNativeAppReference | undefined { | ||
| 232 | switch (name) { | ||
| 233 | case "Chrome": | ||
| 234 | return { _tag: "display-name", displayName: "Google Chrome" }; | ||
| 235 | case "Microsoft Edge": | ||
| 236 | case "Firefox": | ||
| 237 | case "Safari": | ||
| 238 | case "Arc": | ||
| 239 | return { _tag: "display-name", displayName: name }; | ||
| 240 | default: | ||
| 241 | return undefined; | ||
| 242 | } | ||
| 243 | } | ||
| 244 | |||
| 245 | function appDisplayNameFromId(appId: string): string | undefined { | ||
| 246 | const knownNames: Readonly<Record<string, string>> = { | ||
| 247 | "com.apple.finder": "Finder", | ||
| 248 | "com.apple.safari": "Safari", | ||
| 249 | "com.google.chrome": "Chrome", | ||
| 250 | "com.microsoft.edgemac": "Microsoft Edge", | ||
| 251 | "org.mozilla.firefox": "Firefox", | ||
| 252 | "company.thebrowser.browser": "Arc", | ||
| 253 | }; | ||
| 254 | return knownNames[appId.toLowerCase()]; | ||
| 255 | } | ||
| 256 | |||
| 257 | function nativeAppReference(value: unknown): ToolActivityNativeAppReference | undefined { | ||
| 258 | const app = asUnknownRecord(value); | ||
| 259 | if (app?.kind === "appId") { | ||
| 260 | const appId = normalizedAppId(app.appId); | ||
| 261 | return appId ? { _tag: "app-id", appId } : undefined; | ||
| 262 | } | ||
| 263 | if (app?.kind === "displayName") { | ||
| 264 | const displayName = normalizedDisplayName(app.displayName); | ||
| 265 | return displayName ? { _tag: "display-name", displayName } : undefined; | ||
| 266 | } | ||
| 267 | return undefined; | ||
| 268 | } | ||
| 269 | |||
| 270 | function themedLogoIcon( | ||
| 271 | ...records: ReadonlyArray<Record<string, unknown> | undefined> | ||
| 272 | ): ToolActivityIcon | undefined { | ||
| 273 | for (const record of records) { | ||
| 274 | const logoUrl = normalizedImageUrl(record?.logoUrl); | ||
| 275 | if (!logoUrl) continue; | ||
| 276 | const logoUrlDark = normalizedImageUrl(record?.logoUrlDark ?? record?.logoDarkUrl); | ||
| 277 | return { | ||
| 278 | _tag: "themed-logo", | ||
| 279 | logoUrl, | ||
| 280 | ...(logoUrlDark ? { logoUrlDark } : {}), | ||
| 281 | }; | ||
| 282 | } | ||
| 283 | return undefined; | ||
| 284 | } | ||
| 285 | |||
| 286 | interface McpToolPresentation { | ||
| 287 | readonly toolSurface?: "browser" | "computer"; | ||
| 288 | readonly toolIcon?: ToolActivityIcon; | ||
| 289 | readonly toolSource?: ToolActivitySource; | ||
| 290 | } | ||
| 291 | |||
| 292 | function mcpToolPresentation( | ||
| 293 | item: Extract<CodexLifecycleItem, { readonly type: "mcpToolCall" }>, | ||
| 294 | ): McpToolPresentation { | ||
| 295 | const result = asUnknownRecord(item.result); | ||
| 296 | const metadata = asUnknownRecord(result?._meta); | ||
| 297 | const surface = asUnknownRecord(metadata?.["codex/toolSurface"]); | ||
| 298 | const sourceMetadata = asUnknownRecord(metadata?.source); | ||
| 299 | const appContext = asUnknownRecord(item.appContext); | ||
| 300 | const sourceLogo = themedLogoIcon(surface, sourceMetadata, appContext); | ||
| 301 | if (surface?.kind === "browserUse") { | ||
| 302 | const screenshot = asUnknownRecord(surface.screenshot); | ||
| 303 | const browserUse = asUnknownRecord(metadata?.browser_use); | ||
| 304 | const openTabs = Array.isArray(surface.openTabs) ? surface.openTabs : []; | ||
| 305 | const latestOpenTab = openTabs | ||
| 306 | .toReversed() | ||
| 307 | .map(asUnknownRecord) | ||
| 308 | .find((tab) => normalizedHttpUrl(tab?.url) !== undefined); | ||
| 309 | const selectedPage = [ | ||
| 310 | { record: screenshot, url: screenshot?.pageUrl }, | ||
| 311 | { record: browserUse, url: browserUse?.url }, | ||
| 312 | { record: latestOpenTab, url: latestOpenTab?.url }, | ||
| 313 | ] | ||
| 314 | .map((candidate) => ({ ...candidate, pageUrl: normalizedHttpUrl(candidate.url) })) | ||
| 315 | .find((candidate) => candidate.pageUrl !== undefined); | ||
| 316 | const pageUrl = selectedPage?.pageUrl; | ||
| 317 | const faviconUrl = normalizedImageUrl( | ||
| 318 | selectedPage?.record?.faviconUrl ?? selectedPage?.record?.favIconUrl, | ||
| 319 | ); | ||
| 320 | const faviconUrlDark = normalizedImageUrl( | ||
| 321 | selectedPage?.record?.faviconUrlDark ?? selectedPage?.record?.favIconUrlDark, | ||
| 322 | ); | ||
| 323 | const name = | ||
| 324 | browserDisplayName(appContext?.appName) ?? | ||
| 325 | browserDisplayName(surface.browserFamily) ?? | ||
| 326 | browserDisplayName(surface.backend) ?? | ||
| 327 | "Browser"; | ||
| 328 | const nativeBrowserIcon = browserNativeAppReference(name); | ||
| 329 | const sourceIcon = | ||
| 330 | sourceLogo ?? | ||
| 331 | (nativeBrowserIcon ? ({ _tag: "native-app", app: nativeBrowserIcon } as const) : undefined); | ||
| 332 | const sourceKeyPart = normalizedSourceKeyPart(name) || "browser"; | ||
| 333 | return { | ||
| 334 | toolSurface: "browser", | ||
| 335 | ...(pageUrl | ||
| 336 | ? { | ||
| 337 | toolIcon: { | ||
| 338 | _tag: "website", | ||
| 339 | pageUrl, | ||
| 340 | ...(faviconUrl ? { faviconUrl } : {}), | ||
| 341 | ...(faviconUrlDark ? { faviconUrlDark } : {}), | ||
| 342 | } as const, | ||
| 343 | } | ||
| 344 | : {}), | ||
| 345 | toolSource: { | ||
| 346 | key: `browser-use:${sourceKeyPart}`, | ||
| 347 | name, | ||
| 348 | kind: name === "Browser" ? "browser" : "integration", | ||
| 349 | ...(sourceIcon ? { icon: sourceIcon } : {}), | ||
| 350 | }, | ||
| 351 | }; | ||
| 352 | } | ||
| 353 | if (surface?.kind === "computerUse") { | ||
| 354 | const app = nativeAppReference(surface.app); | ||
| 355 | const args = asUnknownRecord(item.arguments); | ||
| 356 | const argumentAppName = | ||
| 357 | normalizedDisplayName(args?.appName) ?? | ||
| 358 | normalizedDisplayName(args?.application) ?? | ||
| 359 | normalizedDisplayName(typeof args?.app === "string" ? args.app : undefined); | ||
| 360 | const name = | ||
| 361 | normalizedDisplayName(appContext?.appName) ?? | ||
| 362 | argumentAppName ?? | ||
| 363 | (app?._tag === "display-name" ? app.displayName : undefined) ?? | ||
| 364 | (app?._tag === "app-id" ? appDisplayNameFromId(app.appId) : undefined) ?? | ||
| 365 | "Computer Use"; | ||
| 366 | const sourceIcon = sourceLogo ?? (app ? ({ _tag: "native-app", app } as const) : undefined); | ||
| 367 | const sourceKey = app | ||
| 368 | ? app._tag === "app-id" | ||
| 369 | ? nativeAppSourceKey(app.appId) | ||
| 370 | : `native-app-name:${normalizedSourceKeyPart(app.displayName)}` | ||
| 371 | : "computer-use"; | ||
| 372 | return { | ||
| 373 | toolSurface: "computer", | ||
| 374 | ...(app ? { toolIcon: { _tag: "native-app", app } as const } : {}), | ||
| 375 | toolSource: { | ||
| 376 | key: sourceKey, | ||
| 377 | name, | ||
| 378 | kind: "computer", | ||
| 379 | ...(sourceIcon ? { icon: sourceIcon } : {}), | ||
| 380 | }, | ||
| 381 | }; | ||
| 382 | } | ||
| 383 | |||
| 384 | return {}; | ||
| 385 | } | ||
| 386 | |||
| 153 | const FATAL_CODEX_STDERR_SNIPPETS = ["failed to connect to websocket"]; | 387 | const FATAL_CODEX_STDERR_SNIPPETS = ["failed to connect to websocket"]; |
| 154 | 388 | ||
| 155 | function isFatalCodexProcessStderrMessage(message: string): boolean { | 389 | function isFatalCodexProcessStderrMessage(message: string): boolean { |
| ... | @@ -239,8 +473,82 @@ function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType | ... | @@ -239,8 +473,82 @@ function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType |
| 239 | return "unknown"; | 473 | return "unknown"; |
| 240 | } | 474 | } |
| 241 | 475 | ||
| 242 | function itemTitle(itemType: CanonicalItemType, item?: CodexLifecycleItem): string | undefined { | 476 | function boundedToolArgument(value: unknown): string | undefined { |
| 477 | const normalized = typeof value === "string" ? value.trim().replace(/\s+/gu, " ") : ""; | ||
| 478 | if (!normalized) return undefined; | ||
| 479 | return normalized.length <= 48 ? normalized : `${normalized.slice(0, 47)}…`; | ||
| 480 | } | ||
| 481 | |||
| 482 | function normalizedMcpToolName(value: string): string { | ||
| 483 | return ( | ||
| 484 | value | ||
| 485 | .split(/__|[./:]/u) | ||
| 486 | .at(-1) | ||
| 487 | ?.trim() ?? value.trim() | ||
| 488 | ); | ||
| 489 | } | ||
| 490 | |||
| 491 | function computerUseToolTitle( | ||
| 492 | item: Extract<CodexLifecycleItem, { readonly type: "mcpToolCall" }>, | ||
| 493 | presentation: McpToolPresentation, | ||
| 494 | ): string | undefined { | ||
| 495 | if (normalizeItemType(item.server) !== "computer use") return undefined; | ||
| 496 | if (item.status === "failed") return undefined; | ||
| 497 | const tool = normalizeItemType(normalizedMcpToolName(item.tool)).replace(/ /gu, "_"); | ||
| 498 | const inProgress = item.status === "inProgress"; | ||
| 499 | const args = asUnknownRecord(item.arguments); | ||
| 500 | const appName = | ||
| 501 | (presentation.toolSource?.kind === "computer" && presentation.toolSource.name !== "Computer Use" | ||
| 502 | ? presentation.toolSource.name | ||
| 503 | : undefined) ?? | ||
| 504 | normalizedDisplayName(args?.appName) ?? | ||
| 505 | normalizedDisplayName(args?.application) ?? | ||
| 506 | normalizedDisplayName(typeof args?.app === "string" ? args.app : undefined); | ||
| 507 | const withApp = (label: string) => (appName ? `${label} in ${appName}` : label); | ||
| 508 | switch (tool) { | ||
| 509 | case "list_apps": | ||
| 510 | return inProgress ? "Listing apps" : "Listed apps"; | ||
| 511 | case "click": | ||
| 512 | return withApp(inProgress ? "Clicking" : "Clicked"); | ||
| 513 | case "drag": | ||
| 514 | return withApp(inProgress ? "Dragging" : "Dragged"); | ||
| 515 | case "get_app_state": | ||
| 516 | case "get_state": | ||
| 517 | return appName | ||
| 518 | ? `${inProgress ? "Looking at" : "Looked at"} ${appName}` | ||
| 519 | : inProgress | ||
| 520 | ? "Looking at the screen" | ||
| 521 | : "Looked at the screen"; | ||
| 522 | case "perform_accessibility_action": | ||
| 523 | case "perform_secondary_action": | ||
| 524 | return inProgress ? "Performing accessibility action" : "Performed accessibility action"; | ||
| 525 | case "press_key": | ||
| 526 | return withApp(inProgress ? "Pressing key" : "Pressed key"); | ||
| 527 | case "scroll": { | ||
| 528 | const direction = boundedToolArgument(args?.direction)?.toLowerCase(); | ||
| 529 | return withApp(`${inProgress ? "Scrolling" : "Scrolled"}${direction ? ` ${direction}` : ""}`); | ||
| 530 | } | ||
| 531 | case "set_value": | ||
| 532 | return withApp(inProgress ? "Setting value" : "Set value"); | ||
| 533 | case "type_text": | ||
| 534 | return withApp(inProgress ? "Typing text" : "Typed text"); | ||
| 535 | default: | ||
| 536 | return undefined; | ||
| 537 | } | ||
| 538 | } | ||
| 539 | |||
| 540 | function itemTitle( | ||
| 541 | itemType: CanonicalItemType, | ||
| 542 | item?: CodexLifecycleItem, | ||
| 543 | presentation: McpToolPresentation = {}, | ||
| 544 | ): string | undefined { | ||
| 243 | if (itemType === "mcp_tool_call" && item?.type === "mcpToolCall") { | 545 | if (itemType === "mcp_tool_call" && item?.type === "mcpToolCall") { |
| 546 | if (normalizedMcpToolName(item.tool) === "js") { | ||
| 547 | const intentTitle = normalizeMcpIntentTitle(asUnknownRecord(item.arguments)?.title); | ||
| 548 | if (intentTitle) return intentTitle; | ||
| 549 | } | ||
| 550 | const computerUseTitle = computerUseToolTitle(item, presentation); | ||
| 551 | if (computerUseTitle) return computerUseTitle; | ||
| 244 | return `${item.server} · ${item.tool}`; | 552 | return `${item.server} · ${item.tool}`; |
| 245 | } | 553 | } |
| 246 | switch (itemType) { | 554 | switch (itemType) { |
| ... | @@ -481,6 +789,8 @@ function mapItemLifecycle( | ... | @@ -481,6 +789,8 @@ function mapItemLifecycle( |
| 481 | } | 789 | } |
| 482 | 790 | ||
| 483 | const detail = itemDetail(itemType, item); | 791 | const detail = itemDetail(itemType, item); |
| 792 | const toolPresentation = item.type === "mcpToolCall" ? mcpToolPresentation(item) : {}; | ||
| 793 | const title = itemTitle(itemType, item, toolPresentation); | ||
| 484 | const status = | 794 | const status = |
| 485 | lifecycle === "item.started" | 795 | lifecycle === "item.started" |
| 486 | ? "inProgress" | 796 | ? "inProgress" |
| ... | @@ -496,8 +806,9 @@ function mapItemLifecycle( | ... | @@ -496,8 +806,9 @@ function mapItemLifecycle( |
| 496 | payload: { | 806 | payload: { |
| 497 | itemType, | 807 | itemType, |
| 498 | ...(status ? { status } : {}), | 808 | ...(status ? { status } : {}), |
| 499 | ...(itemTitle(itemType, item) ? { title: itemTitle(itemType, item) } : {}), | 809 | ...(title ? { title } : {}), |
| 500 | ...(detail ? { detail } : {}), | 810 | ...(detail ? { detail } : {}), |
| 811 | ...toolPresentation, | ||
| 501 | ...(event.payload !== undefined ? { data: event.payload } : {}), | 812 | ...(event.payload !== undefined ? { data: event.payload } : {}), |
| 502 | }, | 813 | }, |
| 503 | }; | 814 | }; |
apps/server/src/server.test.ts+2| ... | @@ -122,6 +122,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; | ... | @@ -122,6 +122,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; |
| 122 | import * as PreviewManager from "./preview/Manager.ts"; | 122 | import * as PreviewManager from "./preview/Manager.ts"; |
| 123 | import * as PortScanner from "./preview/PortScanner.ts"; | 123 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 124 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; | 124 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; |
| 125 | import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; | ||
| 125 | import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; | 126 | import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; |
| 126 | import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; | 127 | import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; |
| 127 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; | 128 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| ... | @@ -626,6 +627,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -626,6 +627,7 @@ const buildAppUnderTest = (options?: { |
| 626 | Layer.provide(WorkspacePaths.layer), | 627 | Layer.provide(WorkspacePaths.layer), |
| 627 | Layer.provide(T3ProjectFileLoader.layer), | 628 | Layer.provide(T3ProjectFileLoader.layer), |
| 628 | ), | 629 | ), |
| 630 | NativeAppIconResolver.layer, | ||
| 629 | ); | 631 | ); |
| 630 | const gitWorkflowLayer = GitWorkflowService.layer.pipe( | 632 | const gitWorkflowLayer = GitWorkflowService.layer.pipe( |
| 631 | Layer.provideMerge(vcsDriverRegistryLayer), | 633 | Layer.provideMerge(vcsDriverRegistryLayer), |
apps/server/src/server.ts+2-1| ... | @@ -67,6 +67,7 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; | ... | @@ -67,6 +67,7 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; |
| 67 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; | 67 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; |
| 68 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; | 68 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; |
| 69 | import * as ServerSettings from "./serverSettings.ts"; | 69 | import * as ServerSettings from "./serverSettings.ts"; |
| 70 | import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; | ||
| 70 | import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; | 71 | import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; |
| 71 | import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; | 72 | import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; |
| 72 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; | 73 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| ... | @@ -443,7 +444,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -443,7 +444,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 443 | // keeps a single Live for all opencode consumers. | 444 | // keeps a single Live for all opencode consumers. |
| 444 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), | 445 | Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), |
| 445 | Layer.provideMerge(WorkspaceLayerLive), | 446 | Layer.provideMerge(WorkspaceLayerLive), |
| 446 | Layer.provideMerge(ProjectFaviconResolverLayerLive), | 447 | Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, ProjectFaviconResolverLayerLive)), |
| 447 | Layer.provideMerge(RepositoryIdentityResolver.layer), | 448 | Layer.provideMerge(RepositoryIdentityResolver.layer), |
| 448 | Layer.provideMerge(ServerEnvironmentLayerLive), | 449 | Layer.provideMerge(ServerEnvironmentLayerLive), |
| 449 | Layer.provideMerge(AuthLayerLive), | 450 | Layer.provideMerge(AuthLayerLive), |
apps/server/src/ws.ts+4-1| ... | @@ -2125,7 +2125,10 @@ const makeWsRpcLayer = ( | ... | @@ -2125,7 +2125,10 @@ const makeWsRpcLayer = ( |
| 2125 | observeRpcEffect( | 2125 | observeRpcEffect( |
| 2126 | WS_METHODS.assetsCreateUrl, | 2126 | WS_METHODS.assetsCreateUrl, |
| 2127 | Effect.gen(function* () { | 2127 | Effect.gen(function* () { |
| 2128 | if (input.resource._tag === "attachment") { | 2128 | if ( |
| 2129 | input.resource._tag === "attachment" || | ||
| 2130 | input.resource._tag === "native-app-icon" | ||
| 2131 | ) { | ||
| 2129 | return yield* issueAssetUrl({ resource: input.resource }); | 2132 | return yield* issueAssetUrl({ resource: input.resource }); |
| 2130 | } | 2133 | } |
| 2131 | if (input.resource._tag === "project-favicon") { | 2134 | if (input.resource._tag === "project-favicon") { |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+112| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | resolveWorkGroupScrollIndex, | 10 | resolveWorkGroupScrollIndex, |
| 11 | shouldFollowWorkGroupAppend, | 11 | shouldFollowWorkGroupAppend, |
| 12 | shouldPreserveAssistantLineBreaks, | 12 | shouldPreserveAssistantLineBreaks, |
| 13 | type MessagesTimelineRow, | ||
| 13 | workEntryDisplayLabel, | 14 | workEntryDisplayLabel, |
| 14 | } from "./MessagesTimeline.logic"; | 15 | } from "./MessagesTimeline.logic"; |
| 15 | 16 | ||
| ... | @@ -1660,6 +1661,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1660,6 +1661,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1660 | label: "Status updated", | 1661 | label: "Status updated", |
| 1661 | detail: "Editing MessagesTimeline.tsx", | 1662 | detail: "Editing MessagesTimeline.tsx", |
| 1662 | tone: middleTone, | 1663 | tone: middleTone, |
| 1664 | toolSurface: "computer" as const, | ||
| 1663 | }, | 1665 | }, |
| 1664 | }, | 1666 | }, |
| 1665 | { | 1667 | { |
| ... | @@ -1672,6 +1674,8 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1672,6 +1674,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1672 | label: "test", | 1674 | label: "test", |
| 1673 | detail: "Running tests", | 1675 | detail: "Running tests", |
| 1674 | tone: "tool" as const, | 1676 | tone: "tool" as const, |
| 1677 | toolSurface: "browser" as const, | ||
| 1678 | toolIcon: { _tag: "website" as const, pageUrl: "https://example.com/checkout" }, | ||
| 1675 | }, | 1679 | }, |
| 1676 | }, | 1680 | }, |
| 1677 | ]; | 1681 | ]; |
| ... | @@ -1695,6 +1699,8 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1695,6 +1699,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1695 | hiddenCount: 3, | 1699 | hiddenCount: 3, |
| 1696 | expanded: false, | 1700 | expanded: false, |
| 1697 | summary, | 1701 | summary, |
| 1702 | toolSurface: "browser", | ||
| 1703 | toolIcon: { _tag: "website", pageUrl: "https://example.com/checkout" }, | ||
| 1698 | }); | 1704 | }); |
| 1699 | expect(expandedRows.map((row) => row.id)).toEqual([ | 1705 | expect(expandedRows.map((row) => row.id)).toEqual([ |
| 1700 | "work-toggle:work-entry-1", | 1706 | "work-toggle:work-entry-1", |
| ... | @@ -1709,6 +1715,84 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1709,6 +1715,84 @@ describe("deriveMessagesTimelineRows", () => { |
| 1709 | }); | 1715 | }); |
| 1710 | }); | 1716 | }); |
| 1711 | 1717 | ||
| 1718 | it("deduplicates integration sources and uses the first source icon for the group", () => { | ||
| 1719 | const chromeSource = { | ||
| 1720 | key: "browser-use:chrome", | ||
| 1721 | name: "Chrome", | ||
| 1722 | kind: "integration" as const, | ||
| 1723 | icon: { | ||
| 1724 | _tag: "native-app" as const, | ||
| 1725 | app: { _tag: "display-name" as const, displayName: "Google Chrome" }, | ||
| 1726 | }, | ||
| 1727 | }; | ||
| 1728 | const timelineEntries = [ | ||
| 1729 | { | ||
| 1730 | id: "browser-1", | ||
| 1731 | kind: "work" as const, | ||
| 1732 | createdAt: "2026-01-01T00:00:01Z", | ||
| 1733 | entry: { | ||
| 1734 | id: "browser-1", | ||
| 1735 | createdAt: "2026-01-01T00:00:01Z", | ||
| 1736 | label: "Open MATLAB", | ||
| 1737 | tone: "tool" as const, | ||
| 1738 | toolSurface: "browser" as const, | ||
| 1739 | toolSource: chromeSource, | ||
| 1740 | toolIcon: { | ||
| 1741 | _tag: "website" as const, | ||
| 1742 | pageUrl: "https://www.mathworks.com/help/matlab/", | ||
| 1743 | }, | ||
| 1744 | }, | ||
| 1745 | }, | ||
| 1746 | { | ||
| 1747 | id: "browser-2", | ||
| 1748 | kind: "work" as const, | ||
| 1749 | createdAt: "2026-01-01T00:00:02Z", | ||
| 1750 | entry: { | ||
| 1751 | id: "browser-2", | ||
| 1752 | createdAt: "2026-01-01T00:00:02Z", | ||
| 1753 | label: "Show summary", | ||
| 1754 | tone: "tool" as const, | ||
| 1755 | toolSurface: "browser" as const, | ||
| 1756 | toolSource: chromeSource, | ||
| 1757 | toolIcon: { | ||
| 1758 | _tag: "website" as const, | ||
| 1759 | pageUrl: "https://www.mathworks.com/help/matlab/summary.html", | ||
| 1760 | }, | ||
| 1761 | }, | ||
| 1762 | }, | ||
| 1763 | { | ||
| 1764 | id: "command-1", | ||
| 1765 | kind: "work" as const, | ||
| 1766 | createdAt: "2026-01-01T00:00:03Z", | ||
| 1767 | entry: { | ||
| 1768 | id: "command-1", | ||
| 1769 | createdAt: "2026-01-01T00:00:03Z", | ||
| 1770 | label: "Ran command", | ||
| 1771 | command: "git status", | ||
| 1772 | itemType: "command_execution" as const, | ||
| 1773 | tone: "tool" as const, | ||
| 1774 | }, | ||
| 1775 | }, | ||
| 1776 | ]; | ||
| 1777 | const [row] = deriveMessagesTimelineRows({ | ||
| 1778 | timelineEntries, | ||
| 1779 | isWorking: false, | ||
| 1780 | activeTurnStartedAt: null, | ||
| 1781 | turnDiffSummaryByAssistantMessageId: new Map(), | ||
| 1782 | revertTurnCountByUserMessageId: new Map(), | ||
| 1783 | }); | ||
| 1784 | |||
| 1785 | expect(row).toMatchObject({ | ||
| 1786 | kind: "work-toggle", | ||
| 1787 | summary: "Used Chrome integration and ran 1 command", | ||
| 1788 | toolSurface: "browser", | ||
| 1789 | toolIcon: { | ||
| 1790 | _tag: "website", | ||
| 1791 | pageUrl: "https://www.mathworks.com/help/matlab/", | ||
| 1792 | }, | ||
| 1793 | }); | ||
| 1794 | }); | ||
| 1795 | |||
| 1712 | it.each([true, false])( | 1796 | it.each([true, false])( |
| 1713 | "keeps a large expanded tool run inside one timeline item, live=%s", | 1797 | "keeps a large expanded tool run inside one timeline item, live=%s", |
| 1714 | (isWorking) => { | 1798 | (isWorking) => { |
| ... | @@ -1845,6 +1929,34 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1845,6 +1929,34 @@ describe("deriveMessagesTimelineRows", () => { |
| 1845 | }); | 1929 | }); |
| 1846 | 1930 | ||
| 1847 | describe("computeStableMessagesTimelineRows", () => { | 1931 | describe("computeStableMessagesTimelineRows", () => { |
| 1932 | it("replaces a cached work toggle when its icon presentation changes", () => { | ||
| 1933 | const initialRow: MessagesTimelineRow = { | ||
| 1934 | kind: "work-toggle", | ||
| 1935 | id: "work-toggle:1", | ||
| 1936 | createdAt: "2026-01-01T00:00:00Z", | ||
| 1937 | groupId: "work-group:1", | ||
| 1938 | hiddenCount: 1, | ||
| 1939 | expanded: false, | ||
| 1940 | summary: "Used Browser", | ||
| 1941 | summaryKind: "other", | ||
| 1942 | toolSurface: "browser", | ||
| 1943 | hasFailure: false, | ||
| 1944 | }; | ||
| 1945 | const initial = computeStableMessagesTimelineRows([initialRow], { | ||
| 1946 | byId: new Map(), | ||
| 1947 | result: [], | ||
| 1948 | }); | ||
| 1949 | const enrichedRow: MessagesTimelineRow = { | ||
| 1950 | ...initialRow, | ||
| 1951 | toolIcon: { _tag: "website", pageUrl: "https://example.com" }, | ||
| 1952 | }; | ||
| 1953 | |||
| 1954 | const updated = computeStableMessagesTimelineRows([enrichedRow], initial); | ||
| 1955 | |||
| 1956 | expect(updated).not.toBe(initial); | ||
| 1957 | expect(updated.result[0]).toBe(enrichedRow); | ||
| 1958 | }); | ||
| 1959 | |||
| 1848 | it.each(["", " \n"])("keeps Thinking after assistant content grows from %j", (text) => { | 1960 | it.each(["", " \n"])("keeps Thinking after assistant content grows from %j", (text) => { |
| 1849 | const startedAt = "2026-01-01T00:00:00Z"; | 1961 | const startedAt = "2026-01-01T00:00:00Z"; |
| 1850 | const turnId = TurnId.make("turn-1"); | 1962 | const turnId = TurnId.make("turn-1"); |
apps/web/src/components/chat/MessagesTimeline.logic.ts+22| ... | @@ -304,6 +304,8 @@ export type MessagesTimelineRow = | ... | @@ -304,6 +304,8 @@ export type MessagesTimelineRow = |
| 304 | expanded: boolean; | 304 | expanded: boolean; |
| 305 | summary: string; | 305 | summary: string; |
| 306 | summaryKind: ToolGroupSummaryKind; | 306 | summaryKind: ToolGroupSummaryKind; |
| 307 | toolSurface?: WorkLogEntry["toolSurface"]; | ||
| 308 | toolIcon?: WorkLogEntry["toolIcon"]; | ||
| 307 | summaryToolIcon?: "browser" | "t3-code"; | 309 | summaryToolIcon?: "browser" | "t3-code"; |
| 308 | hasFailure: boolean; | 310 | hasFailure: boolean; |
| 309 | } | 311 | } |
| ... | @@ -849,6 +851,22 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -849,6 +851,22 @@ export function deriveMessagesTimelineRows(input: { |
| 849 | const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); | 851 | const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); |
| 850 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; | 852 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; |
| 851 | const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); | 853 | const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); |
| 854 | const primarySourceEntry = visibleGroupedEntries.find( | ||
| 855 | (entry) => entry.toolSource !== undefined, | ||
| 856 | ); | ||
| 857 | const primarySourceKey = primarySourceEntry?.toolSource?.key; | ||
| 858 | const primarySourceIcon = primarySourceKey | ||
| 859 | ? (visibleGroupedEntries.find( | ||
| 860 | (entry) => | ||
| 861 | entry.toolSource?.key === primarySourceKey && entry.toolIcon !== undefined, | ||
| 862 | )?.toolIcon ?? primarySourceEntry?.toolSource?.icon) | ||
| 863 | : undefined; | ||
| 864 | const groupToolSurface = | ||
| 865 | primarySourceEntry?.toolSurface ?? | ||
| 866 | visibleGroupedEntries.findLast((entry) => entry.toolSurface !== undefined)?.toolSurface; | ||
| 867 | const groupToolIcon = | ||
| 868 | primarySourceIcon ?? | ||
| 869 | visibleGroupedEntries.findLast((entry) => entry.toolIcon !== undefined)?.toolIcon; | ||
| 852 | const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike); | 870 | const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike); |
| 853 | const singleEntry = | 871 | const singleEntry = |
| 854 | visibleGroupedEntries.length === 1 ? (visibleGroupedEntries[0] ?? null) : null; | 872 | visibleGroupedEntries.length === 1 ? (visibleGroupedEntries[0] ?? null) : null; |
| ... | @@ -872,6 +890,8 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -872,6 +890,8 @@ export function deriveMessagesTimelineRows(input: { |
| 872 | ? singleEntry.label | 890 | ? singleEntry.label |
| 873 | : summarizeToolGroup(visibleGroupedEntries), | 891 | : summarizeToolGroup(visibleGroupedEntries), |
| 874 | summaryKind, | 892 | summaryKind, |
| 893 | ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}), | ||
| 894 | ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}), | ||
| 875 | ...(summaryToolIcon ? { summaryToolIcon } : {}), | 895 | ...(summaryToolIcon ? { summaryToolIcon } : {}), |
| 876 | hasFailure: | 896 | hasFailure: |
| 877 | latestToolEntry !== undefined && | 897 | latestToolEntry !== undefined && |
| ... | @@ -1014,6 +1034,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean | ... | @@ -1014,6 +1034,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 1014 | a.expanded === bw.expanded && | 1034 | a.expanded === bw.expanded && |
| 1015 | a.summary === bw.summary && | 1035 | a.summary === bw.summary && |
| 1016 | a.summaryKind === bw.summaryKind && | 1036 | a.summaryKind === bw.summaryKind && |
| 1037 | a.toolSurface === bw.toolSurface && | ||
| 1038 | Equal.equals(a.toolIcon, bw.toolIcon) && | ||
| 1017 | a.hasFailure === bw.hasFailure | 1039 | a.hasFailure === bw.hasFailure |
| 1018 | ); | 1040 | ); |
| 1019 | } | 1041 | } |
apps/web/src/components/chat/MessagesTimeline.tsx+251-16| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | type MessageId, | 4 | type MessageId, |
| 5 | type ScopedThreadRef, | 5 | type ScopedThreadRef, |
| 6 | type ServerProviderSkill, | 6 | type ServerProviderSkill, |
| 7 | type ToolActivityIcon, | ||
| 7 | type TurnId, | 8 | type TurnId, |
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 9 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | 10 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| ... | @@ -25,6 +26,8 @@ const NOOP_OPEN_AGENTS = () => {}; | ... | @@ -25,6 +26,8 @@ const NOOP_OPEN_AGENTS = () => {}; |
| 25 | const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; | 26 | const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; |
| 26 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; | 27 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; |
| 27 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 28 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 29 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; | ||
| 30 | import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; | ||
| 28 | import { | 31 | import { |
| 29 | createContext, | 32 | createContext, |
| 30 | Fragment, | 33 | Fragment, |
| ... | @@ -32,6 +35,7 @@ import { | ... | @@ -32,6 +35,7 @@ import { |
| 32 | use, | 35 | use, |
| 33 | useCallback, | 36 | useCallback, |
| 34 | useEffect, | 37 | useEffect, |
| 38 | useId, | ||
| 35 | useLayoutEffect, | 39 | useLayoutEffect, |
| 36 | useMemo, | 40 | useMemo, |
| 37 | useRef, | 41 | useRef, |
| ... | @@ -155,7 +159,6 @@ import { cn } from "~/lib/utils"; | ... | @@ -155,7 +159,6 @@ import { cn } from "~/lib/utils"; |
| 155 | import { useUiStateStore } from "~/uiStateStore"; | 159 | import { useUiStateStore } from "~/uiStateStore"; |
| 156 | import { type TimestampFormat } from "@t3tools/contracts/settings"; | 160 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 157 | import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; | 161 | import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; |
| 158 | |||
| 159 | import { | 162 | import { |
| 160 | buildInlineTerminalContextText, | 163 | buildInlineTerminalContextText, |
| 161 | formatInlineTerminalContextLabel, | 164 | formatInlineTerminalContextLabel, |
| ... | @@ -1768,10 +1771,12 @@ function ActivityShimmerOverlay({ children }: { children: ReactNode }) { | ... | @@ -1768,10 +1771,12 @@ function ActivityShimmerOverlay({ children }: { children: ReactNode }) { |
| 1768 | function LiveActivityRow({ | 1771 | function LiveActivityRow({ |
| 1769 | label, | 1772 | label, |
| 1770 | iconName, | 1773 | iconName, |
| 1774 | toolIcon, | ||
| 1771 | failed = false, | 1775 | failed = false, |
| 1772 | }: { | 1776 | }: { |
| 1773 | label: string; | 1777 | label: string; |
| 1774 | iconName?: WorkEntryIconName; | 1778 | iconName?: WorkEntryIconName; |
| 1779 | toolIcon?: ToolActivityIcon | undefined; | ||
| 1775 | failed?: boolean; | 1780 | failed?: boolean; |
| 1776 | }) { | 1781 | }) { |
| 1777 | return ( | 1782 | return ( |
| ... | @@ -1779,11 +1784,18 @@ function LiveActivityRow({ | ... | @@ -1779,11 +1784,18 @@ function LiveActivityRow({ |
| 1779 | <LiveActivityContent | 1784 | <LiveActivityContent |
| 1780 | label={label} | 1785 | label={label} |
| 1781 | iconName={iconName} | 1786 | iconName={iconName} |
| 1787 | toolIcon={toolIcon} | ||
| 1782 | failed={failed} | 1788 | failed={failed} |
| 1783 | announceFailure={failed} | 1789 | announceFailure={failed} |
| 1784 | /> | 1790 | /> |
| 1785 | <ActivityShimmerOverlay> | 1791 | <ActivityShimmerOverlay> |
| 1786 | <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted /> | 1792 | <LiveActivityContent |
| 1793 | label={label} | ||
| 1794 | iconName={iconName} | ||
| 1795 | toolIcon={toolIcon} | ||
| 1796 | failed={failed} | ||
| 1797 | highlighted | ||
| 1798 | /> | ||
| 1787 | </ActivityShimmerOverlay> | 1799 | </ActivityShimmerOverlay> |
| 1788 | </div> | 1800 | </div> |
| 1789 | ); | 1801 | ); |
| ... | @@ -1792,17 +1804,20 @@ function LiveActivityRow({ | ... | @@ -1792,17 +1804,20 @@ function LiveActivityRow({ |
| 1792 | function LiveActivityContent({ | 1804 | function LiveActivityContent({ |
| 1793 | label, | 1805 | label, |
| 1794 | iconName, | 1806 | iconName, |
| 1807 | toolIcon, | ||
| 1795 | failed = false, | 1808 | failed = false, |
| 1796 | announceFailure = false, | 1809 | announceFailure = false, |
| 1797 | highlighted = false, | 1810 | highlighted = false, |
| 1798 | }: { | 1811 | }: { |
| 1799 | label: string; | 1812 | label: string; |
| 1800 | iconName: WorkEntryIconName | undefined; | 1813 | iconName: WorkEntryIconName | undefined; |
| 1814 | toolIcon?: ToolActivityIcon | undefined; | ||
| 1801 | failed?: boolean; | 1815 | failed?: boolean; |
| 1802 | announceFailure?: boolean; | 1816 | announceFailure?: boolean; |
| 1803 | highlighted?: boolean; | 1817 | highlighted?: boolean; |
| 1804 | }) { | 1818 | }) { |
| 1805 | const isSpecialToolIcon = iconName === "browser" || iconName === "t3-code"; | 1819 | const isSpecialToolIcon = |
| 1820 | iconName === "browser" || iconName === "computer" || iconName === "t3-code"; | ||
| 1806 | const resolvedIconName = failed && !isSpecialToolIcon ? "circle-alert" : iconName; | 1821 | const resolvedIconName = failed && !isSpecialToolIcon ? "circle-alert" : iconName; |
| 1807 | 1822 | ||
| 1808 | return ( | 1823 | return ( |
| ... | @@ -1822,9 +1837,11 @@ function LiveActivityContent({ | ... | @@ -1822,9 +1837,11 @@ function LiveActivityContent({ |
| 1822 | role={announceFailure ? "img" : undefined} | 1837 | role={announceFailure ? "img" : undefined} |
| 1823 | aria-label={announceFailure ? "Tool call failed" : undefined} | 1838 | aria-label={announceFailure ? "Tool call failed" : undefined} |
| 1824 | > | 1839 | > |
| 1825 | <WorkEntryIcon | 1840 | <ToolActivityIconView |
| 1826 | name={resolvedIconName} | 1841 | icon={failed && !isSpecialToolIcon ? undefined : toolIcon} |
| 1827 | className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")} | 1842 | fallbackName={resolvedIconName} |
| 1843 | className="block size-4 shrink-0 stroke-[1.8]" | ||
| 1844 | muted={!highlighted} | ||
| 1828 | /> | 1845 | /> |
| 1829 | </span> | 1846 | </span> |
| 1830 | ) : null} | 1847 | ) : null} |
| ... | @@ -1848,12 +1865,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: " | ... | @@ -1848,12 +1865,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: " |
| 1848 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} | 1865 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} |
| 1849 | > | 1866 | > |
| 1850 | {row.active ? ( | 1867 | {row.active ? ( |
| 1851 | <LiveActivityRow label={label} iconName={workEntryIconName(row.entry)} failed={failed} /> | 1868 | <LiveActivityRow |
| 1869 | label={label} | ||
| 1870 | iconName={workEntryIconName(row.entry)} | ||
| 1871 | toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon} | ||
| 1872 | failed={failed} | ||
| 1873 | /> | ||
| 1852 | ) : ( | 1874 | ) : ( |
| 1853 | <div className="min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed"> | 1875 | <div className="min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed"> |
| 1854 | <LiveActivityContent | 1876 | <LiveActivityContent |
| 1855 | label={label} | 1877 | label={label} |
| 1856 | iconName={workEntryIconName(row.entry)} | 1878 | iconName={workEntryIconName(row.entry)} |
| 1879 | toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon} | ||
| 1857 | failed={failed} | 1880 | failed={failed} |
| 1858 | announceFailure={failed} | 1881 | announceFailure={failed} |
| 1859 | /> | 1882 | /> |
| ... | @@ -1908,9 +1931,13 @@ function WorkGroupToggleTimelineRow({ | ... | @@ -1908,9 +1931,13 @@ function WorkGroupToggleTimelineRow({ |
| 1908 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} | 1931 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} |
| 1909 | > | 1932 | > |
| 1910 | <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted"> | 1933 | <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted"> |
| 1911 | <WorkEntryIcon | 1934 | <ToolActivityIconView |
| 1912 | name={row.summaryToolIcon ?? toolGroupSummaryIconName(row.summaryKind)} | 1935 | icon={row.toolIcon} |
| 1913 | className="size-4 shrink-0 stroke-[1.8] opacity-70" | 1936 | fallbackName={ |
| 1937 | row.summaryToolIcon ?? row.toolSurface ?? toolGroupSummaryIconName(row.summaryKind) | ||
| 1938 | } | ||
| 1939 | className="size-4 shrink-0 stroke-[1.8]" | ||
| 1940 | muted | ||
| 1914 | /> | 1941 | /> |
| 1915 | </span> | 1942 | </span> |
| 1916 | <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span> | 1943 | <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span> |
| ... | @@ -2445,6 +2472,7 @@ type WorkEntryIconName = | ... | @@ -2445,6 +2472,7 @@ type WorkEntryIconName = |
| 2445 | | "browser" | 2472 | | "browser" |
| 2446 | | "check" | 2473 | | "check" |
| 2447 | | "circle-alert" | 2474 | | "circle-alert" |
| 2475 | | "computer" | ||
| 2448 | | "eye" | 2476 | | "eye" |
| 2449 | | "globe" | 2477 | | "globe" |
| 2450 | | "hammer" | 2478 | | "hammer" |
| ... | @@ -2457,6 +2485,203 @@ type WorkEntryIconName = | ... | @@ -2457,6 +2485,203 @@ type WorkEntryIconName = |
| 2457 | | "x" | 2485 | | "x" |
| 2458 | | "zap"; | 2486 | | "zap"; |
| 2459 | 2487 | ||
| 2488 | function BrowserAppIcon({ className }: { className: string }) { | ||
| 2489 | return ( | ||
| 2490 | <svg | ||
| 2491 | viewBox="0 0 24 24" | ||
| 2492 | fill="none" | ||
| 2493 | stroke="currentColor" | ||
| 2494 | strokeWidth="1.9" | ||
| 2495 | strokeLinecap="round" | ||
| 2496 | strokeLinejoin="round" | ||
| 2497 | className={className} | ||
| 2498 | aria-hidden | ||
| 2499 | > | ||
| 2500 | <path d="M8.5 19H7.2C4.4 19 3 17.5 3 14.6V7.4C3 4.5 4.5 3 7.4 3h8.2C18.5 3 20 4.5 20 7.4v2.4" /> | ||
| 2501 | <circle cx="7.4" cy="7.2" r="0.75" fill="currentColor" stroke="none" /> | ||
| 2502 | <path d="M11.2 7.2h4.3" /> | ||
| 2503 | <path d="m12.4 11.4 7.5 2.6-3.4 1.6-1.5 3.6z" fill="currentColor" stroke="none" /> | ||
| 2504 | </svg> | ||
| 2505 | ); | ||
| 2506 | } | ||
| 2507 | |||
| 2508 | function ComputerUseAppIcon({ className }: { className: string }) { | ||
| 2509 | const gradientId = `${useId().replaceAll(":", "")}-computer-use-app-gradient`; | ||
| 2510 | return ( | ||
| 2511 | <svg viewBox="0 0 24 24" className={className} aria-hidden> | ||
| 2512 | <defs> | ||
| 2513 | <linearGradient id={gradientId} x1="2" y1="2" x2="22" y2="22"> | ||
| 2514 | <stop offset="0" stopColor="#00dff0" /> | ||
| 2515 | <stop offset="0.42" stopColor="#3b9cff" /> | ||
| 2516 | <stop offset="0.72" stopColor="#b044f5" /> | ||
| 2517 | <stop offset="1" stopColor="#ff78b6" /> | ||
| 2518 | </linearGradient> | ||
| 2519 | </defs> | ||
| 2520 | <rect x="1" y="1" width="22" height="22" rx="5" fill={`url(#${gradientId})`} /> | ||
| 2521 | <path | ||
| 2522 | d="m7.2 6.2 10.5 4.1-4.2 2.1-2 4.7z" | ||
| 2523 | fill="white" | ||
| 2524 | stroke="#315cff" | ||
| 2525 | strokeWidth="1.1" | ||
| 2526 | strokeLinejoin="round" | ||
| 2527 | /> | ||
| 2528 | </svg> | ||
| 2529 | ); | ||
| 2530 | } | ||
| 2531 | |||
| 2532 | function ToolActivityIconView(props: { | ||
| 2533 | icon: ToolActivityIcon | undefined; | ||
| 2534 | fallbackName: WorkEntryIconName; | ||
| 2535 | className: string; | ||
| 2536 | muted: boolean; | ||
| 2537 | }) { | ||
| 2538 | const { resolvedTheme } = use(TimelineRowCtx); | ||
| 2539 | const fallbackClassName = cn(props.className, props.muted && "opacity-70 light:brightness-[.6]"); | ||
| 2540 | if (!props.icon) { | ||
| 2541 | return <WorkEntryIcon name={props.fallbackName} className={fallbackClassName} />; | ||
| 2542 | } | ||
| 2543 | if (props.icon._tag === "website") { | ||
| 2544 | const src = toolActivityFaviconUrl(props.icon, resolvedTheme, 32); | ||
| 2545 | return src ? ( | ||
| 2546 | <ToolActivityImageIcon | ||
| 2547 | key={src} | ||
| 2548 | cacheKey={src} | ||
| 2549 | src={src} | ||
| 2550 | fallbackName={props.fallbackName} | ||
| 2551 | className={props.className} | ||
| 2552 | muted={props.muted} | ||
| 2553 | /> | ||
| 2554 | ) : ( | ||
| 2555 | <WorkEntryIcon name={props.fallbackName} className={fallbackClassName} /> | ||
| 2556 | ); | ||
| 2557 | } | ||
| 2558 | if (props.icon._tag === "themed-logo") { | ||
| 2559 | const src = | ||
| 2560 | resolvedTheme === "dark" | ||
| 2561 | ? (props.icon.logoUrlDark ?? props.icon.logoUrl) | ||
| 2562 | : props.icon.logoUrl; | ||
| 2563 | return ( | ||
| 2564 | <ToolActivityImageIcon | ||
| 2565 | key={src} | ||
| 2566 | cacheKey={src} | ||
| 2567 | src={src} | ||
| 2568 | fallbackName={props.fallbackName} | ||
| 2569 | className={props.className} | ||
| 2570 | muted={props.muted} | ||
| 2571 | /> | ||
| 2572 | ); | ||
| 2573 | } | ||
| 2574 | return ( | ||
| 2575 | <NativeAppToolActivityIcon | ||
| 2576 | app={props.icon.app} | ||
| 2577 | fallbackName={props.fallbackName} | ||
| 2578 | className={props.className} | ||
| 2579 | muted={props.muted} | ||
| 2580 | /> | ||
| 2581 | ); | ||
| 2582 | } | ||
| 2583 | |||
| 2584 | function NativeAppToolActivityIcon(props: { | ||
| 2585 | app: Extract<ToolActivityIcon, { readonly _tag: "native-app" }>["app"]; | ||
| 2586 | fallbackName: WorkEntryIconName; | ||
| 2587 | className: string; | ||
| 2588 | muted: boolean; | ||
| 2589 | }) { | ||
| 2590 | const { activeThreadEnvironmentId } = use(TimelineRowCtx); | ||
| 2591 | const asset = useAssetUrlState(activeThreadEnvironmentId, { | ||
| 2592 | _tag: "native-app-icon", | ||
| 2593 | app: props.app, | ||
| 2594 | }); | ||
| 2595 | if (asset._tag !== "Success") { | ||
| 2596 | return ( | ||
| 2597 | <WorkEntryIcon | ||
| 2598 | name={props.fallbackName} | ||
| 2599 | className={cn(props.className, props.muted && "opacity-70 light:brightness-[.6]")} | ||
| 2600 | /> | ||
| 2601 | ); | ||
| 2602 | } | ||
| 2603 | const cacheKey = getProjectFaviconCacheKey( | ||
| 2604 | activeThreadEnvironmentId, | ||
| 2605 | JSON.stringify(props.app), | ||
| 2606 | asset.url, | ||
| 2607 | ); | ||
| 2608 | return ( | ||
| 2609 | <ToolActivityImageIcon | ||
| 2610 | key={cacheKey} | ||
| 2611 | cacheKey={cacheKey} | ||
| 2612 | src={asset.url} | ||
| 2613 | fallbackName={props.fallbackName} | ||
| 2614 | className={props.className} | ||
| 2615 | muted={props.muted} | ||
| 2616 | /> | ||
| 2617 | ); | ||
| 2618 | } | ||
| 2619 | |||
| 2620 | const loadedToolActivityIconSrcs = new Map<string, string>(); | ||
| 2621 | |||
| 2622 | function ToolActivityImageIcon(props: { | ||
| 2623 | cacheKey: string; | ||
| 2624 | src: string; | ||
| 2625 | fallbackName: WorkEntryIconName; | ||
| 2626 | className: string; | ||
| 2627 | muted: boolean; | ||
| 2628 | }) { | ||
| 2629 | const [displayedSrc, setDisplayedSrc] = useState<string | null>( | ||
| 2630 | () => loadedToolActivityIconSrcs.get(props.cacheKey) ?? null, | ||
| 2631 | ); | ||
| 2632 | const isLoading = displayedSrc !== props.src; | ||
| 2633 | const handleLoadError = (failedSrc: string) => { | ||
| 2634 | if (loadedToolActivityIconSrcs.get(props.cacheKey) === failedSrc) { | ||
| 2635 | loadedToolActivityIconSrcs.delete(props.cacheKey); | ||
| 2636 | } | ||
| 2637 | setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); | ||
| 2638 | }; | ||
| 2639 | return ( | ||
| 2640 | <> | ||
| 2641 | {displayedSrc === null ? ( | ||
| 2642 | <WorkEntryIcon | ||
| 2643 | name={props.fallbackName} | ||
| 2644 | className={cn(props.className, props.muted && "opacity-70 light:brightness-[.6]")} | ||
| 2645 | /> | ||
| 2646 | ) : null} | ||
| 2647 | {displayedSrc ? ( | ||
| 2648 | <span | ||
| 2649 | className={cn( | ||
| 2650 | props.className, | ||
| 2651 | "inline-block overflow-hidden rounded-[3px] bg-background", | ||
| 2652 | props.muted && "opacity-70", | ||
| 2653 | )} | ||
| 2654 | > | ||
| 2655 | <img | ||
| 2656 | src={displayedSrc} | ||
| 2657 | alt="" | ||
| 2658 | aria-hidden | ||
| 2659 | decoding="async" | ||
| 2660 | referrerPolicy="no-referrer" | ||
| 2661 | className={cn("block size-full object-contain", props.muted && "light:brightness-[.6]")} | ||
| 2662 | onError={() => handleLoadError(displayedSrc)} | ||
| 2663 | /> | ||
| 2664 | </span> | ||
| 2665 | ) : null} | ||
| 2666 | {isLoading ? ( | ||
| 2667 | <img | ||
| 2668 | src={props.src} | ||
| 2669 | alt="" | ||
| 2670 | aria-hidden | ||
| 2671 | decoding="async" | ||
| 2672 | referrerPolicy="no-referrer" | ||
| 2673 | className="hidden" | ||
| 2674 | onLoad={() => { | ||
| 2675 | loadedToolActivityIconSrcs.set(props.cacheKey, props.src); | ||
| 2676 | setDisplayedSrc(props.src); | ||
| 2677 | }} | ||
| 2678 | onError={() => handleLoadError(props.src)} | ||
| 2679 | /> | ||
| 2680 | ) : null} | ||
| 2681 | </> | ||
| 2682 | ); | ||
| 2683 | } | ||
| 2684 | |||
| 2460 | function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { | 2685 | function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { |
| 2461 | switch (name) { | 2686 | switch (name) { |
| 2462 | case "bot": | 2687 | case "bot": |
| ... | @@ -2464,7 +2689,9 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className | ... | @@ -2464,7 +2689,9 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className |
| 2464 | case "brain": | 2689 | case "brain": |
| 2465 | return <BrainIcon className={className} aria-hidden />; | 2690 | return <BrainIcon className={className} aria-hidden />; |
| 2466 | case "browser": | 2691 | case "browser": |
| 2467 | return <GlobeIcon className={className} aria-hidden />; | 2692 | return <BrowserAppIcon className={className} />; |
| 2693 | case "computer": | ||
| 2694 | return <ComputerUseAppIcon className={className} />; | ||
| 2468 | case "t3-code": | 2695 | case "t3-code": |
| 2469 | return <T3Wordmark className={className} aria-hidden />; | 2696 | return <T3Wordmark className={className} aria-hidden />; |
| 2470 | case "check": | 2697 | case "check": |
| ... | @@ -2570,6 +2797,7 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { | ... | @@ -2570,6 +2797,7 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { |
| 2570 | ) { | 2797 | ) { |
| 2571 | return "message-circle"; | 2798 | return "message-circle"; |
| 2572 | } | 2799 | } |
| 2800 | if (workEntry.toolSurface) return workEntry.toolSurface; | ||
| 2573 | const toolPresentation = resolveWorkEntryToolPresentation(workEntry); | 2801 | const toolPresentation = resolveWorkEntryToolPresentation(workEntry); |
| 2574 | if (toolPresentation) return toolPresentation.icon; | 2802 | if (toolPresentation) return toolPresentation.icon; |
| 2575 | const action = toolGroupAction(workEntry); | 2803 | const action = toolGroupAction(workEntry); |
| ... | @@ -2731,8 +2959,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -2731,8 +2959,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2731 | const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; | 2959 | const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; |
| 2732 | const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry); | 2960 | const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry); |
| 2733 | const toolPresentation = resolveWorkEntryToolPresentation(workEntry); | 2961 | const toolPresentation = resolveWorkEntryToolPresentation(workEntry); |
| 2962 | const hasSpecialToolIcon = toolPresentation !== null || workEntry.toolSurface !== undefined; | ||
| 2734 | const entryIconName = | 2963 | const entryIconName = |
| 2735 | showWarningIndicator || (showFailedIndicator && !toolPresentation) | 2964 | showWarningIndicator || (showFailedIndicator && !hasSpecialToolIcon) |
| 2736 | ? "circle-alert" | 2965 | ? "circle-alert" |
| 2737 | : workEntryIconName(workEntry); | 2966 | : workEntryIconName(workEntry); |
| 2738 | const previewText = workEntryDisplayLabel(workEntry, workspaceRoot); | 2967 | const previewText = workEntryDisplayLabel(workEntry, workspaceRoot); |
| ... | @@ -2812,9 +3041,15 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -2812,9 +3041,15 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2812 | role={showFailedIndicator ? "img" : undefined} | 3041 | role={showFailedIndicator ? "img" : undefined} |
| 2813 | aria-label={showFailedIndicator ? "Tool call failed" : undefined} | 3042 | aria-label={showFailedIndicator ? "Tool call failed" : undefined} |
| 2814 | > | 3043 | > |
| 2815 | <WorkEntryIcon | 3044 | <ToolActivityIconView |
| 2816 | name={entryIconName} | 3045 | icon={ |
| 2817 | className="block size-4 shrink-0 stroke-[1.8] opacity-70" | 3046 | showWarningIndicator || (showFailedIndicator && !hasSpecialToolIcon) |
| 3047 | ? undefined | ||
| 3048 | : (workEntry.toolIcon ?? workEntry.toolSource?.icon) | ||
| 3049 | } | ||
| 3050 | fallbackName={entryIconName} | ||
| 3051 | className="block size-4 shrink-0 stroke-[1.8]" | ||
| 3052 | muted | ||
| 2818 | /> | 3053 | /> |
| 2819 | </span> | 3054 | </span> |
| 2820 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> | 3055 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> |
| ... | @@ -2823,7 +3058,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -2823,7 +3058,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2823 | <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span> | 3058 | <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span> |
| 2824 | </p> | 3059 | </p> |
| 2825 | </div> | 3060 | </div> |
| 2826 | {showFailedIndicator && toolPresentation ? ( | 3061 | {showFailedIndicator && hasSpecialToolIcon ? ( |
| 2827 | <XIcon aria-hidden className="size-3 shrink-0 text-icon-muted" /> | 3062 | <XIcon aria-hidden className="size-3 shrink-0 text-icon-muted" /> |
| 2828 | ) : null} | 3063 | ) : null} |
| 2829 | <span | 3064 | <span |
apps/web/src/session-logic.test.ts+24| ... | @@ -1240,6 +1240,13 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -1240,6 +1240,13 @@ describe("deriveWorkLogEntries", () => { |
| 1240 | payload: { | 1240 | payload: { |
| 1241 | itemType: "mcp_tool_call", | 1241 | itemType: "mcp_tool_call", |
| 1242 | title: "t3-code · preview_status", | 1242 | title: "t3-code · preview_status", |
| 1243 | toolSurface: "browser", | ||
| 1244 | toolIcon: { _tag: "website", pageUrl: "https://example.com/checkout" }, | ||
| 1245 | toolSource: { | ||
| 1246 | key: "browser-use:browser", | ||
| 1247 | name: "Browser", | ||
| 1248 | kind: "browser", | ||
| 1249 | }, | ||
| 1243 | data: { item }, | 1250 | data: { item }, |
| 1244 | }, | 1251 | }, |
| 1245 | }), | 1252 | }), |
| ... | @@ -1247,6 +1254,16 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -1247,6 +1254,16 @@ describe("deriveWorkLogEntries", () => { |
| 1247 | 1254 | ||
| 1248 | const [entry] = deriveWorkLogEntries(activities); | 1255 | const [entry] = deriveWorkLogEntries(activities); |
| 1249 | expect(entry?.toolTitle).toBe("t3-code · preview_status"); | 1256 | expect(entry?.toolTitle).toBe("t3-code · preview_status"); |
| 1257 | expect(entry?.toolSurface).toBe("browser"); | ||
| 1258 | expect(entry?.toolIcon).toEqual({ | ||
| 1259 | _tag: "website", | ||
| 1260 | pageUrl: "https://example.com/checkout", | ||
| 1261 | }); | ||
| 1262 | expect(entry?.toolSource).toEqual({ | ||
| 1263 | key: "browser-use:browser", | ||
| 1264 | name: "Browser", | ||
| 1265 | kind: "browser", | ||
| 1266 | }); | ||
| 1250 | expect(entry?.toolData).toEqual(item); | 1267 | expect(entry?.toolData).toEqual(item); |
| 1251 | }); | 1268 | }); |
| 1252 | 1269 | ||
| ... | @@ -1296,6 +1313,7 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -1296,6 +1313,7 @@ describe("deriveWorkLogEntries", () => { |
| 1296 | payload: { | 1313 | payload: { |
| 1297 | itemType: "mcp_tool_call", | 1314 | itemType: "mcp_tool_call", |
| 1298 | toolCallId: "call-1", | 1315 | toolCallId: "call-1", |
| 1316 | toolSurface: "browser", | ||
| 1299 | data: { item }, | 1317 | data: { item }, |
| 1300 | }, | 1318 | }, |
| 1301 | }), | 1319 | }), |
| ... | @@ -1306,6 +1324,7 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -1306,6 +1324,7 @@ describe("deriveWorkLogEntries", () => { |
| 1306 | payload: { | 1324 | payload: { |
| 1307 | itemType: "mcp_tool_call", | 1325 | itemType: "mcp_tool_call", |
| 1308 | toolCallId: "call-1", | 1326 | toolCallId: "call-1", |
| 1327 | toolIcon: { _tag: "website", pageUrl: "https://example.com/result" }, | ||
| 1309 | }, | 1328 | }, |
| 1310 | }), | 1329 | }), |
| 1311 | ]; | 1330 | ]; |
| ... | @@ -1313,6 +1332,11 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -1313,6 +1332,11 @@ describe("deriveWorkLogEntries", () => { |
| 1313 | const [entry] = deriveWorkLogEntries(activities); | 1332 | const [entry] = deriveWorkLogEntries(activities); |
| 1314 | expect(entry?.toolData).toEqual(item); | 1333 | expect(entry?.toolData).toEqual(item); |
| 1315 | expect(entry?.toolCallId).toBe("call-1"); | 1334 | expect(entry?.toolCallId).toBe("call-1"); |
| 1335 | expect(entry?.toolSurface).toBe("browser"); | ||
| 1336 | expect(entry?.toolIcon).toEqual({ | ||
| 1337 | _tag: "website", | ||
| 1338 | pageUrl: "https://example.com/result", | ||
| 1339 | }); | ||
| 1316 | expect(resolveWorkEntryToolPresentation(entry!)?.displayName).toBe( | 1340 | expect(resolveWorkEntryToolPresentation(entry!)?.displayName).toBe( |
| 1317 | "Took a snapshot of the preview page", | 1341 | "Took a snapshot of the preview page", |
| 1318 | ); | 1342 | ); |
apps/web/src/session-logic.ts+20| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | extractCommandOutputText, | 7 | extractCommandOutputText, |
| 8 | isWorktreeSetupActivity, | 8 | isWorktreeSetupActivity, |
| 9 | } from "@t3tools/client-runtime/work-log/presentation"; | 9 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 10 | import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; | ||
| 10 | import { | 11 | import { |
| 11 | ApprovalRequestId, | 12 | ApprovalRequestId, |
| 12 | isToolLifecycleItemType, | 13 | isToolLifecycleItemType, |
| ... | @@ -83,6 +84,9 @@ export interface WorkLogEntry { | ... | @@ -83,6 +84,9 @@ export interface WorkLogEntry { |
| 83 | changedFiles?: ReadonlyArray<string>; | 84 | changedFiles?: ReadonlyArray<string>; |
| 84 | tone: "thinking" | "tool" | "info" | "error"; | 85 | tone: "thinking" | "tool" | "info" | "error"; |
| 85 | toolTitle?: string; | 86 | toolTitle?: string; |
| 87 | toolSurface?: import("@t3tools/contracts").ToolActivitySurface; | ||
| 88 | toolIcon?: import("@t3tools/contracts").ToolActivityIcon; | ||
| 89 | toolSource?: import("@t3tools/contracts").ToolActivitySource; | ||
| 86 | toolData?: unknown; | 90 | toolData?: unknown; |
| 87 | itemType?: ToolLifecycleItemType; | 91 | itemType?: ToolLifecycleItemType; |
| 88 | requestKind?: PendingApproval["requestKind"]; | 92 | requestKind?: PendingApproval["requestKind"]; |
| ... | @@ -889,6 +893,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -889,6 +893,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 889 | const commandPreview = extractToolCommand(payload); | 893 | const commandPreview = extractToolCommand(payload); |
| 890 | const changedFiles = extractChangedFiles(payload); | 894 | const changedFiles = extractChangedFiles(payload); |
| 891 | const title = extractToolTitle(payload); | 895 | const title = extractToolTitle(payload); |
| 896 | const toolPresentation = extractToolActivityPresentation(payload); | ||
| 892 | const isTaskActivity = | 897 | const isTaskActivity = |
| 893 | activity.kind === "task.started" || | 898 | activity.kind === "task.started" || |
| 894 | activity.kind === "task.progress" || | 899 | activity.kind === "task.progress" || |
| ... | @@ -948,6 +953,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -948,6 +953,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 948 | if (title) { | 953 | if (title) { |
| 949 | entry.toolTitle = title; | 954 | entry.toolTitle = title; |
| 950 | } | 955 | } |
| 956 | if (toolPresentation.toolSurface) { | ||
| 957 | entry.toolSurface = toolPresentation.toolSurface; | ||
| 958 | } | ||
| 959 | if (toolPresentation.toolIcon) { | ||
| 960 | entry.toolIcon = toolPresentation.toolIcon; | ||
| 961 | } | ||
| 962 | if (toolPresentation.toolSource) { | ||
| 963 | entry.toolSource = toolPresentation.toolSource; | ||
| 964 | } | ||
| 951 | if (itemType === "mcp_tool_call") { | 965 | if (itemType === "mcp_tool_call") { |
| 952 | const data = asRecord(payload?.data); | 966 | const data = asRecord(payload?.data); |
| 953 | const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; | 967 | const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; |
| ... | @@ -1170,6 +1184,9 @@ function mergeDerivedWorkLogEntries( | ... | @@ -1170,6 +1184,9 @@ function mergeDerivedWorkLogEntries( |
| 1170 | const command = next.command ?? previous.command; | 1184 | const command = next.command ?? previous.command; |
| 1171 | const rawCommand = next.rawCommand ?? previous.rawCommand; | 1185 | const rawCommand = next.rawCommand ?? previous.rawCommand; |
| 1172 | const toolTitle = next.toolTitle ?? previous.toolTitle; | 1186 | const toolTitle = next.toolTitle ?? previous.toolTitle; |
| 1187 | const toolSurface = next.toolSurface ?? previous.toolSurface; | ||
| 1188 | const toolIcon = next.toolIcon ?? previous.toolIcon; | ||
| 1189 | const toolSource = next.toolSource ?? previous.toolSource; | ||
| 1173 | const itemType = next.itemType ?? previous.itemType; | 1190 | const itemType = next.itemType ?? previous.itemType; |
| 1174 | const requestKind = next.requestKind ?? previous.requestKind; | 1191 | const requestKind = next.requestKind ?? previous.requestKind; |
| 1175 | const collapseKey = next[workLogCollapseKey] ?? previous[workLogCollapseKey]; | 1192 | const collapseKey = next[workLogCollapseKey] ?? previous[workLogCollapseKey]; |
| ... | @@ -1185,6 +1202,9 @@ function mergeDerivedWorkLogEntries( | ... | @@ -1185,6 +1202,9 @@ function mergeDerivedWorkLogEntries( |
| 1185 | ...(rawCommand ? { rawCommand } : {}), | 1202 | ...(rawCommand ? { rawCommand } : {}), |
| 1186 | ...(changedFiles.length > 0 ? { changedFiles } : {}), | 1203 | ...(changedFiles.length > 0 ? { changedFiles } : {}), |
| 1187 | ...(toolTitle ? { toolTitle } : {}), | 1204 | ...(toolTitle ? { toolTitle } : {}), |
| 1205 | ...(toolSurface ? { toolSurface } : {}), | ||
| 1206 | ...(toolIcon ? { toolIcon } : {}), | ||
| 1207 | ...(toolSource ? { toolSource } : {}), | ||
| 1188 | ...(itemType ? { itemType } : {}), | 1208 | ...(itemType ? { itemType } : {}), |
| 1189 | ...(requestKind ? { requestKind } : {}), | 1209 | ...(requestKind ? { requestKind } : {}), |
| 1190 | ...(collapseKey ? { [workLogCollapseKey]: collapseKey } : {}), | 1210 | ...(collapseKey ? { [workLogCollapseKey]: collapseKey } : {}), |
docs/user/providers-codex.md+11| ... | @@ -40,6 +40,17 @@ The web and desktop Agents panel shows each sub-agent's model and reasoning effo | ... | @@ -40,6 +40,17 @@ The web and desktop Agents panel shows each sub-agent's model and reasoning effo |
| 40 | reports them. If Codex does not report either value, T3 Code leaves it out instead of using the | 40 | reports them. If Codex does not report either value, T3 Code leaves it out instead of using the |
| 41 | parent agent's settings. | 41 | parent agent's settings. |
| 42 | 42 | ||
| 43 | ## Browser and computer activity | ||
| 44 | |||
| 45 | Browser and Computer Use calls show their user-facing task title when Codex provides one. Expanded | ||
| 46 | activity groups show an icon for every call. Website calls use the active page's favicon when it is | ||
| 47 | available, and desktop app calls use the app's native icon on macOS when available. Other hosts use | ||
| 48 | a generic fallback glyph. | ||
| 49 | |||
| 50 | Collapsed activity groups are summarized by source, such as `Used Chrome integration`, instead of | ||
| 51 | showing the underlying tool name. Website favicons and native app icons keep their original colors; | ||
| 52 | integrations that provide separate light and dark logos use the logo for the current appearance. | ||
| 53 | |||
| 43 | ## Approve access to other apps | 54 | ## Approve access to other apps |
| 44 | 55 | ||
| 45 | When a Codex tool needs access to an app such as Safari, T3 Code shows the app name and asks for | 56 | When a Codex tool needs access to an app such as Safari, T3 Code shows the app name and asks for |
packages/client-runtime/package.json+4| ... | @@ -199,6 +199,10 @@ | ... | @@ -199,6 +199,10 @@ |
| 199 | "types": "./src/work-log/presentation.ts", | 199 | "types": "./src/work-log/presentation.ts", |
| 200 | "default": "./src/work-log/presentation.ts" | 200 | "default": "./src/work-log/presentation.ts" |
| 201 | }, | 201 | }, |
| 202 | "./work-log/tool-presentation": { | ||
| 203 | "types": "./src/work-log/toolPresentation.ts", | ||
| 204 | "default": "./src/work-log/toolPresentation.ts" | ||
| 205 | }, | ||
| 202 | "./work-log/command-label": { | 206 | "./work-log/command-label": { |
| 203 | "types": "./src/work-log/commandLabel.ts", | 207 | "types": "./src/work-log/commandLabel.ts", |
| 204 | "default": "./src/work-log/commandLabel.ts" | 208 | "default": "./src/work-log/commandLabel.ts" |
packages/client-runtime/src/work-log/presentation.test.ts+35| ... | @@ -14,6 +14,41 @@ import { | ... | @@ -14,6 +14,41 @@ import { |
| 14 | workEntryViewedImagePath, | 14 | workEntryViewedImagePath, |
| 15 | } from "./presentation.js"; | 15 | } from "./presentation.js"; |
| 16 | 16 | ||
| 17 | describe("summarizeToolGroup", () => { | ||
| 18 | it("deduplicates named sources ahead of ordinary actions", () => { | ||
| 19 | const source = { key: "browser-use:chrome", name: "Chrome", kind: "integration" as const }; | ||
| 20 | expect( | ||
| 21 | summarizeToolGroup([ | ||
| 22 | { label: "Open page", tone: "tool", toolSource: source }, | ||
| 23 | { label: "Inspect page", tone: "tool", toolSource: source }, | ||
| 24 | { | ||
| 25 | label: "Ran command", | ||
| 26 | tone: "tool", | ||
| 27 | itemType: "command_execution", | ||
| 28 | command: "git status", | ||
| 29 | }, | ||
| 30 | ]), | ||
| 31 | ).toBe("Used Chrome integration and ran 1 command"); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("omits the integration suffix for special browser and computer sources", () => { | ||
| 35 | expect( | ||
| 36 | summarizeToolGroup([ | ||
| 37 | { | ||
| 38 | label: "Inspect page", | ||
| 39 | tone: "tool", | ||
| 40 | toolSource: { key: "browser-use", name: "Browser", kind: "browser" }, | ||
| 41 | }, | ||
| 42 | { | ||
| 43 | label: "Click", | ||
| 44 | tone: "tool", | ||
| 45 | toolSource: { key: "computer-use", name: "Computer Use", kind: "computer" }, | ||
| 46 | }, | ||
| 47 | ]), | ||
| 48 | ).toBe("Used Browser and Computer Use"); | ||
| 49 | }); | ||
| 50 | }); | ||
| 51 | |||
| 17 | describe("resolveWorkEntryToolPresentation", () => { | 52 | describe("resolveWorkEntryToolPresentation", () => { |
| 18 | it.each([ | 53 | it.each([ |
| 19 | "mcp__t3-code__preview_click", | 54 | "mcp__t3-code__preview_click", |
packages/client-runtime/src/work-log/presentation.ts+21| ... | @@ -2,6 +2,7 @@ import { | ... | @@ -2,6 +2,7 @@ import { |
| 2 | isToolLifecycleItemType, | 2 | isToolLifecycleItemType, |
| 3 | type AssetResource, | 3 | type AssetResource, |
| 4 | type ThreadId, | 4 | type ThreadId, |
| 5 | type ToolActivitySource, | ||
| 5 | type ToolLifecycleItemType, | 6 | type ToolLifecycleItemType, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 7 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | 8 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; |
| ... | @@ -28,6 +29,7 @@ export interface WorkLogPresentationEntry { | ... | @@ -28,6 +29,7 @@ export interface WorkLogPresentationEntry { |
| 28 | readonly toolLifecycleStatus?: string; | 29 | readonly toolLifecycleStatus?: string; |
| 29 | readonly sourceActivityKind?: string; | 30 | readonly sourceActivityKind?: string; |
| 30 | readonly taskId?: string; | 31 | readonly taskId?: string; |
| 32 | readonly toolSource?: ToolActivitySource; | ||
| 31 | } | 33 | } |
| 32 | 34 | ||
| 33 | export type ToolGroupAction = | 35 | export type ToolGroupAction = |
| ... | @@ -426,8 +428,13 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string { | ... | @@ -426,8 +428,13 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string { |
| 426 | 428 | ||
| 427 | export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEntry>): string { | 429 | export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEntry>): string { |
| 428 | const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry); | 430 | const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry); |
| 431 | const sources = new Map<string, ToolActivitySource>(); | ||
| 429 | const groupedEntries = new Map<ToolGroupAction, WorkLogPresentationEntry[]>(); | 432 | const groupedEntries = new Map<ToolGroupAction, WorkLogPresentationEntry[]>(); |
| 430 | for (const entry of summaryEntries) { | 433 | for (const entry of summaryEntries) { |
| 434 | if (entry.toolSource) { | ||
| 435 | sources.set(entry.toolSource.key, entry.toolSource); | ||
| 436 | continue; | ||
| 437 | } | ||
| 431 | const action = toolGroupAction(entry); | 438 | const action = toolGroupAction(entry); |
| 432 | const group = groupedEntries.get(action); | 439 | const group = groupedEntries.get(action); |
| 433 | if (group) group.push(entry); | 440 | if (group) group.push(entry); |
| ... | @@ -436,6 +443,20 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt | ... | @@ -436,6 +443,20 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt |
| 436 | const labels = [...groupedEntries].map(([action, actionEntries]) => | 443 | const labels = [...groupedEntries].map(([action, actionEntries]) => |
| 437 | toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)), | 444 | toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)), |
| 438 | ); | 445 | ); |
| 446 | if (sources.size > 0) { | ||
| 447 | const sourceValues = [...sources.values()]; | ||
| 448 | const sourceNames = sourceValues.map((source) => source.name); | ||
| 449 | const formattedNames = | ||
| 450 | sourceNames.length < 2 | ||
| 451 | ? sourceNames[0]! | ||
| 452 | : sourceNames.length === 2 | ||
| 453 | ? sourceNames.join(" and ") | ||
| 454 | : `${sourceNames.slice(0, -1).join(", ")}, and ${sourceNames.at(-1)}`; | ||
| 455 | const allIntegrations = sourceValues.every((source) => source.kind === "integration"); | ||
| 456 | labels.unshift( | ||
| 457 | `Used ${formattedNames}${allIntegrations ? ` ${sources.size === 1 ? "integration" : "integrations"}` : ""}`, | ||
| 458 | ); | ||
| 459 | } | ||
| 439 | const sentenceLabels = labels.map((label, index) => | 460 | const sentenceLabels = labels.map((label, index) => |
| 440 | index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), | 461 | index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), |
| 441 | ); | 462 | ); |
packages/client-runtime/src/work-log/toolPresentation.test.ts created+95| ... | @@ -0,0 +1,95 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { extractToolActivityPresentation } from "./toolPresentation.ts"; | ||
| 4 | |||
| 5 | describe("extractToolActivityPresentation", () => { | ||
| 6 | it("reads provider-neutral presentation fields", () => { | ||
| 7 | expect( | ||
| 8 | extractToolActivityPresentation({ | ||
| 9 | toolSurface: "browser", | ||
| 10 | toolIcon: { | ||
| 11 | _tag: "website", | ||
| 12 | pageUrl: "https://example.com/docs", | ||
| 13 | faviconUrl: "https://example.com/favicon.png", | ||
| 14 | faviconUrlDark: "https://example.com/favicon-dark.png", | ||
| 15 | }, | ||
| 16 | toolSource: { | ||
| 17 | key: "integration:example", | ||
| 18 | name: "Example", | ||
| 19 | kind: "integration", | ||
| 20 | icon: { | ||
| 21 | _tag: "themed-logo", | ||
| 22 | logoUrl: "https://example.com/logo-light.png", | ||
| 23 | logoUrlDark: "https://example.com/logo-dark.png", | ||
| 24 | }, | ||
| 25 | }, | ||
| 26 | }), | ||
| 27 | ).toEqual({ | ||
| 28 | toolSurface: "browser", | ||
| 29 | toolIcon: { | ||
| 30 | _tag: "website", | ||
| 31 | pageUrl: "https://example.com/docs", | ||
| 32 | faviconUrl: "https://example.com/favicon.png", | ||
| 33 | faviconUrlDark: "https://example.com/favicon-dark.png", | ||
| 34 | }, | ||
| 35 | toolSource: { | ||
| 36 | key: "integration:example", | ||
| 37 | name: "Example", | ||
| 38 | kind: "integration", | ||
| 39 | icon: { | ||
| 40 | _tag: "themed-logo", | ||
| 41 | logoUrl: "https://example.com/logo-light.png", | ||
| 42 | logoUrlDark: "https://example.com/logo-dark.png", | ||
| 43 | }, | ||
| 44 | }, | ||
| 45 | }); | ||
| 46 | }); | ||
| 47 | |||
| 48 | it("reads provider-neutral native app icons", () => { | ||
| 49 | expect( | ||
| 50 | extractToolActivityPresentation({ | ||
| 51 | toolSurface: "computer", | ||
| 52 | toolIcon: { | ||
| 53 | _tag: "native-app", | ||
| 54 | app: { _tag: "app-id", appId: "com.example.Editor" }, | ||
| 55 | }, | ||
| 56 | toolSource: { | ||
| 57 | key: "native-app:com.example.editor", | ||
| 58 | name: "Editor", | ||
| 59 | kind: "computer", | ||
| 60 | }, | ||
| 61 | }), | ||
| 62 | ).toEqual({ | ||
| 63 | toolSurface: "computer", | ||
| 64 | toolIcon: { | ||
| 65 | _tag: "native-app", | ||
| 66 | app: { _tag: "app-id", appId: "com.example.Editor" }, | ||
| 67 | }, | ||
| 68 | toolSource: { | ||
| 69 | key: "native-app:com.example.editor", | ||
| 70 | name: "Editor", | ||
| 71 | kind: "computer", | ||
| 72 | }, | ||
| 73 | }); | ||
| 74 | }); | ||
| 75 | |||
| 76 | it("does not infer presentation from provider-specific payload data", () => { | ||
| 77 | expect( | ||
| 78 | extractToolActivityPresentation({ | ||
| 79 | data: { | ||
| 80 | item: { | ||
| 81 | arguments: { code: 'await sky.click({ app: "Finder" })' }, | ||
| 82 | result: { | ||
| 83 | _meta: { | ||
| 84 | "codex/toolSurface": { | ||
| 85 | kind: "computerUse", | ||
| 86 | app: { kind: "displayName", displayName: "Finder" }, | ||
| 87 | }, | ||
| 88 | }, | ||
| 89 | }, | ||
| 90 | }, | ||
| 91 | }, | ||
| 92 | }), | ||
| 93 | ).toEqual({}); | ||
| 94 | }); | ||
| 95 | }); | ||
packages/client-runtime/src/work-log/toolPresentation.ts created+123| ... | @@ -0,0 +1,123 @@ | ||
| 1 | import type { | ||
| 2 | ToolActivityIcon, | ||
| 3 | ToolActivityNativeAppReference, | ||
| 4 | ToolActivitySource, | ||
| 5 | ToolActivitySurface, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | export interface ExtractedToolActivityPresentation { | ||
| 9 | readonly toolSurface?: ToolActivitySurface; | ||
| 10 | readonly toolIcon?: ToolActivityIcon; | ||
| 11 | readonly toolSource?: ToolActivitySource; | ||
| 12 | } | ||
| 13 | |||
| 14 | function asRecord(value: unknown): Record<string, unknown> | undefined { | ||
| 15 | return value !== null && typeof value === "object" && !Array.isArray(value) | ||
| 16 | ? (value as Record<string, unknown>) | ||
| 17 | : undefined; | ||
| 18 | } | ||
| 19 | |||
| 20 | function trimmedString(value: unknown, maxLength: number): string | undefined { | ||
| 21 | if (typeof value !== "string") return undefined; | ||
| 22 | const trimmed = value.trim(); | ||
| 23 | return trimmed && trimmed.length <= maxLength ? trimmed : undefined; | ||
| 24 | } | ||
| 25 | |||
| 26 | function imageUrl(value: unknown): string | undefined { | ||
| 27 | const raw = trimmedString(value, 4096); | ||
| 28 | if (!raw) return undefined; | ||
| 29 | try { | ||
| 30 | const url = new URL(raw); | ||
| 31 | return url.protocol === "http:" || url.protocol === "https:" || url.protocol === "data:" | ||
| 32 | ? url.href | ||
| 33 | : undefined; | ||
| 34 | } catch { | ||
| 35 | return undefined; | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | function pageUrl(value: unknown): string | undefined { | ||
| 40 | const raw = trimmedString(value, 4096); | ||
| 41 | if (!raw) return undefined; | ||
| 42 | try { | ||
| 43 | const url = new URL(raw); | ||
| 44 | return url.protocol === "http:" || url.protocol === "https:" ? url.href : undefined; | ||
| 45 | } catch { | ||
| 46 | return undefined; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | function nativeAppReference(value: unknown): ToolActivityNativeAppReference | undefined { | ||
| 51 | const app = asRecord(value); | ||
| 52 | const appId = trimmedString(app?.appId, 512); | ||
| 53 | if (app?._tag === "app-id" && appId && /^[A-Za-z0-9._-]+$/u.test(appId)) { | ||
| 54 | return { _tag: "app-id", appId }; | ||
| 55 | } | ||
| 56 | const displayName = trimmedString(app?.displayName, 160); | ||
| 57 | if (app?._tag === "display-name" && displayName) { | ||
| 58 | return { _tag: "display-name", displayName }; | ||
| 59 | } | ||
| 60 | return undefined; | ||
| 61 | } | ||
| 62 | |||
| 63 | function activityIcon(value: unknown): ToolActivityIcon | undefined { | ||
| 64 | const icon = asRecord(value); | ||
| 65 | if (icon?._tag === "website") { | ||
| 66 | const resolvedPageUrl = pageUrl(icon.pageUrl); | ||
| 67 | const faviconUrl = imageUrl(icon.faviconUrl); | ||
| 68 | const faviconUrlDark = imageUrl(icon.faviconUrlDark); | ||
| 69 | if (resolvedPageUrl) { | ||
| 70 | return { | ||
| 71 | _tag: "website", | ||
| 72 | pageUrl: resolvedPageUrl, | ||
| 73 | ...(faviconUrl ? { faviconUrl } : {}), | ||
| 74 | ...(faviconUrlDark ? { faviconUrlDark } : {}), | ||
| 75 | }; | ||
| 76 | } | ||
| 77 | } | ||
| 78 | if (icon?._tag === "native-app") { | ||
| 79 | const app = nativeAppReference(icon.app); | ||
| 80 | if (app) return { _tag: "native-app", app }; | ||
| 81 | } | ||
| 82 | if (icon?._tag === "themed-logo") { | ||
| 83 | const logoUrl = imageUrl(icon.logoUrl); | ||
| 84 | const logoUrlDark = imageUrl(icon.logoUrlDark); | ||
| 85 | if (logoUrl) { | ||
| 86 | return { | ||
| 87 | _tag: "themed-logo", | ||
| 88 | logoUrl, | ||
| 89 | ...(logoUrlDark ? { logoUrlDark } : {}), | ||
| 90 | }; | ||
| 91 | } | ||
| 92 | } | ||
| 93 | return undefined; | ||
| 94 | } | ||
| 95 | |||
| 96 | function activitySource(value: unknown): ToolActivitySource | undefined { | ||
| 97 | const source = asRecord(value); | ||
| 98 | const key = trimmedString(source?.key, 512); | ||
| 99 | const name = trimmedString(source?.name, 160); | ||
| 100 | const kind = source?.kind; | ||
| 101 | if (!key || !name || (kind !== "browser" && kind !== "computer" && kind !== "integration")) { | ||
| 102 | return undefined; | ||
| 103 | } | ||
| 104 | const icon = activityIcon(source?.icon); | ||
| 105 | return { key, name, kind, ...(icon ? { icon } : {}) }; | ||
| 106 | } | ||
| 107 | |||
| 108 | export function extractToolActivityPresentation( | ||
| 109 | payloadValue: unknown, | ||
| 110 | ): ExtractedToolActivityPresentation { | ||
| 111 | const payload = asRecord(payloadValue); | ||
| 112 | const toolSurface = | ||
| 113 | payload?.toolSurface === "browser" || payload?.toolSurface === "computer" | ||
| 114 | ? payload.toolSurface | ||
| 115 | : undefined; | ||
| 116 | const toolIcon = activityIcon(payload?.toolIcon); | ||
| 117 | const toolSource = activitySource(payload?.toolSource); | ||
| 118 | return { | ||
| 119 | ...(toolSurface ? { toolSurface } : {}), | ||
| 120 | ...(toolIcon ? { toolIcon } : {}), | ||
| 121 | ...(toolSource ? { toolSource } : {}), | ||
| 122 | }; | ||
| 123 | } | ||
packages/contracts/src/assets.ts+4| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, | 7 | PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, |
| 8 | ProjectFaviconPath, | 8 | ProjectFaviconPath, |
| 9 | } from "./orchestration.ts"; | 9 | } from "./orchestration.ts"; |
| 10 | import { ToolActivityNativeAppReference } from "./providerRuntime.ts"; | ||
| 10 | 11 | ||
| 11 | const ASSET_PATH_MAX_LENGTH = 1024; | 12 | const ASSET_PATH_MAX_LENGTH = 1024; |
| 12 | 13 | ||
| ... | @@ -40,6 +41,9 @@ export const AssetResource = Schema.Union([ | ... | @@ -40,6 +41,9 @@ export const AssetResource = Schema.Union([ |
| 40 | // project projection before it issues the signed URL. | 41 | // project projection before it issues the signed URL. |
| 41 | path: Schema.optional(ProjectFaviconPath), | 42 | path: Schema.optional(ProjectFaviconPath), |
| 42 | }), | 43 | }), |
| 44 | Schema.TaggedStruct("native-app-icon", { | ||
| 45 | app: ToolActivityNativeAppReference, | ||
| 46 | }), | ||
| 43 | ]); | 47 | ]); |
| 44 | export type AssetResource = typeof AssetResource.Type; | 48 | export type AssetResource = typeof AssetResource.Type; |
| 45 | 49 |
packages/contracts/src/providerRuntime.ts+43| ... | @@ -406,11 +406,54 @@ const TurnDiffUpdatedPayload = Schema.Struct({ | ... | @@ -406,11 +406,54 @@ const TurnDiffUpdatedPayload = Schema.Struct({ |
| 406 | }); | 406 | }); |
| 407 | export type TurnDiffUpdatedPayload = typeof TurnDiffUpdatedPayload.Type; | 407 | export type TurnDiffUpdatedPayload = typeof TurnDiffUpdatedPayload.Type; |
| 408 | 408 | ||
| 409 | export const ToolActivitySurface = Schema.Literals(["browser", "computer"]); | ||
| 410 | export type ToolActivitySurface = typeof ToolActivitySurface.Type; | ||
| 411 | |||
| 412 | export const ToolActivityNativeAppReference = Schema.Union([ | ||
| 413 | Schema.TaggedStruct("app-id", { | ||
| 414 | appId: TrimmedNonEmptyStringSchema.check( | ||
| 415 | Schema.isMaxLength(512), | ||
| 416 | Schema.isPattern(/^[A-Za-z0-9._-]+$/u), | ||
| 417 | ), | ||
| 418 | }), | ||
| 419 | Schema.TaggedStruct("display-name", { | ||
| 420 | displayName: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(160)), | ||
| 421 | }), | ||
| 422 | ]); | ||
| 423 | export type ToolActivityNativeAppReference = typeof ToolActivityNativeAppReference.Type; | ||
| 424 | |||
| 425 | export const ToolActivityIcon = Schema.Union([ | ||
| 426 | Schema.TaggedStruct("website", { | ||
| 427 | pageUrl: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(4096)), | ||
| 428 | faviconUrl: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(4096))), | ||
| 429 | faviconUrlDark: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(4096))), | ||
| 430 | }), | ||
| 431 | Schema.TaggedStruct("native-app", { | ||
| 432 | app: ToolActivityNativeAppReference, | ||
| 433 | }), | ||
| 434 | Schema.TaggedStruct("themed-logo", { | ||
| 435 | logoUrl: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(4096)), | ||
| 436 | logoUrlDark: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(4096))), | ||
| 437 | }), | ||
| 438 | ]); | ||
| 439 | export type ToolActivityIcon = typeof ToolActivityIcon.Type; | ||
| 440 | |||
| 441 | export const ToolActivitySource = Schema.Struct({ | ||
| 442 | key: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(512)), | ||
| 443 | name: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(160)), | ||
| 444 | kind: Schema.Literals(["browser", "computer", "integration"]), | ||
| 445 | icon: Schema.optional(ToolActivityIcon), | ||
| 446 | }); | ||
| 447 | export type ToolActivitySource = typeof ToolActivitySource.Type; | ||
| 448 | |||
| 409 | export const ItemLifecyclePayload = Schema.Struct({ | 449 | export const ItemLifecyclePayload = Schema.Struct({ |
| 410 | itemType: CanonicalItemType, | 450 | itemType: CanonicalItemType, |
| 411 | status: Schema.optional(RuntimeItemStatus), | 451 | status: Schema.optional(RuntimeItemStatus), |
| 412 | title: Schema.optional(TrimmedNonEmptyStringSchema), | 452 | title: Schema.optional(TrimmedNonEmptyStringSchema), |
| 413 | detail: Schema.optional(TrimmedNonEmptyStringSchema), | 453 | detail: Schema.optional(TrimmedNonEmptyStringSchema), |
| 454 | toolSurface: Schema.optional(ToolActivitySurface), | ||
| 455 | toolIcon: Schema.optional(ToolActivityIcon), | ||
| 456 | toolSource: Schema.optional(ToolActivitySource), | ||
| 414 | data: Schema.optional(Schema.Unknown), | 457 | data: Schema.optional(Schema.Unknown), |
| 415 | /** | 458 | /** |
| 416 | * Owning agent when this item ran inside a subagent (resolved from the | 459 | * Owning agent when this item ran inside a subagent (resolved from the |
packages/shared/package.json+4| ... | @@ -79,6 +79,10 @@ | ... | @@ -79,6 +79,10 @@ |
| 79 | "types": "./src/toolActivity.ts", | 79 | "types": "./src/toolActivity.ts", |
| 80 | "import": "./src/toolActivity.ts" | 80 | "import": "./src/toolActivity.ts" |
| 81 | }, | 81 | }, |
| 82 | "./favicon": { | ||
| 83 | "types": "./src/favicon.ts", | ||
| 84 | "import": "./src/favicon.ts" | ||
| 85 | }, | ||
| 82 | "./Struct": { | 86 | "./Struct": { |
| 83 | "types": "./src/Struct.ts", | 87 | "types": "./src/Struct.ts", |
| 84 | "import": "./src/Struct.ts" | 88 | "import": "./src/Struct.ts" |
packages/shared/src/favicon.test.ts created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { explicitFaviconUrl, faviconUrlForPage, toolActivityFaviconUrl } from "./favicon.ts"; | ||
| 4 | |||
| 5 | describe("faviconUrlForPage", () => { | ||
| 6 | it("uses the page origin instead of a third-party favicon service", () => { | ||
| 7 | expect(faviconUrlForPage("https://example.com/docs/page?q=1")).toBe( | ||
| 8 | "https://example.com/favicon.ico", | ||
| 9 | ); | ||
| 10 | expect(faviconUrlForPage("http://localhost:5173/app")).toBe( | ||
| 11 | "http://localhost:5173/favicon.ico", | ||
| 12 | ); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("selects site-owned light and dark variants without filtering full-color icons", () => { | ||
| 16 | expect(toolActivityFaviconUrl({ pageUrl: "https://github.com/openai/codex" }, "light")).toBe( | ||
| 17 | "https://github.githubassets.com/favicons/favicon.svg", | ||
| 18 | ); | ||
| 19 | expect(toolActivityFaviconUrl({ pageUrl: "https://github.com/openai/codex" }, "dark")).toBe( | ||
| 20 | "https://github.githubassets.com/favicons/favicon-dark.svg", | ||
| 21 | ); | ||
| 22 | |||
| 23 | const fullColorIcon = { | ||
| 24 | pageUrl: "https://example.com/docs", | ||
| 25 | faviconUrl: "https://cdn.example.com/full-color.png", | ||
| 26 | }; | ||
| 27 | expect(toolActivityFaviconUrl(fullColorIcon, "light")).toBe(fullColorIcon.faviconUrl); | ||
| 28 | expect(toolActivityFaviconUrl(fullColorIcon, "dark")).toBe(fullColorIcon.faviconUrl); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("prefers a provider-supplied dark favicon", () => { | ||
| 32 | expect( | ||
| 33 | toolActivityFaviconUrl( | ||
| 34 | { | ||
| 35 | pageUrl: "https://example.com/docs", | ||
| 36 | faviconUrl: "https://example.com/light.svg", | ||
| 37 | faviconUrlDark: "https://example.com/dark.svg", | ||
| 38 | }, | ||
| 39 | "dark", | ||
| 40 | ), | ||
| 41 | ).toBe("https://example.com/dark.svg"); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("accepts provider-supplied image URLs but rejects extension URLs", () => { | ||
| 45 | expect(explicitFaviconUrl("https://example.com/icon.png")).toBe("https://example.com/icon.png"); | ||
| 46 | expect(explicitFaviconUrl("chrome-extension://example/_favicon/")).toBeNull(); | ||
| 47 | }); | ||
| 48 | }); | ||
packages/shared/src/favicon.ts created+81| ... | @@ -0,0 +1,81 @@ | ||
| 1 | /** | ||
| 2 | * Mirrors Codex's generic Browser Use fallback: ask the page origin for its | ||
| 3 | * conventional favicon and let the image element fall back to a browser glyph. | ||
| 4 | * Chrome-backed tools can pass their tab's explicit favicon URL separately. | ||
| 5 | */ | ||
| 6 | export function faviconUrlForPage(rawUrl: string | null | undefined, _size = 32): string | null { | ||
| 7 | if (!rawUrl || rawUrl.length > 4096) return null; | ||
| 8 | try { | ||
| 9 | const pageUrl = new URL(rawUrl); | ||
| 10 | if (pageUrl.protocol !== "http:" && pageUrl.protocol !== "https:") return null; | ||
| 11 | return new URL("/favicon.ico", pageUrl.origin).href; | ||
| 12 | } catch { | ||
| 13 | return null; | ||
| 14 | } | ||
| 15 | } | ||
| 16 | |||
| 17 | const THEMED_FAVICON_BY_HOSTNAME: Readonly< | ||
| 18 | Record<string, Readonly<{ light: string; dark: string }>> | ||
| 19 | > = { | ||
| 20 | "github.com": { | ||
| 21 | light: "https://github.githubassets.com/favicons/favicon.svg", | ||
| 22 | dark: "https://github.githubassets.com/favicons/favicon-dark.svg", | ||
| 23 | }, | ||
| 24 | }; | ||
| 25 | |||
| 26 | function themedFaviconUrlForPage( | ||
| 27 | rawUrl: string | null | undefined, | ||
| 28 | appearance: "light" | "dark", | ||
| 29 | ): string | null { | ||
| 30 | if (!rawUrl || rawUrl.length > 4096) return null; | ||
| 31 | try { | ||
| 32 | const pageUrl = new URL(rawUrl); | ||
| 33 | if (pageUrl.protocol !== "http:" && pageUrl.protocol !== "https:") return null; | ||
| 34 | return THEMED_FAVICON_BY_HOSTNAME[pageUrl.hostname.toLowerCase()]?.[appearance] ?? null; | ||
| 35 | } catch { | ||
| 36 | return null; | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | /** Accepts image URLs supplied by a trusted provider event. */ | ||
| 41 | export function explicitFaviconUrl(rawUrl: string | null | undefined): string | null { | ||
| 42 | if (!rawUrl || rawUrl.length > 4096) return null; | ||
| 43 | try { | ||
| 44 | const url = new URL(rawUrl); | ||
| 45 | return url.protocol === "http:" || url.protocol === "https:" || url.protocol === "data:" | ||
| 46 | ? url.href | ||
| 47 | : null; | ||
| 48 | } catch { | ||
| 49 | return null; | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | /** | ||
| 54 | * Chooses a website icon for the app's resolved theme. Provider-supplied | ||
| 55 | * variants mirror Codex's Chrome-selected favicon path. A small site-owned | ||
| 56 | * fallback table covers websites whose conventional favicon is illegible in | ||
| 57 | * one appearance without recoloring full-color icons. | ||
| 58 | */ | ||
| 59 | export function toolActivityFaviconUrl( | ||
| 60 | icon: { | ||
| 61 | readonly pageUrl: string; | ||
| 62 | readonly faviconUrl?: string | undefined; | ||
| 63 | readonly faviconUrlDark?: string | undefined; | ||
| 64 | }, | ||
| 65 | appearance: "light" | "dark", | ||
| 66 | size = 32, | ||
| 67 | ): string | null { | ||
| 68 | if (appearance === "dark") { | ||
| 69 | return ( | ||
| 70 | explicitFaviconUrl(icon.faviconUrlDark) ?? | ||
| 71 | themedFaviconUrlForPage(icon.pageUrl, "dark") ?? | ||
| 72 | explicitFaviconUrl(icon.faviconUrl) ?? | ||
| 73 | faviconUrlForPage(icon.pageUrl, size) | ||
| 74 | ); | ||
| 75 | } | ||
| 76 | return ( | ||
| 77 | explicitFaviconUrl(icon.faviconUrl) ?? | ||
| 78 | themedFaviconUrlForPage(icon.pageUrl, "light") ?? | ||
| 79 | faviconUrlForPage(icon.pageUrl, size) | ||
| 80 | ); | ||
| 81 | } | ||