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> = {
185185 close: IconX,
186186 construction: IconHammer,
187187 content_copy: IconCopy,
188 desktop_windows: IconDeviceDesktop,
188189 edit: IconEdit,
189190 error: IconAlertCircle,
190191 folder: IconFolder,
apps/mobile/src/features/threads/ThreadFeed.tsx+12
......@@ -1501,6 +1501,7 @@ function renderFeedEntry(
15011501 readonly markdownStyles: MarkdownStyleSets;
15021502 readonly reviewCommentColors: ReviewCommentColors;
15031503 readonly reviewCommentBubbleWidth: number;
1504 readonly themeAppearance: "light" | "dark";
15041505 readonly userBubbleMaxWidth: number;
15051506 },
15061507) {
......@@ -1538,12 +1539,16 @@ function renderFeedEntry(
15381539 if (entry.type === "work-toggle") {
15391540 return (
15401541 <ThreadWorkGroupToggle
1542 environmentId={props.environmentId}
15411543 rowSizing={props.workRowSizing}
15421544 expanded={entry.expanded}
15431545 hiddenCount={entry.hiddenCount}
15441546 iconSubtleColor={iconSubtleColor}
15451547 summary={entry.summary}
15461548 summaryKind={entry.summaryKind}
1549 themeAppearance={props.themeAppearance}
1550 toolSurface={entry.toolSurface}
1551 toolIcon={entry.toolIcon}
15471552 summaryToolIcon={entry.summaryToolIcon}
15481553 hasFailure={entry.hasFailure}
15491554 shimmer={entry.shimmer}
......@@ -1716,12 +1721,14 @@ function renderFeedEntry(
17161721 // Anchors/details live in ThreadFeed and survive this group-only remount.
17171722 key={`${entry.id}:${props.workRowSizing.textSizeKey}`}
17181723 activities={entry.activities}
1724 environmentId={props.environmentId}
17191725 anchorKey={entry.id}
17201726 copiedRowId={props.copiedRowId}
17211727 expandedRows={props.expandedWorkRows}
17221728 rowSizing={props.workRowSizing}
17231729 scrollPositions={props.workGroupScrollPositions}
17241730 iconSubtleColor={iconSubtleColor}
1731 themeAppearance={props.themeAppearance}
17251732 onCopyRow={props.onCopyWorkRow}
17261733 onToggleRow={props.onToggleWorkRow}
17271734 renderImage={props.renderViewedImage}
......@@ -2001,6 +2008,7 @@ function ThreadFeedPlaceholder(props: {
20012008
20022009export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
20032010 const navigation = useNavigation();
2011 const { themeAppearance } = useAppearancePreferences();
20042012 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
20052013 const disclosureSettleFrameRef = useRef<number | null>(null);
20062014 const disclosureSettleSecondFrameRef = useRef<number | null>(null);
......@@ -2319,6 +2327,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
23192327 iconSubtleColor,
23202328 markdownStyles,
23212329 reviewCommentColors,
2330 themeAppearance,
23222331 userBubbleColor,
23232332 viewportWidth,
23242333 }),
......@@ -2329,6 +2338,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
23292338 iconSubtleColor,
23302339 markdownStyles,
23312340 reviewCommentColors,
2341 themeAppearance,
23322342 userBubbleColor,
23332343 viewportWidth,
23342344 ],
......@@ -2754,6 +2764,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
27542764 markdownStyles,
27552765 reviewCommentColors,
27562766 reviewCommentBubbleWidth,
2767 themeAppearance,
27572768 userBubbleMaxWidth,
27582769 skills: props.skills,
27592770 onUseArtifactTemplate: props.onUseArtifactTemplate,
......@@ -2774,6 +2785,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
27742785 markdownStyles,
27752786 reviewCommentColors,
27762787 reviewCommentBubbleWidth,
2788 themeAppearance,
27772789 userBubbleMaxWidth,
27782790 onCopyWorkRow,
27792791 markdownLinkHandlers,
apps/mobile/src/features/threads/thread-work-log.tsx+184-9
......@@ -1,4 +1,5 @@
11import * as Haptics from "expo-haptics";
2import { Image } from "expo-image";
23import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
34import { MaskedView } from "@expo/ui/community/masked-view";
45import type { LegendListRef } from "@legendapp/list/react-native";
......@@ -26,6 +27,8 @@ import {
2627 View,
2728} from "react-native";
2829import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
30import type { EnvironmentId, ToolActivityIcon } from "@t3tools/contracts";
31import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
2932
3033import { AppText as Text } from "../../components/AppText";
3134import { T3Wordmark } from "../../components/T3Wordmark";
......@@ -58,6 +61,7 @@ import Animated, {
5861 withSequence,
5962 withTiming,
6063} from "react-native-reanimated";
64import { useAssetUrl } from "../../state/assets";
6165
6266const SHIMMER_WIDTH = 72;
6367const SHIMMER_SWEEP_MS = 1_350;
......@@ -134,17 +138,28 @@ export function ThreadDisclosureChevron(props: {
134138}
135139
136140function ShimmerWorkContent(props: {
141 readonly environmentId?: EnvironmentId;
137142 readonly highlighted: boolean;
138143 readonly icon: WorkContentIcon;
139144 readonly iconSubtleColor: ColorValue;
140145 readonly label: string;
141146 readonly onTextLayout?: ComponentProps<typeof Text>["onTextLayout"];
142147 readonly showIcon: boolean;
148 readonly themeAppearance?: "light" | "dark";
149 readonly toolIcon?: ToolActivityIcon;
143150}) {
144151 return (
145152 <View className="flex-row items-center gap-1.5">
146153 <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 ? (
148163 <WorkLogIcon
149164 icon={props.icon}
150165 color={props.iconSubtleColor}
......@@ -167,10 +182,13 @@ function ShimmerWorkContent(props: {
167182}
168183
169184export function ShimmeringWorkContent(props: {
185 readonly environmentId?: EnvironmentId;
170186 readonly icon: WorkContentIcon;
171187 readonly iconSubtleColor: ColorValue;
172188 readonly label: string;
173189 readonly showIcon: boolean;
190 readonly themeAppearance?: "light" | "dark";
191 readonly toolIcon?: ToolActivityIcon;
174192}) {
175193 const [availableWidth, setAvailableWidth] = useState(0);
176194 const [textWidth, setTextWidth] = useState(0);
......@@ -235,11 +253,14 @@ export function ShimmeringWorkContent(props: {
235253 onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
236254 >
237255 <ShimmerWorkContent
256 environmentId={props.environmentId}
238257 highlighted={false}
239258 icon={props.icon}
240259 iconSubtleColor={props.iconSubtleColor}
241260 label={props.label}
242261 showIcon={props.showIcon}
262 themeAppearance={props.themeAppearance}
263 toolIcon={props.toolIcon}
243264 onTextLayout={(event) => setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)}
244265 />
245266 {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? (
......@@ -272,11 +293,14 @@ export function ShimmeringWorkContent(props: {
272293 >
273294 <Animated.View style={[{ width: availableWidth }, counterSweepStyle]}>
274295 <ShimmerWorkContent
296 environmentId={props.environmentId}
275297 highlighted
276298 icon={props.icon}
277299 iconSubtleColor={props.iconSubtleColor}
278300 label={props.label}
279301 showIcon={props.showIcon}
302 themeAppearance={props.themeAppearance}
303 toolIcon={props.toolIcon}
280304 />
281305 </Animated.View>
282306 </MaskedView>
......@@ -307,10 +331,14 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName {
307331 return { ios: "sparkles", android: "auto_awesome" };
308332 case "alert":
309333 return { ios: "exclamationmark.triangle", android: "error" };
334 case "browser":
335 return { ios: "safari", android: "public" };
310336 case "check":
311337 return { ios: "checkmark", android: "check" };
312338 case "command":
313339 return { ios: "terminal", android: "terminal" };
340 case "computer":
341 return { ios: "desktopcomputer", android: "desktop_windows" };
314342 case "edit":
315343 return { ios: "square.and.pencil", android: "edit" };
316344 case "eye":
......@@ -369,11 +397,13 @@ export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActiv
369397interface ThreadWorkLogProps {
370398 readonly activities: ReadonlyArray<ThreadFeedActivity>;
371399 readonly anchorKey: string;
400 readonly environmentId: EnvironmentId;
372401 readonly copiedRowId: string | null;
373402 readonly expandedRows: Readonly<Record<string, boolean>>;
374403 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
375404 readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
376405 readonly iconSubtleColor: ColorValue;
406 readonly themeAppearance: "light" | "dark";
377407 readonly onCopyRow: (rowId: string, value: string) => void;
378408 readonly onToggleRow: (rowId: string, anchorKey: string) => void;
379409 readonly renderImage: MarkdownImageRenderer;
......@@ -388,20 +418,24 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) {
388418 anchorKey={props.anchorKey}
389419 copied={props.copiedRowId === row.id}
390420 expanded={props.expandedRows[row.id] ?? false}
421 environmentId={props.environmentId}
391422 iconSubtleColor={props.iconSubtleColor}
392423 onCopyRow={props.onCopyRow}
393424 onToggleRow={props.onToggleRow}
394425 renderImage={props.renderImage}
426 themeAppearance={props.themeAppearance}
395427 />
396428 ),
397429 [
398430 props.anchorKey,
399431 props.copiedRowId,
400432 props.expandedRows,
433 props.environmentId,
401434 props.iconSubtleColor,
402435 props.onCopyRow,
403436 props.onToggleRow,
404437 props.renderImage,
438 props.themeAppearance,
405439 ],
406440 );
407441
......@@ -669,7 +703,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
669703 !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText;
670704 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
671705 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
674713 return (
675714 <Animated.View
......@@ -697,18 +736,31 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
697736 <View className="min-h-8 flex-row items-center gap-1.5">
698737 {row.live ? (
699738 <ShimmeringWorkContent
739 environmentId={props.environmentId}
700740 icon={icon}
701741 iconSubtleColor={props.iconSubtleColor}
702742 label={displayText}
703743 showIcon
744 themeAppearance={props.themeAppearance}
745 toolIcon={toolIcon}
704746 />
705747 ) : (
706748 <>
707749 <View className="h-6 w-6 shrink-0 items-center justify-center">
708 <WorkLogIcon
709 icon={icon}
710 color={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
711 />
750 {failed && !hasSpecialToolIcon ? (
751 <WorkLogIcon
752 icon={icon}
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 )}
712764 </View>
713765 <Text
714766 className={cn(
......@@ -728,7 +780,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
728780 Copied
729781 </Text>
730782 ) : null}
731 {failed && toolPresentation ? (
783 {failed && hasSpecialToolIcon ? (
732784 <View
733785 className="h-4 w-4 items-center justify-center"
734786 accessibilityElementsHidden
......@@ -786,6 +838,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
786838});
787839
788840export function ThreadWorkGroupToggle(props: {
841 readonly environmentId: EnvironmentId;
789842 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
790843 readonly expanded: boolean;
791844 readonly hiddenCount: number;
......@@ -793,6 +846,9 @@ export function ThreadWorkGroupToggle(props: {
793846 readonly summary: string;
794847 readonly summaryKind: ToolGroupSummaryKind;
795848 readonly summaryToolIcon?: "browser" | "t3-code";
849 readonly themeAppearance: "light" | "dark";
850 readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
851 readonly toolIcon?: ToolActivityIcon;
796852 readonly hasFailure: boolean;
797853 readonly shimmer: boolean;
798854 readonly onToggle: () => void;
......@@ -800,7 +856,11 @@ export function ThreadWorkGroupToggle(props: {
800856 const accessibilityLabel = props.hasFailure
801857 ? `${props.summary}, tool call failed`
802858 : 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
805865 return (
806866 <View className="-mx-1 px-1 py-0">
......@@ -820,15 +880,24 @@ export function ThreadWorkGroupToggle(props: {
820880 {props.shimmer ? (
821881 <ShimmeringWorkContent
822882 key={props.rowSizing.textSizeKey}
883 environmentId={props.environmentId}
823884 icon={icon}
824885 iconSubtleColor={props.iconSubtleColor}
825886 label={props.summary}
826887 showIcon
888 themeAppearance={props.themeAppearance}
889 toolIcon={props.toolIcon}
827890 />
828891 ) : (
829892 <>
830893 <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 />
832901 </View>
833902 <Text
834903 key={props.rowSizing.textSizeKey}
......@@ -850,6 +919,112 @@ export function ThreadWorkGroupToggle(props: {
850919 );
851920}
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
8531028function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
8541029 switch (kind) {
8551030 case "read":
apps/mobile/src/lib/threadActivity.test.ts+43-3
......@@ -800,6 +800,20 @@ describe("buildThreadFeed", () => {
800800 payload: {
801801 title: "Call repository tool",
802802 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 },
803817 detail: "repository.search",
804818 status: "completed",
805819 data: {
......@@ -820,7 +834,21 @@ describe("buildThreadFeed", () => {
820834 return;
821835 }
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 });
824852 expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"');
825853 expect(group.activities[0]?.getFullDetail()).toContain("repository.search");
826854 });
......@@ -1600,6 +1628,8 @@ describe("buildThreadFeed", () => {
16001628 id: string,
16011629 createdAt: string,
16021630 status: ThreadFeedActivity["status"] = "success",
1631 toolSurface?: "browser" | "computer",
1632 toolIcon?: import("@t3tools/contracts").ToolActivityIcon,
16031633 ): ThreadFeedActivity => ({
16041634 id,
16051635 createdAt,
......@@ -1619,6 +1649,8 @@ describe("buildThreadFeed", () => {
16191649 label: `Tool ${id}`,
16201650 command: `command ${id}`,
16211651 tone: "tool",
1652 ...(toolSurface ? { toolSurface } : {}),
1653 ...(toolIcon ? { toolIcon } : {}),
16221654 },
16231655 });
16241656 const feed: ThreadFeedEntry[] = [
......@@ -1630,8 +1662,11 @@ describe("buildThreadFeed", () => {
16301662 activities: [
16311663 activity("activity-1", "2026-04-01T00:00:01.000Z"),
16321664 activity("activity-neutral", "2026-04-01T00:00:02.000Z", "neutral"),
1633 activity("activity-2", "2026-04-01T00:00:03.000Z"),
1634 activity("activity-3", "2026-04-01T00:00:04.000Z"),
1665 activity("activity-2", "2026-04-01T00:00:03.000Z", "success", "browser"),
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 }),
16351670 ],
16361671 },
16371672 ];
......@@ -1644,6 +1679,11 @@ describe("buildThreadFeed", () => {
16441679 hiddenCount: 3,
16451680 expanded: false,
16461681 summary: "Ran 3 commands",
1682 toolSurface: "computer",
1683 toolIcon: {
1684 _tag: "native-app",
1685 app: { _tag: "app-id", appId: "com.example.Editor" },
1686 },
16471687 });
16481688
16491689 const expanded = deriveThreadFeedPresentation(
apps/mobile/src/lib/threadActivity.ts+48
......@@ -26,6 +26,7 @@ import {
2626 toolGroupSummaryKind,
2727 type ToolGroupSummaryKind,
2828} from "@t3tools/client-runtime/work-log/presentation";
29import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
2930import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
3031
3132import * as Arr from "effect/Array";
......@@ -67,8 +68,10 @@ export interface ThreadFeedActivity {
6768 readonly icon:
6869 | "agent"
6970 | "alert"
71 | "browser"
7072 | "check"
7173 | "command"
74 | "computer"
7275 | "edit"
7376 | "eye"
7477 | "globe"
......@@ -99,6 +102,9 @@ export interface WorkLogEntry {
99102 changedFiles?: ReadonlyArray<string>;
100103 tone: "thinking" | "tool" | "info" | "error";
101104 toolTitle?: string;
105 toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
106 toolIcon?: import("@t3tools/contracts").ToolActivityIcon;
107 toolSource?: import("@t3tools/contracts").ToolActivitySource;
102108 itemType?: ToolLifecycleItemType;
103109 requestKind?: PendingApproval["requestKind"];
104110 toolLifecycleStatus?: WorkLogToolLifecycleStatus;
......@@ -149,6 +155,8 @@ export type ThreadFeedEntry =
149155 readonly expanded: boolean;
150156 readonly summary: string;
151157 readonly summaryKind: ToolGroupSummaryKind;
158 readonly toolSurface?: WorkLogEntry["toolSurface"];
159 readonly toolIcon?: WorkLogEntry["toolIcon"];
152160 readonly summaryToolIcon?: "browser" | "t3-code";
153161 readonly hasFailure: boolean;
154162 readonly live: boolean;
......@@ -398,6 +406,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
398406 const commandPreview = extractToolCommand(payload);
399407 const changedFiles = extractChangedFiles(payload);
400408 const title = extractToolTitle(payload);
409 const toolPresentation = extractToolActivityPresentation(payload);
401410 // task.updated included: terminal bypassed updates (Codex children's only
402411 // terminal signal) must carry task identity so they collapse per child
403412 // instead of stacking anonymous "Task idle" rows.
......@@ -479,6 +488,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
479488 if (title) {
480489 entry.toolTitle = title;
481490 }
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 }
482500 if (itemType === "mcp_tool_call") {
483501 const data = asRecord(payload?.data);
484502 const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item;
......@@ -614,6 +632,9 @@ function mergeDerivedWorkLogEntries(
614632 const command = next.command ?? previous.command;
615633 const rawCommand = next.rawCommand ?? previous.rawCommand;
616634 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;
617638 const itemType = next.itemType ?? previous.itemType;
618639 const requestKind = next.requestKind ?? previous.requestKind;
619640 const collapseKey = next.collapseKey ?? previous.collapseKey;
......@@ -631,6 +652,9 @@ function mergeDerivedWorkLogEntries(
631652 ...(rawCommand ? { rawCommand } : {}),
632653 ...(changedFiles.length > 0 ? { changedFiles } : {}),
633654 ...(toolTitle ? { toolTitle } : {}),
655 ...(toolSurface ? { toolSurface } : {}),
656 ...(toolIcon ? { toolIcon } : {}),
657 ...(toolSource ? { toolSource } : {}),
634658 ...(itemType ? { itemType } : {}),
635659 ...(requestKind ? { requestKind } : {}),
636660 ...(collapseKey ? { collapseKey } : {}),
......@@ -752,6 +776,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
752776 return "message";
753777 }
754778 if (entry.sourceActivityKind === "runtime.warning") return "warning";
779 if (entry.toolSurface) return entry.toolSurface;
755780 if (entry.requestKind === "command") return "command";
756781 if (entry.requestKind === "file-read") return "eye";
757782 if (entry.requestKind === "file-change") return "edit";
......@@ -1537,6 +1562,27 @@ function appendToolGroupRows(
15371562 : singleActivity !== null && !singleActivity.toolLike
15381563 ? singleActivity.workEntry.label
15391564 : 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;
15401586 const summaryToolIcon = live
15411587 ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon
15421588 : singleActivity !== null &&
......@@ -1556,6 +1602,8 @@ function appendToolGroupRows(
15561602 summaryKind: toolGroupSummaryKind(
15571603 (live ? [latestActivity] : activities).map((activity) => activity.workEntry),
15581604 ),
1605 ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}),
1606 ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}),
15591607 ...(summaryToolIcon ? { summaryToolIcon } : {}),
15601608 hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure",
15611609 live,
apps/server/src/assets/AssetAccess.test.ts+17
......@@ -22,6 +22,7 @@ import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
2222import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
2323import { assetFileResponse } from "../http.ts";
2424import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
25import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
2526import { openMediaFile } from "./MediaFile.ts";
2627
2728vi.mock("node:fs/promises", async (importOriginal) => {
......@@ -40,6 +41,7 @@ const testLayer = Layer.mergeAll(
4041 Layer.provide(WorkspacePaths.layer),
4142 Layer.provide(T3ProjectFileLoader.layer),
4243 ),
44 NativeAppIconResolver.layer.pipe(Layer.provide(configLayer)),
4345 ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
4446).pipe(Layer.provideMerge(NodeServices.layer));
4547
......@@ -576,6 +578,21 @@ describe("AssetAccess", () => {
576578 });
577579 }).pipe(Effect.provide(testLayer)),
578580 );
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
580597 it.effect("serves document attachments inline when a viewer requests it", () =>
581598 Effect.gen(function* () {
apps/server/src/assets/AssetAccess.ts+24
......@@ -12,6 +12,7 @@ import {
1212 AssetWorkspacePathValidationError,
1313 AssetWorkspaceResolutionError,
1414 AssetWorkspaceRootNormalizationError,
15 ToolActivityNativeAppReference,
1516} from "@t3tools/contracts";
1617import {
1718 hostPreviewMimeTypeFromExtension,
......@@ -42,6 +43,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta
4243import * as ServerConfig from "../config.ts";
4344import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
4445import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
46import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
4547import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts";
4648
4749export const ASSET_ROUTE_PREFIX = "/api/assets";
......@@ -120,6 +122,12 @@ const AssetClaimsSchema = Schema.Union([
120122 filePath: Schema.String,
121123 expiresAt: Schema.Number,
122124 }),
125 Schema.Struct({
126 version: Schema.Literal(1),
127 kind: Schema.Literal("native-app-icon"),
128 app: ToolActivityNativeAppReference,
129 expiresAt: Schema.Number,
130 }),
123131]);
124132type AssetClaims = typeof AssetClaimsSchema.Type;
125133
......@@ -504,6 +512,16 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
504512 }
505513 break;
506514 }
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 }
507525 }
508526
509527 const secretStore = yield* ServerSecretStore.ServerSecretStore;
......@@ -603,6 +621,12 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
603621 : null;
604622 }
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
606630 const decodedPath = decodeRelativePath(relativePath);
607631 if (decodedPath === null) return null;
608632 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", () => {
28442844 itemType: "command_execution",
28452845 status: "inProgress",
28462846 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 },
28472857 detail: "Bash: vp test run",
28482858 data: {
28492859 toolName: "Bash",
......@@ -2871,6 +2881,17 @@ describe("ProviderRuntimeIngestion", () => {
28712881 itemType: "command_execution",
28722882 toolCallId: "tool-call-9",
28732883 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 },
28742895 detail: "Bash: vp test run",
28752896 data: {
28762897 toolName: "Bash",
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+12
......@@ -812,7 +812,11 @@ export function runtimeEventToActivities(
812812 itemType: event.payload.itemType,
813813 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
814814 ...(event.payload.status ? { status: event.payload.status } : {}),
815 ...(event.payload.title ? { title: event.payload.title } : {}),
815816 ...(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 } : {}),
816820 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
817821 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
818822 ...(event.payload.parentToolUseId
......@@ -840,7 +844,11 @@ export function runtimeEventToActivities(
840844 itemType: event.payload.itemType,
841845 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
842846 ...(event.payload.status ? { status: event.payload.status } : {}),
847 ...(event.payload.title ? { title: event.payload.title } : {}),
843848 ...(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 } : {}),
844852 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
845853 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
846854 ...(event.payload.parentToolUseId
......@@ -868,7 +876,11 @@ export function runtimeEventToActivities(
868876 itemType: event.payload.itemType,
869877 ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}),
870878 ...(event.payload.status ? { status: event.payload.status } : {}),
879 ...(event.payload.title ? { title: event.payload.title } : {}),
871880 ...(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 } : {}),
872884 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
873885 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
874886 ...(event.payload.parentToolUseId
apps/server/src/provider/Layers/CodexAdapter.test.ts+241
......@@ -800,9 +800,207 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
800800 }),
801801 );
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
803998 it.effect("preserves failed and declined outcomes on completed tool items", () =>
804999 Effect.gen(function* () {
8051000 const { adapter, runtime } = yield* startLifecycleRuntime();
1001 const maxLengthAppId = `com.${"a".repeat(508)}`;
1002 const collidingMaxLengthAppId = `com.${"a".repeat(507)}b`;
1003 const longAppSourceKeys: string[] = [];
8061004 const items = [
8071005 {
8081006 type: "commandExecution",
......@@ -822,6 +1020,42 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
8221020 error: { message: "Build failed" },
8231021 status: "failed",
8241022 },
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 },
8251059 {
8261060 type: "fileChange",
8271061 id: "declined-change",
......@@ -856,7 +1090,14 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
8561090 return;
8571091 }
8581092 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 }
8591099 }
1100 NodeAssert.equal(new Set(longAppSourceKeys).size, 2);
8601101 }),
8611102 );
8621103
apps/server/src/provider/Layers/CodexAdapter.ts+313-2
......@@ -17,6 +17,9 @@ import {
1717 type ProviderRuntimeEvent,
1818 type ProviderRequestKind,
1919 type ThreadTokenUsageSnapshot,
20 type ToolActivityIcon,
21 type ToolActivityNativeAppReference,
22 type ToolActivitySource,
2023 type ProviderUserInputAnswers,
2124 RuntimeItemId,
2225 RuntimeRequestId,
......@@ -27,6 +30,7 @@ import {
2730 ProviderSendTurnInput,
2831} from "@t3tools/contracts";
2932import * as Effect from "effect/Effect";
33import * as NodeCrypto from "node:crypto";
3034import * as Crypto from "effect/Crypto";
3135import * as Exit from "effect/Exit";
3236import * as Fiber from "effect/Fiber";
......@@ -150,6 +154,236 @@ function trimText(value: string | undefined | null): string | undefined {
150154 return trimmed && trimmed.length > 0 ? trimmed : undefined;
151155}
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
153387const FATAL_CODEX_STDERR_SNIPPETS = ["failed to connect to websocket"];
154388
155389function isFatalCodexProcessStderrMessage(message: string): boolean {
......@@ -239,8 +473,82 @@ function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType
239473 return "unknown";
240474}
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 {
243545 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;
244552 return `${item.server} · ${item.tool}`;
245553 }
246554 switch (itemType) {
......@@ -481,6 +789,8 @@ function mapItemLifecycle(
481789 }
482790
483791 const detail = itemDetail(itemType, item);
792 const toolPresentation = item.type === "mcpToolCall" ? mcpToolPresentation(item) : {};
793 const title = itemTitle(itemType, item, toolPresentation);
484794 const status =
485795 lifecycle === "item.started"
486796 ? "inProgress"
......@@ -496,8 +806,9 @@ function mapItemLifecycle(
496806 payload: {
497807 itemType,
498808 ...(status ? { status } : {}),
499 ...(itemTitle(itemType, item) ? { title: itemTitle(itemType, item) } : {}),
809 ...(title ? { title } : {}),
500810 ...(detail ? { detail } : {}),
811 ...toolPresentation,
501812 ...(event.payload !== undefined ? { data: event.payload } : {}),
502813 },
503814 };
apps/server/src/server.test.ts+2
......@@ -122,6 +122,7 @@ import * as TerminalManager from "./terminal/Manager.ts";
122122import * as PreviewManager from "./preview/Manager.ts";
123123import * as PortScanner from "./preview/PortScanner.ts";
124124import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
125import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts";
125126import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
126127import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
127128import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
......@@ -626,6 +627,7 @@ const buildAppUnderTest = (options?: {
626627 Layer.provide(WorkspacePaths.layer),
627628 Layer.provide(T3ProjectFileLoader.layer),
628629 ),
630 NativeAppIconResolver.layer,
629631 );
630632 const gitWorkflowLayer = GitWorkflowService.layer.pipe(
631633 Layer.provideMerge(vcsDriverRegistryLayer),
apps/server/src/server.ts+2-1
......@@ -67,6 +67,7 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
6767import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
6868import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
6969import * as ServerSettings from "./serverSettings.ts";
70import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts";
7071import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
7172import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
7273import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
......@@ -443,7 +444,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
443444 // keeps a single Live for all opencode consumers.
444445 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
445446 Layer.provideMerge(WorkspaceLayerLive),
446 Layer.provideMerge(ProjectFaviconResolverLayerLive),
447 Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, ProjectFaviconResolverLayerLive)),
447448 Layer.provideMerge(RepositoryIdentityResolver.layer),
448449 Layer.provideMerge(ServerEnvironmentLayerLive),
449450 Layer.provideMerge(AuthLayerLive),
apps/server/src/ws.ts+4-1
......@@ -2125,7 +2125,10 @@ const makeWsRpcLayer = (
21252125 observeRpcEffect(
21262126 WS_METHODS.assetsCreateUrl,
21272127 Effect.gen(function* () {
2128 if (input.resource._tag === "attachment") {
2128 if (
2129 input.resource._tag === "attachment" ||
2130 input.resource._tag === "native-app-icon"
2131 ) {
21292132 return yield* issueAssetUrl({ resource: input.resource });
21302133 }
21312134 if (input.resource._tag === "project-favicon") {
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+112
......@@ -10,6 +10,7 @@ import {
1010 resolveWorkGroupScrollIndex,
1111 shouldFollowWorkGroupAppend,
1212 shouldPreserveAssistantLineBreaks,
13 type MessagesTimelineRow,
1314 workEntryDisplayLabel,
1415} from "./MessagesTimeline.logic";
1516
......@@ -1660,6 +1661,7 @@ describe("deriveMessagesTimelineRows", () => {
16601661 label: "Status updated",
16611662 detail: "Editing MessagesTimeline.tsx",
16621663 tone: middleTone,
1664 toolSurface: "computer" as const,
16631665 },
16641666 },
16651667 {
......@@ -1672,6 +1674,8 @@ describe("deriveMessagesTimelineRows", () => {
16721674 label: "test",
16731675 detail: "Running tests",
16741676 tone: "tool" as const,
1677 toolSurface: "browser" as const,
1678 toolIcon: { _tag: "website" as const, pageUrl: "https://example.com/checkout" },
16751679 },
16761680 },
16771681 ];
......@@ -1695,6 +1699,8 @@ describe("deriveMessagesTimelineRows", () => {
16951699 hiddenCount: 3,
16961700 expanded: false,
16971701 summary,
1702 toolSurface: "browser",
1703 toolIcon: { _tag: "website", pageUrl: "https://example.com/checkout" },
16981704 });
16991705 expect(expandedRows.map((row) => row.id)).toEqual([
17001706 "work-toggle:work-entry-1",
......@@ -1709,6 +1715,84 @@ describe("deriveMessagesTimelineRows", () => {
17091715 });
17101716 });
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
17121796 it.each([true, false])(
17131797 "keeps a large expanded tool run inside one timeline item, live=%s",
17141798 (isWorking) => {
......@@ -1845,6 +1929,34 @@ describe("deriveMessagesTimelineRows", () => {
18451929});
18461930
18471931describe("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
18481960 it.each(["", " \n"])("keeps Thinking after assistant content grows from %j", (text) => {
18491961 const startedAt = "2026-01-01T00:00:00Z";
18501962 const turnId = TurnId.make("turn-1");
apps/web/src/components/chat/MessagesTimeline.logic.ts+22
......@@ -304,6 +304,8 @@ export type MessagesTimelineRow =
304304 expanded: boolean;
305305 summary: string;
306306 summaryKind: ToolGroupSummaryKind;
307 toolSurface?: WorkLogEntry["toolSurface"];
308 toolIcon?: WorkLogEntry["toolIcon"];
307309 summaryToolIcon?: "browser" | "t3-code";
308310 hasFailure: boolean;
309311 }
......@@ -849,6 +851,22 @@ export function deriveMessagesTimelineRows(input: {
849851 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
850852 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
851853 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;
852870 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
853871 const singleEntry =
854872 visibleGroupedEntries.length === 1 ? (visibleGroupedEntries[0] ?? null) : null;
......@@ -872,6 +890,8 @@ export function deriveMessagesTimelineRows(input: {
872890 ? singleEntry.label
873891 : summarizeToolGroup(visibleGroupedEntries),
874892 summaryKind,
893 ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}),
894 ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}),
875895 ...(summaryToolIcon ? { summaryToolIcon } : {}),
876896 hasFailure:
877897 latestToolEntry !== undefined &&
......@@ -1014,6 +1034,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
10141034 a.expanded === bw.expanded &&
10151035 a.summary === bw.summary &&
10161036 a.summaryKind === bw.summaryKind &&
1037 a.toolSurface === bw.toolSurface &&
1038 Equal.equals(a.toolIcon, bw.toolIcon) &&
10171039 a.hasFailure === bw.hasFailure
10181040 );
10191041 }
apps/web/src/components/chat/MessagesTimeline.tsx+251-16
......@@ -4,6 +4,7 @@ import {
44 type MessageId,
55 type ScopedThreadRef,
66 type ServerProviderSkill,
7 type ToolActivityIcon,
78 type TurnId,
89} from "@t3tools/contracts";
910import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
......@@ -25,6 +26,8 @@ const NOOP_OPEN_AGENTS = () => {};
2526const NOOP_USE_ARTIFACT_TEMPLATE = () => {};
2627const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
2728import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
29import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
30import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";
2831import {
2932 createContext,
3033 Fragment,
......@@ -32,6 +35,7 @@ import {
3235 use,
3336 useCallback,
3437 useEffect,
38 useId,
3539 useLayoutEffect,
3640 useMemo,
3741 useRef,
......@@ -155,7 +159,6 @@ import { cn } from "~/lib/utils";
155159import { useUiStateStore } from "~/uiStateStore";
156160import { type TimestampFormat } from "@t3tools/contracts/settings";
157161import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";
158
159162import {
160163 buildInlineTerminalContextText,
161164 formatInlineTerminalContextLabel,
......@@ -1768,10 +1771,12 @@ function ActivityShimmerOverlay({ children }: { children: ReactNode }) {
17681771function LiveActivityRow({
17691772 label,
17701773 iconName,
1774 toolIcon,
17711775 failed = false,
17721776}: {
17731777 label: string;
17741778 iconName?: WorkEntryIconName;
1779 toolIcon?: ToolActivityIcon | undefined;
17751780 failed?: boolean;
17761781}) {
17771782 return (
......@@ -1779,11 +1784,18 @@ function LiveActivityRow({
17791784 <LiveActivityContent
17801785 label={label}
17811786 iconName={iconName}
1787 toolIcon={toolIcon}
17821788 failed={failed}
17831789 announceFailure={failed}
17841790 />
17851791 <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 />
17871799 </ActivityShimmerOverlay>
17881800 </div>
17891801 );
......@@ -1792,17 +1804,20 @@ function LiveActivityRow({
17921804function LiveActivityContent({
17931805 label,
17941806 iconName,
1807 toolIcon,
17951808 failed = false,
17961809 announceFailure = false,
17971810 highlighted = false,
17981811}: {
17991812 label: string;
18001813 iconName: WorkEntryIconName | undefined;
1814 toolIcon?: ToolActivityIcon | undefined;
18011815 failed?: boolean;
18021816 announceFailure?: boolean;
18031817 highlighted?: boolean;
18041818}) {
1805 const isSpecialToolIcon = iconName === "browser" || iconName === "t3-code";
1819 const isSpecialToolIcon =
1820 iconName === "browser" || iconName === "computer" || iconName === "t3-code";
18061821 const resolvedIconName = failed && !isSpecialToolIcon ? "circle-alert" : iconName;
18071822
18081823 return (
......@@ -1822,9 +1837,11 @@ function LiveActivityContent({
18221837 role={announceFailure ? "img" : undefined}
18231838 aria-label={announceFailure ? "Tool call failed" : undefined}
18241839 >
1825 <WorkEntryIcon
1826 name={resolvedIconName}
1827 className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")}
1840 <ToolActivityIconView
1841 icon={failed && !isSpecialToolIcon ? undefined : toolIcon}
1842 fallbackName={resolvedIconName}
1843 className="block size-4 shrink-0 stroke-[1.8]"
1844 muted={!highlighted}
18281845 />
18291846 </span>
18301847 ) : null}
......@@ -1848,12 +1865,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
18481865 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
18491866 >
18501867 {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 />
18521874 ) : (
18531875 <div className="min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
18541876 <LiveActivityContent
18551877 label={label}
18561878 iconName={workEntryIconName(row.entry)}
1879 toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon}
18571880 failed={failed}
18581881 announceFailure={failed}
18591882 />
......@@ -1908,9 +1931,13 @@ function WorkGroupToggleTimelineRow({
19081931 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
19091932 >
19101933 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1911 <WorkEntryIcon
1912 name={row.summaryToolIcon ?? toolGroupSummaryIconName(row.summaryKind)}
1913 className="size-4 shrink-0 stroke-[1.8] opacity-70"
1934 <ToolActivityIconView
1935 icon={row.toolIcon}
1936 fallbackName={
1937 row.summaryToolIcon ?? row.toolSurface ?? toolGroupSummaryIconName(row.summaryKind)
1938 }
1939 className="size-4 shrink-0 stroke-[1.8]"
1940 muted
19141941 />
19151942 </span>
19161943 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
......@@ -2445,6 +2472,7 @@ type WorkEntryIconName =
24452472 | "browser"
24462473 | "check"
24472474 | "circle-alert"
2475 | "computer"
24482476 | "eye"
24492477 | "globe"
24502478 | "hammer"
......@@ -2457,6 +2485,203 @@ type WorkEntryIconName =
24572485 | "x"
24582486 | "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
24602685function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {
24612686 switch (name) {
24622687 case "bot":
......@@ -2464,7 +2689,9 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className
24642689 case "brain":
24652690 return <BrainIcon className={className} aria-hidden />;
24662691 case "browser":
2467 return <GlobeIcon className={className} aria-hidden />;
2692 return <BrowserAppIcon className={className} />;
2693 case "computer":
2694 return <ComputerUseAppIcon className={className} />;
24682695 case "t3-code":
24692696 return <T3Wordmark className={className} aria-hidden />;
24702697 case "check":
......@@ -2570,6 +2797,7 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
25702797 ) {
25712798 return "message-circle";
25722799 }
2800 if (workEntry.toolSurface) return workEntry.toolSurface;
25732801 const toolPresentation = resolveWorkEntryToolPresentation(workEntry);
25742802 if (toolPresentation) return toolPresentation.icon;
25752803 const action = toolGroupAction(workEntry);
......@@ -2731,8 +2959,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
27312959 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
27322960 const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry);
27332961 const toolPresentation = resolveWorkEntryToolPresentation(workEntry);
2962 const hasSpecialToolIcon = toolPresentation !== null || workEntry.toolSurface !== undefined;
27342963 const entryIconName =
2735 showWarningIndicator || (showFailedIndicator && !toolPresentation)
2964 showWarningIndicator || (showFailedIndicator && !hasSpecialToolIcon)
27362965 ? "circle-alert"
27372966 : workEntryIconName(workEntry);
27382967 const previewText = workEntryDisplayLabel(workEntry, workspaceRoot);
......@@ -2812,9 +3041,15 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
28123041 role={showFailedIndicator ? "img" : undefined}
28133042 aria-label={showFailedIndicator ? "Tool call failed" : undefined}
28143043 >
2815 <WorkEntryIcon
2816 name={entryIconName}
2817 className="block size-4 shrink-0 stroke-[1.8] opacity-70"
3044 <ToolActivityIconView
3045 icon={
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
28183053 />
28193054 </span>
28203055 <div className="flex min-w-0 flex-1 items-center gap-1.5">
......@@ -2823,7 +3058,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
28233058 <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span>
28243059 </p>
28253060 </div>
2826 {showFailedIndicator && toolPresentation ? (
3061 {showFailedIndicator && hasSpecialToolIcon ? (
28273062 <XIcon aria-hidden className="size-3 shrink-0 text-icon-muted" />
28283063 ) : null}
28293064 <span
apps/web/src/session-logic.test.ts+24
......@@ -1240,6 +1240,13 @@ describe("deriveWorkLogEntries", () => {
12401240 payload: {
12411241 itemType: "mcp_tool_call",
12421242 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 },
12431250 data: { item },
12441251 },
12451252 }),
......@@ -1247,6 +1254,16 @@ describe("deriveWorkLogEntries", () => {
12471254
12481255 const [entry] = deriveWorkLogEntries(activities);
12491256 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 });
12501267 expect(entry?.toolData).toEqual(item);
12511268 });
12521269
......@@ -1296,6 +1313,7 @@ describe("deriveWorkLogEntries", () => {
12961313 payload: {
12971314 itemType: "mcp_tool_call",
12981315 toolCallId: "call-1",
1316 toolSurface: "browser",
12991317 data: { item },
13001318 },
13011319 }),
......@@ -1306,6 +1324,7 @@ describe("deriveWorkLogEntries", () => {
13061324 payload: {
13071325 itemType: "mcp_tool_call",
13081326 toolCallId: "call-1",
1327 toolIcon: { _tag: "website", pageUrl: "https://example.com/result" },
13091328 },
13101329 }),
13111330 ];
......@@ -1313,6 +1332,11 @@ describe("deriveWorkLogEntries", () => {
13131332 const [entry] = deriveWorkLogEntries(activities);
13141333 expect(entry?.toolData).toEqual(item);
13151334 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 });
13161340 expect(resolveWorkEntryToolPresentation(entry!)?.displayName).toBe(
13171341 "Took a snapshot of the preview page",
13181342 );
apps/web/src/session-logic.ts+20
......@@ -7,6 +7,7 @@ import {
77 extractCommandOutputText,
88 isWorktreeSetupActivity,
99} from "@t3tools/client-runtime/work-log/presentation";
10import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
1011import {
1112 ApprovalRequestId,
1213 isToolLifecycleItemType,
......@@ -83,6 +84,9 @@ export interface WorkLogEntry {
8384 changedFiles?: ReadonlyArray<string>;
8485 tone: "thinking" | "tool" | "info" | "error";
8586 toolTitle?: string;
87 toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
88 toolIcon?: import("@t3tools/contracts").ToolActivityIcon;
89 toolSource?: import("@t3tools/contracts").ToolActivitySource;
8690 toolData?: unknown;
8791 itemType?: ToolLifecycleItemType;
8892 requestKind?: PendingApproval["requestKind"];
......@@ -889,6 +893,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
889893 const commandPreview = extractToolCommand(payload);
890894 const changedFiles = extractChangedFiles(payload);
891895 const title = extractToolTitle(payload);
896 const toolPresentation = extractToolActivityPresentation(payload);
892897 const isTaskActivity =
893898 activity.kind === "task.started" ||
894899 activity.kind === "task.progress" ||
......@@ -948,6 +953,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
948953 if (title) {
949954 entry.toolTitle = title;
950955 }
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 }
951965 if (itemType === "mcp_tool_call") {
952966 const data = asRecord(payload?.data);
953967 const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item;
......@@ -1170,6 +1184,9 @@ function mergeDerivedWorkLogEntries(
11701184 const command = next.command ?? previous.command;
11711185 const rawCommand = next.rawCommand ?? previous.rawCommand;
11721186 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;
11731190 const itemType = next.itemType ?? previous.itemType;
11741191 const requestKind = next.requestKind ?? previous.requestKind;
11751192 const collapseKey = next[workLogCollapseKey] ?? previous[workLogCollapseKey];
......@@ -1185,6 +1202,9 @@ function mergeDerivedWorkLogEntries(
11851202 ...(rawCommand ? { rawCommand } : {}),
11861203 ...(changedFiles.length > 0 ? { changedFiles } : {}),
11871204 ...(toolTitle ? { toolTitle } : {}),
1205 ...(toolSurface ? { toolSurface } : {}),
1206 ...(toolIcon ? { toolIcon } : {}),
1207 ...(toolSource ? { toolSource } : {}),
11881208 ...(itemType ? { itemType } : {}),
11891209 ...(requestKind ? { requestKind } : {}),
11901210 ...(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
4040reports them. If Codex does not report either value, T3 Code leaves it out instead of using the
4141parent 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
4354## Approve access to other apps
4455
4556When 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 @@
199199 "types": "./src/work-log/presentation.ts",
200200 "default": "./src/work-log/presentation.ts"
201201 },
202 "./work-log/tool-presentation": {
203 "types": "./src/work-log/toolPresentation.ts",
204 "default": "./src/work-log/toolPresentation.ts"
205 },
202206 "./work-log/command-label": {
203207 "types": "./src/work-log/commandLabel.ts",
204208 "default": "./src/work-log/commandLabel.ts"
packages/client-runtime/src/work-log/presentation.test.ts+35
......@@ -14,6 +14,41 @@ import {
1414 workEntryViewedImagePath,
1515} 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
1752describe("resolveWorkEntryToolPresentation", () => {
1853 it.each([
1954 "mcp__t3-code__preview_click",
packages/client-runtime/src/work-log/presentation.ts+21
......@@ -2,6 +2,7 @@ import {
22 isToolLifecycleItemType,
33 type AssetResource,
44 type ThreadId,
5 type ToolActivitySource,
56 type ToolLifecycleItemType,
67} from "@t3tools/contracts";
78import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
......@@ -28,6 +29,7 @@ export interface WorkLogPresentationEntry {
2829 readonly toolLifecycleStatus?: string;
2930 readonly sourceActivityKind?: string;
3031 readonly taskId?: string;
32 readonly toolSource?: ToolActivitySource;
3133}
3234
3335export type ToolGroupAction =
......@@ -426,8 +428,13 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
426428
427429export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEntry>): string {
428430 const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry);
431 const sources = new Map<string, ToolActivitySource>();
429432 const groupedEntries = new Map<ToolGroupAction, WorkLogPresentationEntry[]>();
430433 for (const entry of summaryEntries) {
434 if (entry.toolSource) {
435 sources.set(entry.toolSource.key, entry.toolSource);
436 continue;
437 }
431438 const action = toolGroupAction(entry);
432439 const group = groupedEntries.get(action);
433440 if (group) group.push(entry);
......@@ -436,6 +443,20 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt
436443 const labels = [...groupedEntries].map(([action, actionEntries]) =>
437444 toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)),
438445 );
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 }
439460 const sentenceLabels = labels.map((label, index) =>
440461 index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
441462 );
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 {
77 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,
88 ProjectFaviconPath,
99} from "./orchestration.ts";
10import { ToolActivityNativeAppReference } from "./providerRuntime.ts";
1011
1112const ASSET_PATH_MAX_LENGTH = 1024;
1213
......@@ -40,6 +41,9 @@ export const AssetResource = Schema.Union([
4041 // project projection before it issues the signed URL.
4142 path: Schema.optional(ProjectFaviconPath),
4243 }),
44 Schema.TaggedStruct("native-app-icon", {
45 app: ToolActivityNativeAppReference,
46 }),
4347]);
4448export type AssetResource = typeof AssetResource.Type;
4549
packages/contracts/src/providerRuntime.ts+43
......@@ -406,11 +406,54 @@ const TurnDiffUpdatedPayload = Schema.Struct({
406406});
407407export 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
409449export const ItemLifecyclePayload = Schema.Struct({
410450 itemType: CanonicalItemType,
411451 status: Schema.optional(RuntimeItemStatus),
412452 title: Schema.optional(TrimmedNonEmptyStringSchema),
413453 detail: Schema.optional(TrimmedNonEmptyStringSchema),
454 toolSurface: Schema.optional(ToolActivitySurface),
455 toolIcon: Schema.optional(ToolActivityIcon),
456 toolSource: Schema.optional(ToolActivitySource),
414457 data: Schema.optional(Schema.Unknown),
415458 /**
416459 * Owning agent when this item ran inside a subagent (resolved from the
packages/shared/package.json+4
......@@ -79,6 +79,10 @@
7979 "types": "./src/toolActivity.ts",
8080 "import": "./src/toolActivity.ts"
8181 },
82 "./favicon": {
83 "types": "./src/favicon.ts",
84 "import": "./src/favicon.ts"
85 },
8286 "./Struct": {
8387 "types": "./src/Struct.ts",
8488 "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}