authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-09-03 00:32:41-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 01:32:41-04:00
log6cf0c6ea55d281f65c80502ec1871b0adf472025
treeeac6154fde5aafc4d62185303678d41b4a12ad78
parent0bc59bbae391ee45ab3a7cfb2dc703c8b27e5772
signature Signed by PGP key B5690EEEBB952194

feat: display native app and browser icons in work logs (#9093)

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 <ThreadWorkGroupToggle1541 <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: {
20012008
2002export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {2009export 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 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { Image } from "expo-image";
2import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";3import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
3import { MaskedView } from "@expo/ui/community/masked-view";4import { MaskedView } from "@expo/ui/community/masked-view";
4import type { LegendListRef } from "@legendapp/list/react-native";5import 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";
28import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";29import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
30import type { EnvironmentId, ToolActivityIcon } from "@t3tools/contracts";
31import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
2932
30import { AppText as Text } from "../../components/AppText";33import { AppText as Text } from "../../components/AppText";
31import { T3Wordmark } from "../../components/T3Wordmark";34import { 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";
64import { useAssetUrl } from "../../state/assets";
6165
62const SHIMMER_WIDTH = 72;66const SHIMMER_WIDTH = 72;
63const SHIMMER_SWEEP_MS = 1_350;67const SHIMMER_SWEEP_MS = 1_350;
...@@ -134,17 +138,28 @@ export function ThreadDisclosureChevron(props: {...@@ -134,17 +138,28 @@ export function ThreadDisclosureChevron(props: {
134}138}
135139
136function ShimmerWorkContent(props: {140function 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 <WorkLogIcon163 <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}
168183
169export function ShimmeringWorkContent(props: {184export 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 <ShimmerWorkContent255 <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 <ShimmerWorkContent295 <ShimmerWorkContent
296 environmentId={props.environmentId}
275 highlighted297 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
369interface ThreadWorkLogProps {397interface 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 );
407441
...@@ -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));
673712
674 return (713 return (
675 <Animated.View714 <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 <ShimmeringWorkContent738 <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 showIcon743 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 <WorkLogIcon750 {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 <Text765 <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 Copied780 Copied
729 </Text>781 </Text>
730 ) : null}782 ) : null}
731 {failed && toolPresentation ? (783 {failed && hasSpecialToolIcon ? (
732 <View784 <View
733 className="h-4 w-4 items-center justify-center"785 className="h-4 w-4 items-center justify-center"
734 accessibilityElementsHidden786 accessibilityElementsHidden
...@@ -786,6 +838,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -786,6 +838,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
786});838});
787839
788export function ThreadWorkGroupToggle(props: {840export 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.hasFailure856 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));
804864
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 <ShimmeringWorkContent881 <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 showIcon887 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 <Text902 <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}
852921
922function 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
972function 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
996function 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
853function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {1028function 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 }
822836
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 });
16481688
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";
29import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
29import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";30import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
3031
31import * as Arr from "effect/Array";32import * 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 only410 // task.updated included: terminal bypassed updates (Codex children's only
402 // terminal signal) must carry task identity so they collapse per child411 // 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.toolLike1562 : singleActivity !== null && !singleActivity.toolLike
1538 ? singleActivity.workEntry.label1563 ? 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 = live1586 const summaryToolIcon = live
1541 ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon1587 ? 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";
22import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";22import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
23import { assetFileResponse } from "../http.ts";23import { assetFileResponse } from "../http.ts";
24import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";24import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
25import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
25import { openMediaFile } from "./MediaFile.ts";26import { openMediaFile } from "./MediaFile.ts";
2627
27vi.mock("node:fs/promises", async (importOriginal) => {28vi.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));
4547
...@@ -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 );
579596
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";
16import {17import {
17 hostPreviewMimeTypeFromExtension,18 hostPreviewMimeTypeFromExtension,
...@@ -42,6 +43,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta...@@ -42,6 +43,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta
42import * as ServerConfig from "../config.ts";43import * as ServerConfig from "../config.ts";
43import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";44import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
44import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";45import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
46import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
45import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts";47import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts";
4648
47export const ASSET_ROUTE_PREFIX = "/api/assets";49export 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]);
124type AssetClaims = typeof AssetClaimsSchema.Type;132type AssetClaims = typeof AssetClaimsSchema.Type;
125133
...@@ -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 }
508526
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 }
605623
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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Sink from "effect/Sink";
6import * as Stream from "effect/Stream";
7import { ChildProcessSpawner } from "effect/unstable/process";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
9
10import * as ServerConfig from "../config.ts";
11import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
12
13function 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
29describe("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 @@
1import * as NodeCrypto from "node:crypto";
2import type { ToolActivityNativeAppReference } from "@t3tools/contracts";
3import * as Cache from "effect/Cache";
4import * as Cause from "effect/Cause";
5import * as Effect from "effect/Effect";
6import * as Clock from "effect/Clock";
7import * as Context from "effect/Context";
8import * as Duration from "effect/Duration";
9import * as Exit from "effect/Exit";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as Path from "effect/Path";
14import * as PlatformError from "effect/PlatformError";
15import * as Semaphore from "effect/Semaphore";
16import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
17import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
18
19import * as ServerConfig from "../config.ts";
20
21const ICON_SIZE = 64;
22const COMMAND_TIMEOUT = "5 seconds";
23const RESOLUTION_CACHE_TTL = Duration.hours(1);
24const RESOLUTION_CACHE_MAX_ENTRIES = 256;
25
26/** Resolves and caches macOS application icons without exposing host paths to clients. */
27export 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
35function appCacheKey(app: ToolActivityNativeAppReference): string {
36 return JSON.stringify(app);
37}
38
39function appFromCacheKey(key: string): ToolActivityNativeAppReference {
40 return JSON.parse(key) as ToolActivityNativeAppReference;
41}
42
43const 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
55const 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
65const 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
82function escapeSpotlightString(value: string): string {
83 return value.replace(/([\\'*?])/gu, "\\$1");
84}
85
86function 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
93const 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
133const 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
209export 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
268export 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.parentToolUseId822 ...(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.parentToolUseId854 ...(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.parentToolUseId886 ...(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 );
802802
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 );
8621103
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";
29import * as Effect from "effect/Effect";32import * as Effect from "effect/Effect";
33import * as NodeCrypto from "node:crypto";
30import * as Crypto from "effect/Crypto";34import * as Crypto from "effect/Crypto";
31import * as Exit from "effect/Exit";35import * as Exit from "effect/Exit";
32import * as Fiber from "effect/Fiber";36import * 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}
152156
157function asUnknownRecord(value: unknown): Record<string, unknown> | undefined {
158 return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
159}
160
161function 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
169function 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
182function 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
194function 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
202function 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
208function normalizedSourceKeyPart(value: string): string {
209 return value.trim().toLowerCase();
210}
211
212function 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
219function 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
231function 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
245function 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
257function 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
270function 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
286interface McpToolPresentation {
287 readonly toolSurface?: "browser" | "computer";
288 readonly toolIcon?: ToolActivityIcon;
289 readonly toolSource?: ToolActivitySource;
290}
291
292function 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
153const FATAL_CODEX_STDERR_SNIPPETS = ["failed to connect to websocket"];387const FATAL_CODEX_STDERR_SNIPPETS = ["failed to connect to websocket"];
154388
155function isFatalCodexProcessStderrMessage(message: string): boolean {389function 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}
241475
242function itemTitle(itemType: CanonicalItemType, item?: CodexLifecycleItem): string | undefined {476function 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
482function normalizedMcpToolName(value: string): string {
483 return (
484 value
485 .split(/__|[./:]/u)
486 .at(-1)
487 ?.trim() ?? value.trim()
488 );
489}
490
491function 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
540function 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 }
482790
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";
122import * as PreviewManager from "./preview/Manager.ts";122import * as PreviewManager from "./preview/Manager.ts";
123import * as PortScanner from "./preview/PortScanner.ts";123import * as PortScanner from "./preview/PortScanner.ts";
124import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";124import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
125import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts";
125import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";126import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
126import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";127import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
127import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";128import * 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";
67import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";67import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
68import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";68import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
69import * as ServerSettings from "./serverSettings.ts";69import * as ServerSettings from "./serverSettings.ts";
70import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts";
70import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";71import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
71import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";72import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
72import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";73import * 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";
1516
...@@ -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 });
17111717
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});
18461930
1847describe("computeStableMessagesTimelineRows", () => {1931describe("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.label890 ? 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.hasFailure1039 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";
9import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";10import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
...@@ -25,6 +26,8 @@ const NOOP_OPEN_AGENTS = () => {};...@@ -25,6 +26,8 @@ const NOOP_OPEN_AGENTS = () => {};
25const NOOP_USE_ARTIFACT_TEMPLATE = () => {};26const NOOP_USE_ARTIFACT_TEMPLATE = () => {};
26const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};27const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
27import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";28import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
29import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
30import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";
28import {31import {
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";
155import { useUiStateStore } from "~/uiStateStore";159import { useUiStateStore } from "~/uiStateStore";
156import { type TimestampFormat } from "@t3tools/contracts/settings";160import { type TimestampFormat } from "@t3tools/contracts/settings";
157import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";161import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";
158
159import {162import {
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 }) {
1768function LiveActivityRow({1771function 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 <LiveActivityContent1784 <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({
1792function LiveActivityContent({1804function 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;
18071822
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 <WorkEntryIcon1840 <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 <LiveActivityContent1876 <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 <WorkEntryIcon1934 <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";
24592487
2488function 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
2508function 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
2532function 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
2584function 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
2620const loadedToolActivityIconSrcs = new Map<string, string>();
2621
2622function 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
2460function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {2685function 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 <WorkEntryIcon3044 <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 <span3064 <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", () => {
12471254
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 });
12521269
...@@ -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";
10import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
10import {11import {
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
40reports them. If Codex does not report either value, T3 Code leaves it out instead of using the40reports them. If Codex does not report either value, T3 Code leaves it out instead of using the
41parent agent's settings.41parent agent's settings.
4242
43## Browser and computer activity
44
45Browser and Computer Use calls show their user-facing task title when Codex provides one. Expanded
46activity groups show an icon for every call. Website calls use the active page's favicon when it is
47available, and desktop app calls use the app's native icon on macOS when available. Other hosts use
48a generic fallback glyph.
49
50Collapsed activity groups are summarized by source, such as `Used Chrome integration`, instead of
51showing the underlying tool name. Website favicons and native app icons keep their original colors;
52integrations that provide separate light and dark logos use the logo for the current appearance.
53
43## Approve access to other apps54## Approve access to other apps
4455
45When a Codex tool needs access to an app such as Safari, T3 Code shows the app name and asks for56When 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";
1616
17describe("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
17describe("resolveWorkEntryToolPresentation", () => {52describe("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";
7import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";8import { 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}
3234
33export type ToolGroupAction =35export type ToolGroupAction =
...@@ -426,8 +428,13 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {...@@ -426,8 +428,13 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
426428
427export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEntry>): string {429export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { extractToolActivityPresentation } from "./toolPresentation.ts";
4
5describe("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 @@
1import type {
2 ToolActivityIcon,
3 ToolActivityNativeAppReference,
4 ToolActivitySource,
5 ToolActivitySurface,
6} from "@t3tools/contracts";
7
8export interface ExtractedToolActivityPresentation {
9 readonly toolSurface?: ToolActivitySurface;
10 readonly toolIcon?: ToolActivityIcon;
11 readonly toolSource?: ToolActivitySource;
12}
13
14function 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
20function 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
26function 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
39function 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
50function 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
63function 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
96function 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
108export 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";
10import { ToolActivityNativeAppReference } from "./providerRuntime.ts";
1011
11const ASSET_PATH_MAX_LENGTH = 1024;12const ASSET_PATH_MAX_LENGTH = 1024;
1213
...@@ -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]);
44export type AssetResource = typeof AssetResource.Type;48export type AssetResource = typeof AssetResource.Type;
4549
packages/contracts/src/providerRuntime.ts+43
...@@ -406,11 +406,54 @@ const TurnDiffUpdatedPayload = Schema.Struct({...@@ -406,11 +406,54 @@ const TurnDiffUpdatedPayload = Schema.Struct({
406});406});
407export type TurnDiffUpdatedPayload = typeof TurnDiffUpdatedPayload.Type;407export type TurnDiffUpdatedPayload = typeof TurnDiffUpdatedPayload.Type;
408408
409export const ToolActivitySurface = Schema.Literals(["browser", "computer"]);
410export type ToolActivitySurface = typeof ToolActivitySurface.Type;
411
412export 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]);
423export type ToolActivityNativeAppReference = typeof ToolActivityNativeAppReference.Type;
424
425export 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]);
439export type ToolActivityIcon = typeof ToolActivityIcon.Type;
440
441export 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});
447export type ToolActivitySource = typeof ToolActivitySource.Type;
448
409export const ItemLifecyclePayload = Schema.Struct({449export 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 the459 * 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 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { explicitFaviconUrl, faviconUrlForPage, toolActivityFaviconUrl } from "./favicon.ts";
4
5describe("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 */
6export 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
17const 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
26function 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. */
41export 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 */
59export 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}