# Files changed · PR \#22243 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/22243/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(mobile\): thread chips show the thread's project icon](https://git.cafe/pingdotgg/t3code/pull/22243.md)

Version: 38

Head commit: 8b0678c092431fd2e4fbff348099056645540bd7

Base commit: e64766c56ac6fc1678e6107052feb0047a35337f

Comparison base: ecfe4f7955bd826a4d465aa52200ea4354a0cb7f

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/App.tsx

Status: modified; +2; −0

```diff
@@ -9,6 +9,7 @@
 import { createStaticNavigation } from "@react-navigation/native";
 
 import { RegistryContext } from "@effect/atom-react";
+import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages";
 import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet";
 import * as Dialog from "./components/ui/Dialog";
 import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
@@ -108,6 +109,7 @@
                   </IncomingShareProvider>
                   <Dialog.Host />
                   <ThreadArrangementHost />
+                  <ProjectChipIconRasterizer />
                 </NativeLayoutMetricsProvider>
               </GlobalVoiceInputControl>
               {/* Anchored-menu overlays render here — in-window, so the
```

## apps/mobile/src/features/threads/ThreadFeed.tsx

Status: modified; +20; −5

```diff
@@ -4,7 +4,11 @@
 import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width";
 import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo";
 import { useMarkdownImageSource } from "../../native/useMarkdownImageSource";
+import {
+  enrichedContextLinkCustomization,
+  enrichedThreadLinkCustomization,
+  withChipIcon,
-import { enrichedContextLinkCustomization } from "../../native/useEnrichedLinkVariants";
+} from "../../native/useEnrichedLinkVariants";
 import {
   enrichedSkillDisplayName,
   enrichedSkillInstructionsPath,
@@ -230,7 +234,7 @@
   pickWorkspaceBasenameMatch,
   WORKSPACE_BASENAME_LOOKUP_LIMIT,
 } from "@t3tools/client-runtime/workspace-basename-lookup";
+import { useLiveThreadLinkLabels, useThreadChipIcons } from "../../state/entities";
-import { useLiveThreadLinkLabels } from "../../state/entities";
 import { useThreadSelection } from "../../state/use-thread-selection";
 import { composerDocumentAttachmentRecord } from "../../lib/composerContext";
 import * as Option from "effect/Option";
@@ -826,6 +830,12 @@
 }) {
   const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
   const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]);
+  const threadIcons = useThreadChipIcons(props.environmentId, liveMarkdown);
+  const linkHandlers: MarkdownLinkHandlers = {
+    ...props.linkHandlers,
+    linkCustomization: (href) =>
+      withChipIcon(props.linkHandlers.linkCustomization?.(href), threadIcons.get(href)),
+  };
 
   return segments.map((segment) => {
     if (segment.kind === "artifact-template") {
@@ -846,7 +856,7 @@
         markdown={markdown}
         skills={props.skills}
         textStyle={props.markdownStyles.nativeTextStyle}
+        {...linkHandlers}
-        {...props.linkHandlers}
         renderImage={props.renderImage}
       />
     );
@@ -1491,6 +1501,7 @@
   const navigation = useNavigation();
   const { selectedThread } = useThreadSelection();
   const liveText = useLiveThreadLinkLabels(props.text, props.environmentId);
+  const threadIcons = useThreadChipIcons(props.environmentId, liveText, props.context?.records);
   const text = replaceComposerContextReferences(liveText, (ref) => {
     const available = props.context?.records.some((record) => record.contextId === ref.contextId);
     return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`;
@@ -1574,8 +1585,11 @@
             }
           },
           linkCustomization: (href) =>
-            enrichedContextLinkCustomization(href, props.context?.records ?? []) ??
-            props.linkHandlers.linkCustomization?.(href),
+            withChipIcon(
+              enrichedContextLinkCustomization(href, props.context?.records ?? []) ??
+                props.linkHandlers.linkCustomization?.(href),
+              threadIcons.get(href),
+            ),
         }}
       />
       {selected ? (
@@ -1977,6 +1991,7 @@
   });
   const markdownLinkHandlers: MarkdownLinkHandlers = {
     onLinkPress: onMarkdownLinkPress,
+    linkCustomization: enrichedThreadLinkCustomization,
     onImagePress: (href) => onMarkdownLinkPress(href, true),
     resolveImageSource,
     fileContextMenu: (href) => {
```

## apps/mobile/src/lib/enrichedLinkPresentation.test.ts

Status: modified; +49; −0

```diff
@@ -1,8 +1,11 @@
 import { describe, expect, it } from "vite-plus/test";
+import type { ComposerContextId, EnvironmentId, ThreadId } from "@t3tools/contracts";
 import {
   enrichedLinkVariantPattern,
   enrichedSkillLinkRegex,
+  enrichedThreadLinkPresentation,
   ENRICHED_INLINE_FILE_LINK_REGEX,
+  threadChipTargets,
 } from "./enrichedLinkPresentation";
 
 describe("Enriched link presentation", () => {
@@ -46,4 +49,50 @@
     expect(pattern.test(`${url}/child`)).toBe(false);
     expect(pattern.test("file:///workspace/app1Xtsx?line=34")).toBe(false);
   });
+
+  it("presents thread links as thread chips and leaves other links alone", () => {
+    expect(enrichedThreadLinkPresentation("t3-thread://v1/thread%3Aproject%3A1")).toEqual({
+      accent: "#009c96",
+      icon: { kind: "chip", name: "messages-square" },
+    });
+    for (const href of [
+      "https://github.com/example/repo/pull/7",
+      "https://example.com",
+      "t3-context://v1/thread/thread_1",
+    ]) {
+      expect(enrichedThreadLinkPresentation(href), href).toBeUndefined();
+    }
+  });
+
+  it("finds each linked thread once and resolves thread records to their own environment", () => {
+    const here = "env-here" as EnvironmentId;
+    const there = "env-there" as EnvironmentId;
+    expect(
+      threadChipTargets(
+        here,
+        "See [A](t3-thread://v1/thread_a), [A again](t3-thread://v1/thread_a) and [B](t3-thread://v1/thread_b).",
+        [
+          {
+            version: 1,
+            contextId: "thr1" as ComposerContextId,
+            kind: "thread",
+            label: "C",
+            environmentId: there,
+            threadId: "thread_c" as ThreadId,
+            title: "C",
+          },
+        ],
+      ),
+    ).toEqual([
+      { href: "t3-thread://v1/thread_a", environmentId: here, threadId: "thread_a", link: true },
+      { href: "t3-thread://v1/thread_b", environmentId: here, threadId: "thread_b", link: true },
+      {
+        href: "t3-context://v1/thread/thr1",
+        environmentId: there,
+        threadId: "thread_c",
+        link: false,
+      },
+    ]);
+    expect(threadChipTargets(here, "No links here.")).toEqual([]);
+  });
 });
```

## apps/mobile/src/lib/enrichedLinkPresentation.ts

Status: modified; +49; −2

```diff
@@ -1,4 +1,4 @@
-import type { ComposerContextRecord } from "@t3tools/contracts";
+import type { ComposerContextRecord, EnvironmentId, ThreadId } from "@t3tools/contracts";
 import {
   composerChipSizeSuffix,
   contextChipIcon,
@@ -8,7 +8,11 @@
   UNAVAILABLE_CONTEXT_CHIP_PRESENTATION,
 } from "./composerChipPresentation";
 import type { SelectableMarkdownSkill } from "../native/SelectableMarkdownText.types";
+import {
+  formatComposerContextHref,
+  parseComposerContextHref,
+} from "@t3tools/shared/composerContextReferences";
+import { hasThreadLinks, parseThreadLinkHref } from "@t3tools/shared/threadLinks";
-import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";
 
 export function enrichedContextLinkPresentation(
   href: string,
@@ -29,6 +33,49 @@
   };
 }
 
+export function enrichedThreadLinkPresentation(
+  href: string,
+): { accent?: string; icon: ContextChipIconRef } | undefined {
+  if (!parseThreadLinkHref(href)) return undefined;
+  const presentation = contextChipPresentation("thread");
+  return {
+    ...(presentation.accent ? { accent: presentation.accent } : {}),
+    icon: contextChipIcon(presentation, ""),
+  };
+}
+
+export interface ThreadChipTarget {
+  readonly href: string;
+  readonly environmentId: EnvironmentId;
+  readonly threadId: ThreadId;
+  readonly link: boolean;
+}
+
+export function threadChipTargets(
+  environmentId: EnvironmentId,
+  markdown: string,
+  records?: ReadonlyArray<ComposerContextRecord>,
+): ReadonlyArray<ThreadChipTarget> {
+  const targets = new Map<string, ThreadChipTarget>();
+  if (hasThreadLinks(markdown)) {
+    for (const [, href = ""] of markdown.matchAll(/\]\((t3-thread:\/\/v1\/[^\s)]+)\)/g)) {
+      const threadId = parseThreadLinkHref(href);
+      if (threadId) targets.set(href, { href, environmentId, threadId, link: true });
+    }
+  }
+  for (const record of records ?? []) {
+    if (record.kind !== "thread" || !("threadId" in record)) continue;
+    const href = formatComposerContextHref("thread", record.contextId);
+    targets.set(href, {
+      href,
+      environmentId: record.environmentId,
+      threadId: record.threadId,
+      link: false,
+    });
+  }
+  return [...targets.values()];
+}
+
 export function enrichedLinkVariantPattern(url: string) {
   return `^${url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`;
 }
```

## apps/mobile/src/lib/projectIcon.test.ts

Status: added; +59; −0

```diff
@@ -1,0 +1,59 @@
+import type { ProjectIconOverride } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveProjectChipIcon } from "./projectIcon";
+
+const project = (title: string, projectIcon?: ProjectIconOverride) => ({
+  title,
+  workspaceRoot: `/work/${title}`,
+  projectIcon,
+});
+
+describe("resolveProjectChipIcon", () => {
+  const favicon = () => "https://env.example/favicon.png";
+  const noFavicon = () => null;
+
+  it("shows the chat bubble for unknown threads and the no-project folder", () => {
+    expect(resolveProjectChipIcon(null, "/scratch", favicon)).toBeNull();
+    expect(resolveProjectChipIcon(project("scratch"), "/work/scratch", favicon)).toBeNull();
+  });
+
+  it("prefers a custom icon over the favicon", () => {
+    expect(
+      resolveProjectChipIcon(
+        project("t3code", { kind: "monogram", text: "Q7", color: "violet" }),
+        null,
+        favicon,
+      ),
+    ).toEqual({ kind: "monogram", text: "Q7", color: "violet" });
+    expect(
+      resolveProjectChipIcon(project("t3code", { kind: "emoji", emoji: "🚀" }), null, favicon),
+    ).toEqual({ kind: "emoji", emoji: "🚀" });
+    expect(
+      resolveProjectChipIcon(
+        project("lucide proj", { kind: "lucide", name: "rocket", color: "emerald" }),
+        null,
+        favicon,
+      ),
+    ).toEqual({ kind: "monogram", text: "LP", color: "emerald" });
+  });
+
+  it("uses the favicon, then a monogram of the title", () => {
+    expect(resolveProjectChipIcon(project("t3code"), null, favicon)).toEqual({
+      kind: "favicon",
+      uri: "https://env.example/favicon.png",
+    });
+    expect(resolveProjectChipIcon(project("Auto Monogram"), null, noFavicon)).toEqual({
+      kind: "monogram",
+      text: "AM",
+      color: "lime",
+    });
+    expect(
+      resolveProjectChipIcon(
+        project("t3code"),
+        null,
+        () => "https://env.example/api/assets/project-favicon-missing",
+      ),
+    ).toMatchObject({ kind: "monogram" });
+  });
+});
```

## apps/mobile/src/lib/projectIcon.ts

Status: modified; +35; −1

```diff
@@ -1,4 +1,6 @@
+import { isScratchProject } from "@t3tools/client-runtime/state/projects";
+import { ProjectIconColor, type ProjectIconOverride } from "@t3tools/contracts";
+import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
-import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
 
 export type ProjectIconGlyph =
   | { readonly kind: "emoji"; readonly emoji: string }
@@ -30,6 +32,15 @@
   return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join("");
 }
 
+export function projectMonogramColor(projectName: string): ProjectIconColor {
+  const seed = projectName.normalize("NFKC").trim().toLocaleLowerCase("en-US") || "project";
+  let index = 0;
+  for (const glyph of seed) {
+    index = (index * 31 + (glyph.codePointAt(0) ?? 0)) % ProjectIconColor.literals.length;
+  }
+  return ProjectIconColor.literals[index] ?? "blue";
+}
+
 export function resolveProjectIconGlyph(
   projectIcon: ProjectIconOverride | null | undefined,
   projectTitle: string,
@@ -46,6 +57,29 @@
   }
 }
 
+export type ProjectChipIcon = { readonly kind: "favicon"; readonly uri: string } | ProjectIconGlyph;
+
+export function resolveProjectChipIcon(
+  project: {
+    readonly title: string;
+    readonly workspaceRoot: string;
+    readonly projectIcon?: ProjectIconOverride | null;
+  } | null,
+  scratchWorkspaceRoot: string | null | undefined,
+  faviconUrl: () => string | null,
+): ProjectChipIcon | null {
+  if (!project || isScratchProject(project, scratchWorkspaceRoot)) return null;
+  const glyph = resolveProjectIconGlyph(project.projectIcon, project.title);
+  if (glyph) return glyph;
+  const url = faviconUrl();
+  if (url && !isProjectFaviconFallbackUrl(url)) return { kind: "favicon", uri: url };
+  return {
+    kind: "monogram",
+    text: projectMonogram(project.title),
+    color: projectMonogramColor(project.title),
+  };
+}
+
 const PROJECT_ICON_COLOR_CLASSES: Record<
   ProjectIconColor,
   { readonly text: string; readonly background: string }
```

## apps/mobile/src/native/ProjectChipIconImages.tsx

Status: added; +150; −0

```diff
@@ -1,0 +1,150 @@
+import type { ProjectIconColor } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import { Atom } from "effect/reactivity";
+import { useRef, useSyncExternalStore } from "react";
+import { Platform, View } from "react-native";
+import Svg, { Rect, Text } from "react-native-svg";
+import { useResolveClassNames } from "uniwind";
+
+import { countGlyphs, projectIconColorClassNames, type ProjectIconGlyph } from "../lib/projectIcon";
+
+const TILE_SIZE = 48;
+
+interface PendingTile {
+  readonly key: string;
+  readonly glyph: ProjectIconGlyph;
+  readonly promise: Promise<string>;
+  readonly resolve: (uri: string) => void;
+  readonly reject: () => void;
+}
+
+const tiles = new Map<string, PendingTile>();
+let pendingTiles: ReadonlyArray<PendingTile> = [];
+const listeners = new Set<() => void>();
+
+export function projectGlyphKey(glyph: ProjectIconGlyph): string {
+  return JSON.stringify(
+    glyph.kind === "emoji" ? ["emoji", glyph.emoji] : ["monogram", glyph.text, glyph.color],
+  );
+}
+
+function decodeGlyphKey(key: string): ProjectIconGlyph {
+  const [kind, text, color] = JSON.parse(key) as [string, string, ProjectIconColor];
+  return kind === "emoji" ? { kind: "emoji", emoji: text } : { kind: "monogram", text, color };
+}
+
+function rasterize(key: string): Promise<string> {
+  const existing = tiles.get(key);
+  if (existing) return existing.promise;
+  let resolve!: (uri: string) => void;
+  let reject!: () => void;
+  const promise = new Promise<string>((done, fail) => {
+    resolve = done;
+    reject = fail;
+  });
+  const tile = { key, glyph: decodeGlyphKey(key), promise, resolve, reject };
+  tiles.set(key, tile);
+  pendingTiles = [...pendingTiles, tile];
+  queueMicrotask(notify);
+  return promise;
+}
+
+function finish(tile: PendingTile, base64: string | undefined) {
+  if (base64) {
+    tile.resolve(`data:image/png;base64,${base64.replace(/\s/g, "")}`);
+  } else {
+    tile.reject();
+  }
+  pendingTiles = pendingTiles.filter((pending) => pending !== tile);
+  notify();
+}
+
+function notify() {
+  for (const listener of listeners) listener();
+}
+
+function subscribe(listener: () => void) {
+  listeners.add(listener);
+  return () => listeners.delete(listener);
+}
+
+export const projectGlyphImageAtom = Atom.family((key: string) =>
+  Atom.make(Effect.tryPromise(() => rasterize(key))).pipe(
+    Atom.keepAlive,
+    Atom.withLabel(`mobile-project-glyph-image:${key}`),
+  ),
+);
+
+export function ProjectChipIconRasterizer() {
+  const pending = useSyncExternalStore(subscribe, () => pendingTiles);
+  return pending.map((tile) => <GlyphCapture key={tile.key} tile={tile} />);
+}
+
+function captureTile(svg: Svg, tile: PendingTile) {
+  try {
+    svg.toDataURL(
+      (base64?: string) => finish(tile, base64),
+      Platform.OS === "ios" ? { width: TILE_SIZE, height: TILE_SIZE } : undefined,
+    );
+  } catch {
+    finish(tile, undefined);
+  }
+}
+
+function GlyphCapture(props: { readonly tile: PendingTile }) {
+  const started = useRef(false);
+  const capture = (svg: Svg | null) => {
+    if (!svg || started.current) return;
+    started.current = true;
+    requestAnimationFrame(() => captureTile(svg, props.tile));
+  };
+  return (
+    <View
+      pointerEvents="none"
+      style={{ position: "absolute", left: -TILE_SIZE * 4, top: 0, opacity: 0 }}
+    >
+      <Svg ref={capture} width={TILE_SIZE} height={TILE_SIZE}>
+        {props.tile.glyph.kind === "emoji" ? (
+          <Text
+            x={TILE_SIZE / 2}
+            y={TILE_SIZE * 0.8}
+            textAnchor="middle"
+            fontSize={TILE_SIZE * 0.8}
+          >
+            {props.tile.glyph.emoji}
+          </Text>
+        ) : (
+          <MonogramTile text={props.tile.glyph.text} color={props.tile.glyph.color} />
+        )}
+      </Svg>
+    </View>
+  );
+}
+
+function MonogramTile(props: { readonly text: string; readonly color: ProjectIconColor }) {
+  const classNames = projectIconColorClassNames(props.color);
+  const background = useResolveClassNames(classNames.background);
+  const foreground = useResolveClassNames(`font-mono ${classNames.text}`);
+  const fontSize = TILE_SIZE * (countGlyphs(props.text) === 1 ? 0.6 : 0.515625);
+  return (
+    <>
+      <Rect
+        width={TILE_SIZE}
+        height={TILE_SIZE}
+        rx={TILE_SIZE * 0.25}
+        fill={String(background.backgroundColor)}
+      />
+      <Text
+        x={TILE_SIZE / 2}
+        y={TILE_SIZE / 2 + fontSize * 0.36}
+        textAnchor="middle"
+        fill={String(foreground.color)}
+        fontFamily={typeof foreground.fontFamily === "string" ? foreground.fontFamily : undefined}
+        fontWeight="700"
+        fontSize={fontSize}
+      >
+        {props.text}
+      </Text>
+    </>
+  );
+}
```

## apps/mobile/src/native/useEnrichedLinkVariants.ts

Status: modified; +30; −7

```diff
@@ -22,19 +22,42 @@
   enrichedLinkVariantPattern,
   enrichedSkillDisplayName,
   enrichedSkillReference,
+  enrichedThreadLinkPresentation,
 } from "../lib/enrichedLinkPresentation";
 
+function chipCustomization(
+  chip: ReturnType<typeof enrichedContextLinkPresentation>,
+): MarkdownLinkCustomization | undefined {
+  if (!chip) return undefined;
+  return {
+    label: chip.label,
+    icon: contextChipIconSource(chip.icon),
+    chip: { accent: chip.accent, tintsIcon: chip.icon.kind === "chip" },
+  };
+}
+
 export function enrichedContextLinkCustomization(
   href: string,
   records?: ReadonlyArray<ComposerContextRecord>,
+) {
+  return chipCustomization(enrichedContextLinkPresentation(href, records));
+}
+
+export function enrichedThreadLinkCustomization(href: string) {
+  return chipCustomization(enrichedThreadLinkPresentation(href));
+}
+
+export function withChipIcon(
+  customization: MarkdownLinkCustomization | undefined,
+  iconUri: string | undefined,
 ): MarkdownLinkCustomization | undefined {
-  const context = enrichedContextLinkPresentation(href, records);
-  if (!context) return undefined;
-  return {
-    label: context.label,
-    icon: contextChipIconSource(context.icon),
+  return iconUri && customization?.chip
+    ? {
+        ...customization,
+        icon: { uri: iconUri },
+        chip: { ...customization.chip, tintsIcon: false },
-    chip: { accent: context.accent, tintsIcon: context.icon.kind === "chip" },
-  };
+      }
+    : customization;
 }
 
 function presentation(
```

## apps/mobile/src/state/entities.ts

Status: modified; +98; −6

```diff
@@ -7,17 +7,29 @@
   EnvironmentThread,
   EnvironmentThreadShell,
 } from "@t3tools/client-runtime/state/shell";
-import type {
+import {
   EnvironmentId,
-  ScopedProjectRef,
-  ScopedThreadRef,
-  ServerConfig,
   ThreadId,
+  type ComposerContextRecord,
+  type ScopedProjectRef,
+  type ScopedThreadRef,
+  type ServerConfig,
 } from "@t3tools/contracts";
-import { hasThreadLinks, relabelThreadLinks } from "@t3tools/shared/threadLinks";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import {
-import { Atom } from "effect/reactivity";
+  hasThreadLinks,
+  relabelThreadLinks,
+  resolveThreadLinkRef,
+} from "@t3tools/shared/threadLinks";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import { AsyncResult, Atom } from "effect/reactivity";
 import { useMemo } from "react";
 
+import { threadChipTargets } from "../lib/enrichedLinkPresentation";
+import { resolveProjectChipIcon } from "../lib/projectIcon";
+import { projectGlyphImageAtom, projectGlyphKey } from "../native/ProjectChipIconImages";
+import { projectFaviconUrlAtom } from "./assets";
 import { environmentProjects } from "./projects";
 import { environmentServerConfigsAtom, serverEnvironment } from "./server";
 import { environmentThreadDetails, environmentThreadShells } from "./threads";
@@ -53,6 +65,73 @@
   }).pipe(Atom.withLabel(`mobile-thread-titles:${environmentId}`));
 });
 
+const decodeThreadKey = Schema.decodeUnknownSync(Schema.Tuple([EnvironmentId, ThreadId]));
+const decodeThreadChipTargets = Schema.decodeUnknownSync(
+  Schema.Array(
+    Schema.Struct({
+      href: Schema.String,
+      environmentId: EnvironmentId,
+      threadId: ThreadId,
+      link: Schema.Boolean,
+    }),
+  ),
+);
+
+const threadProjectChipIconAtom = Atom.family((key: string) => {
+  const ref = scopeThreadRef(...decodeThreadKey(JSON.parse(key)));
+  return Atom.make((get): string | null => {
+    const shell = get(environmentThreadShells.threadShellAtom(ref));
+    const project = shell
+      ? get(
+          environmentProjects.projectAtom({
+            environmentId: ref.environmentId,
+            projectId: shell.projectId,
+          }),
+        )
+      : null;
+    const icon = resolveProjectChipIcon(
+      project,
+      get(serverEnvironment.configValueAtom(ref.environmentId))?.scratchWorkspaceRoot,
+      () =>
+        project
+          ? get(
+              projectFaviconUrlAtom({
+                environmentId: ref.environmentId,
+                cwd: project.workspaceRoot,
+                faviconPath: project.faviconPath,
+              }),
+            )
+          : null,
+    );
+    if (!icon) return null;
+    if (icon.kind === "favicon") return icon.uri;
+    return Option.getOrNull(AsyncResult.value(get(projectGlyphImageAtom(projectGlyphKey(icon)))));
+  }).pipe(Atom.withLabel(`mobile-thread-project-chip-icon:${key}`));
+});
+
+const EMPTY_THREAD_CHIP_ICONS: ReadonlyMap<string, string> = new Map();
+const EMPTY_THREAD_CHIP_ICONS_ATOM = Atom.make(EMPTY_THREAD_CHIP_ICONS).pipe(
+  Atom.withLabel("mobile-thread-chip-icons:empty"),
+);
+
+const threadChipIconsAtom = Atom.family((key: string) => {
+  const targets = decodeThreadChipTargets(JSON.parse(key));
+  return Atom.make((get) => {
+    const icons = new Map<string, string>();
+    for (const target of targets) {
+      const source = scopeThreadRef(target.environmentId, target.threadId);
+      const ref = target.link
+        ? (resolveThreadLinkRef(source, get(environmentThreadShells.threadRefsAtom)) ?? source)
+        : source;
+      const icon = get(
+        threadProjectChipIconAtom(JSON.stringify([ref.environmentId, ref.threadId])),
+      );
+      if (icon) icons.set(target.href, icon);
+    }
+    return icons;
+  }).pipe(Atom.withLabel(`mobile-thread-chip-icons:${key}`));
+});
+
 export function waitForProject(
   ref: ScopedProjectRef,
   timeoutMs = 10_000,
@@ -123,6 +202,19 @@
   );
 }
 
+export function useThreadChipIcons(
+  environmentId: EnvironmentId,
+  markdown: string,
+  records?: ReadonlyArray<ComposerContextRecord>,
+): ReadonlyMap<string, string> {
+  const targets = threadChipTargets(environmentId, markdown, records);
+  return useAtomValue(
+    targets.length === 0
+      ? EMPTY_THREAD_CHIP_ICONS_ATOM
+      : threadChipIconsAtom(JSON.stringify(targets)),
+  );
+}
+
 export function useEnvironmentServerConfig(
   environmentId: EnvironmentId | null,
 ): ServerConfig | null {
```
