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

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

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

Visibility: public

[fix\(mobile\): chat and composer chips stay readable in light mode](https://git.cafe/pingdotgg/t3code/pull/22585.md)

Version: 32

Head commit: 598d97f9296df66240967552b28bba68a5afa90a

Base commit: 9a391eb3f55b738113b61768cf2d8263b4f3ac2e

Comparison base: 9a391eb3f55b738113b61768cf2d8263b4f3ac2e

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, getStateFromPath } 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";
@@ -110,6 +111,7 @@
                   </IncomingShareProvider>
                   <Dialog.Host />
                   <ThreadArrangementHost />
+                  <ProjectChipIconRasterizer />
                 </NativeLayoutMetricsProvider>
               </GlobalVoiceInputControl>
               {/* Anchored-menu overlays render here — in-window, so the
```

## apps/mobile/src/components/ProjectFavicon.tsx

Status: modified; +6; −2

```diff
@@ -14,6 +14,7 @@
 import { projectFaviconUrlAtom } from "../state/assets";
 import {
   countGlyphs,
+  projectAutoMonogram,
   projectIconColorClassNames,
   resolveProjectIconGlyph,
   type ProjectIconGlyph,
@@ -167,6 +168,7 @@
 
   const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest;
   const showImage = requestIsActive && status === "loaded";
+  const fallbackGlyph = showImage ? null : projectAutoMonogram(props.projectTitle);
 
   return (
     <View
@@ -177,14 +179,16 @@
         justifyContent: "center",
       }}
     >
-      {!showImage ? (
+      {showImage ? null : fallbackGlyph ? (
+        <ProjectIconGlyphView glyph={fallbackGlyph} size={props.size} />
+      ) : (
         <SymbolView
           name={{ ios: "folder.fill", android: props.open ? "folder_open" : "folder" }}
           size={props.size}
           tintColorClassName={"accent-icon-subtle"}
           type="monochrome"
         />
-      ) : null}
+      )}
 
       {requestIsActive ? (
         <Image
```

## apps/mobile/src/features/keyboard/CommandPalette.tsx

Status: modified; +20; −6

```diff
@@ -18,6 +18,7 @@
 
 import { GestureHandlerRootView } from "react-native-gesture-handler";
 
+import { ProjectFavicon } from "../../components/ProjectFavicon";
 import { GlassSurface } from "../../components/ui/GlassSurface";
 import { RowPressable } from "../../components/ui/RowPressable";
 import { AppText as Text } from "../../components/ui/AppText";
@@ -68,7 +69,6 @@
 };
 
 function itemIcon(item: CommandPaletteItem): AppSymbolName {
-  if (item.kind === "project") return "folder";
   if (item.kind === "thread") return "text.bubble";
   return ACTION_ICONS[item.key] ?? "ellipsis";
 }
@@ -101,11 +101,24 @@
       style={{ height: ROW_HEIGHT }}
     >
       <View className="w-7 items-center">
+        {props.item.project ? (
+          <ProjectFavicon
+            environmentId={props.item.project.environmentId}
+            faviconPath={props.item.project.faviconPath}
+            projectIcon={props.item.project.projectIcon}
-        <SymbolView
-          name={itemIcon(props.item)}
-          size={20}
-          tintColorClassName={props.selected ? "accent-thread-selected-foreground" : "accent-icon"}
+            projectTitle={props.item.project.title}
-        />
+            size={20}
+            workspaceRoot={props.item.project.workspaceRoot}
+          />
+        ) : (
+          <SymbolView
+            name={itemIcon(props.item)}
+            size={20}
+            tintColorClassName={
+              props.selected ? "accent-thread-selected-foreground" : "accent-icon"
+            }
+          />
+        )}
       </View>
       <View className="flex-1">
         <Text numberOfLines={1} className={cn("text-base", foregroundClassName)}>
@@ -310,6 +323,7 @@
     const projectItems: CommandPaletteItem[] = projects.map((project) => ({
       key: `project:${scopedProjectKey(project.environmentId, project.id)}`,
       kind: "project",
+      project,
       title: project.title,
       detail: `New thread · ${savedConnectionsById[project.environmentId]?.environmentLabel ?? project.environmentId}`,
       searchTerms: [project.workspaceRoot, "new thread", "project"],
```

## apps/mobile/src/features/keyboard/commandPaletteItems.ts

Status: modified; +3; −0

```diff
@@ -1,6 +1,9 @@
+import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
+
 export interface CommandPaletteItem {
   readonly key: string;
   readonly kind: "action" | "project" | "thread";
+  readonly project?: EnvironmentProject;
   readonly title: string;
   readonly detail?: string;
   readonly searchTerms: ReadonlyArray<string>;
```

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

Status: modified; +24; −5

```diff
@@ -4,8 +4,12 @@
 import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width";
 import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo";
 import { useMarkdownImageSource } from "../../native/useMarkdownImageSource";
-import { enrichedContextLinkCustomization } from "../../native/useEnrichedLinkVariants";
 import {
+  enrichedContextLinkCustomization,
+  enrichedThreadLinkCustomization,
+  withChipIcon,
+} from "../../native/useEnrichedLinkVariants";
+import {
   enrichedSkillDisplayName,
   enrichedSkillInstructionsPath,
   enrichedSkillReference,
@@ -240,7 +244,11 @@
   pickWorkspaceBasenameMatch,
   WORKSPACE_BASENAME_LOOKUP_LIMIT,
 } from "@t3tools/client-runtime/workspace-basename-lookup";
+import {
+  useLiveThreadLinkLabels,
-import { useLiveThreadLinkLabels, useQuietThreadShell } from "../../state/entities";
+  useQuietThreadShell,
+  useThreadChipIcons,
+} from "../../state/entities";
 import { useThreadSelection } from "../../state/use-thread-selection";
 import { useEnvironmentQuery } from "../../state/query";
 import { serverEnvironment } from "../../state/server";
@@ -846,6 +854,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") {
@@ -866,7 +880,7 @@
         markdown={markdown}
         skills={props.skills}
         textStyle={props.markdownStyles.nativeTextStyle}
+        {...linkHandlers}
-        {...props.linkHandlers}
         renderImage={props.renderImage}
       />
     );
@@ -1769,6 +1783,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})`;
@@ -1852,8 +1867,11 @@
             }
           },
           linkCustomization: (href) =>
-            enrichedContextLinkCustomization(href, props.context?.records ?? []) ??
+            withChipIcon(
+              enrichedContextLinkCustomization(href, props.context?.records ?? []) ??
+                props.linkHandlers.linkCustomization?.(href),
+              threadIcons.get(href),
-            props.linkHandlers.linkCustomization?.(href),
+            ),
         }}
       />
       {selected ? (
@@ -2311,6 +2329,7 @@
   });
   const markdownLinkHandlers: MarkdownLinkHandlers = {
     onLinkPress: onMarkdownLinkPress,
+    linkCustomization: enrichedThreadLinkCustomization,
     onImagePress: (href) => onMarkdownLinkPress(href, true),
     resolveImageSource,
     fileContextMenu: (href) => {
```

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

Status: added; +109; −0

```diff
@@ -1,0 +1,109 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { contextChipAccent, contextChipPresentation } from "./composerChipPresentation";
+import {
+  flattenThemeColor,
+  getMobileThemeVariables,
+  MOBILE_THEME_IDS,
+  type MobileThemeVariables,
+} from "./mobileTheme";
+
+const CHIP_ACCENTS = [
+  ...new Set(
+    [
+      ...[
+        "image",
+        "video",
+        "file",
+        "mention",
+        "terminal",
+        "element",
+        "review-comment",
+        "pull-request",
+        "skill",
+        "thread",
+      ].map((kind) => contextChipPresentation(kind)),
+      ...[
+        { state: "open", isDraft: false },
+        { state: "open", isDraft: true },
+        { state: "merged", isDraft: false },
+        { state: "closed", isDraft: false },
+      ].map((pullRequest) => contextChipPresentation("pull-request", { pullRequest })),
+    ].flatMap(({ accent }) => (accent ? [accent] : [])),
+  ),
+];
+
+function relativeLuminance(hex: string): number {
+  const [red = 0, green = 0, blue = 0] = [1, 3, 5].map((offset) => {
+    const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
+    return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
+  });
+  return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
+}
+
+function contrastRatio(first: string, second: string): number {
+  const [darker = 0, lighter = 0] = [relativeLuminance(first), relativeLuminance(second)].sort(
+    (a, b) => a - b,
+  );
+  return (lighter + 0.05) / (darker + 0.05);
+}
+
+function chipSurfaces(theme: MobileThemeVariables) {
+  const screen = theme["--color-screen"];
+  return (
+    [
+      ["--color-user-bubble", "--color-user-bubble-foreground"],
+      ["--color-thread-canvas", "--color-md-body"],
+      ["--color-composer-surface", "--color-foreground"],
+    ] as const
+  ).map(([surface, text]) => ({
+    surface: flattenThemeColor(theme[surface], screen).slice(0, 7),
+    text: theme[text],
+  }));
+}
+
+const BUILT_IN_THEMES = MOBILE_THEME_IDS.filter((id) => id !== "material-you").flatMap((id) =>
+  (["light", "dark"] as const).map((appearance) => ({
+    name: `${id} ${appearance}`,
+    theme: getMobileThemeVariables(id, appearance),
+  })),
+);
+
+describe("contextChipAccent", () => {
+  it("draws one color per theme that reads at 5:1 in messages, replies and the composer", () => {
+    for (const { name, theme } of BUILT_IN_THEMES) {
+      for (const accent of CHIP_ACCENTS) {
+        const shown = contextChipAccent(accent, theme, theme["--color-md-body"])!;
+        for (const { surface, text } of chipSurfaces(theme)) {
+          expect(contextChipAccent(accent, theme, text)).toBe(shown);
+          expect(
+            contrastRatio(shown, surface),
+            `${name}: ${accent} → ${shown} on ${surface}`,
+          ).toBeGreaterThanOrEqual(5);
+        }
+      }
+    }
+  });
+
+  it("reads on both a saturated Material You bubble and the light canvas beside it", () => {
+    const theme = {
+      ...getMobileThemeVariables("t3-code", "light"),
+      "--color-user-bubble": "#6750a4",
+      "--color-user-bubble-foreground": "#ffffff",
+    };
+    for (const { surface, text } of chipSurfaces(theme)) {
+      for (const accent of CHIP_ACCENTS) {
+        const shown = contextChipAccent(accent, theme, text)!;
+        expect(
+          contrastRatio(shown, surface),
+          `${accent} → ${shown} on ${surface}`,
+        ).toBeGreaterThanOrEqual(5);
+      }
+    }
+  });
+
+  it("keeps an accent that already reads everywhere", () => {
+    const theme = getMobileThemeVariables("t3-code", "dark");
+    expect(contextChipAccent("#009f6e", theme, theme["--color-md-body"])).toBe("#009f6e");
+  });
+});
```

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

Status: modified; +42; −0

```diff
@@ -4,6 +4,12 @@
 
 import type { CONTEXT_CHIP_ICON_SOURCES } from "./contextChipIcons.generated";
 import { resolveMarkdownFileIcon, type MarkdownFileIcon } from "./markdownLinks";
+import {
+  flattenThemeColor,
+  isDarkThemeColor,
+  themeAccentWithContrast,
+  type MobileThemeVariables,
+} from "./mobileTheme";
 
 export type ContextChipIcon = keyof typeof CONTEXT_CHIP_ICON_SOURCES;
 
@@ -39,6 +45,42 @@
   },
 } as const satisfies Record<string, ContextChipPresentation>;
 
+const CHIP_CONTRAST = 5;
+
+const readableChipAccents = new Map<string, string>();
+
+/**
+ * A chip keeps one color per theme so it looks the same in a message, a reply and the composer,
+ * which means it must read on all of them; the user bubble is usually the hardest. A surface
+ * whose text is the opposite shade, like Material You's primary-colored light bubble, can't share
+ * a color with the canvas, so chips are matched only against surfaces like the one they sit on.
+ */
+export function contextChipAccent(
+  accent: string | undefined,
+  theme: MobileThemeVariables,
+  textColor: string,
+): string | undefined {
+  if (!accent) return undefined;
+  const screen = theme["--color-screen"];
+  const darkText = isDarkThemeColor(textColor);
+  const surfaces = (
+    [
+      ["--color-user-bubble", "--color-user-bubble-foreground"],
+      ["--color-thread-canvas", "--color-md-body"],
+      ["--color-composer-surface", "--color-foreground"],
+    ] as const
+  )
+    .filter(([, text]) => isDarkThemeColor(theme[text]) === darkText)
+    .map(([surface]) => flattenThemeColor(theme[surface], screen));
+  const key = [accent, ...surfaces].join();
+  let readable = readableChipAccents.get(key);
+  if (readable === undefined) {
+    readable = themeAccentWithContrast(accent, surfaces, CHIP_CONTRAST);
+    readableChipAccents.set(key, readable);
+  }
+  return readable;
+}
+
 export const UNAVAILABLE_CONTEXT_CHIP_PRESENTATION: ContextChipPresentation = {
   icon: "circle-dashed",
 };
```

## 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/mobileTheme.ts

Status: modified; +97; −15

```diff
@@ -94,6 +94,22 @@
   return Math.round(Math.min(1, Math.max(0, converted)) * 255);
 }
 
+function oklabToLinearSrgb(lightness: number, a: number, b: number) {
+  const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
+  const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
+  const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
+  return [
+    4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+    -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
+  ] as const;
+}
+
+function oklabToSrgb(lightness: number, a: number, b: number) {
+  const [red, green, blue] = oklabToLinearSrgb(lightness, a, b);
+  return [linearToSrgb(red), linearToSrgb(green), linearToSrgb(blue)] as const;
+}
+
 /** React Native does not accept OKLCH ColorValues, so palettes cross the app boundary as sRGB. */
 export function themeColorToNativeColor(value: string): string {
   const match = OKLCH_PATTERN.exec(value);
@@ -103,17 +119,7 @@
   const chroma = Number(match[2]);
   const hue = (Number(match[3]) * Math.PI) / 180;
   const alpha = match[4] === undefined ? 1 : Number(match[4]);
-  const a = chroma * Math.cos(hue);
-  const b = chroma * Math.sin(hue);
-  const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b;
-  const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b;
-  const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b;
-  const l = lPrime ** 3;
-  const m = mPrime ** 3;
-  const s = sPrime ** 3;
+  const [red, green, blue] = oklabToSrgb(lightness, chroma * Math.cos(hue), chroma * Math.sin(hue));
-  const red = linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s);
-  const green = linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s);
-  const blue = linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s);
 
   return alpha < 1
     ? `rgba(${red}, ${green}, ${blue}, ${Number(alpha.toFixed(4))})`
@@ -168,14 +174,33 @@
   return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
 }
 
+function srgbToLinear(channel: number): number {
+  const value = channel / 255;
+  return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
+}
+
+function srgbToOklab(channels: readonly [number, number, number]) {
+  const [red = 0, green = 0, blue = 0] = channels.map(srgbToLinear);
+  const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue);
+  const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue);
+  const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue);
+  return [
+    0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
+    1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
+    0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
+  ] as const;
+}
+
 function relativeLuminance(channels: readonly [number, number, number]): number {
-  const [red, green, blue] = channels.map((channel) => {
-    const value = channel / 255;
-    return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
-  });
+  const [red, green, blue] = channels.map(srgbToLinear);
   return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!;
 }
 
+export function isDarkThemeColor(color: string): boolean {
+  const channels = rgbChannels(color);
+  return channels !== null && relativeLuminance(channels) < 0.18;
+}
+
 function contrastRatio(
   first: readonly [number, number, number],
   second: readonly [number, number, number],
@@ -230,6 +255,63 @@
   return `#${readable.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
 }
 
+/**
+ * Unlike `themeColorWithContrast`, which blends toward black or white and greys the color out,
+ * this moves only OKLCH lightness and takes the most chroma sRGB allows at each step, so a vivid
+ * accent stays vivid at the contrast it needs.
+ */
+export function themeAccentWithContrast(
+  accent: string,
+  surfaces: ReadonlyArray<string>,
+  minimum = 4.5,
+): string {
+  const accentChannels = rgbChannels(accent);
+  const surfaceChannels = surfaces.map(rgbChannels).filter((channels) => channels !== null);
+  const minimumContrast = (channels: readonly [number, number, number]) =>
+    Math.min(...surfaceChannels.map((surface) => contrastRatio(channels, surface)));
+  if (
+    !accentChannels ||
+    surfaceChannels.length === 0 ||
+    minimumContrast(accentChannels) >= minimum
+  ) {
+    return accent;
+  }
+
+  const [lightness, a, b] = srgbToOklab(accentChannels);
+  const hue = Math.atan2(b, a);
+  const inGamut = (channels: ReadonlyArray<number>) =>
+    channels.every((channel) => channel >= -1e-4 && channel <= 1 + 1e-4);
+  const mostVivid = (stepLightness: number) => {
+    let chroma = 0;
+    let lower = 0;
+    let upper = Math.max(Math.hypot(a, b), 0.2);
+    for (let index = 0; index < 20; index += 1) {
+      const candidate = (lower + upper) / 2;
+      if (
+        inGamut(
+          oklabToLinearSrgb(stepLightness, candidate * Math.cos(hue), candidate * Math.sin(hue)),
+        )
+      ) {
+        chroma = candidate;
+        lower = candidate;
+      } else {
+        upper = candidate;
+      }
+    }
+    return oklabToSrgb(stepLightness, chroma * Math.cos(hue), chroma * Math.sin(hue));
+  };
+
+  const lighten = minimumContrast([255, 255, 255]) >= minimumContrast([0, 0, 0]);
+  const step = lighten ? 0.002 : -0.002;
+  for (let next = lightness; lighten ? next <= 1 : next >= 0; next += step) {
+    const candidate = mostVivid(next);
+    if (minimumContrast(candidate) >= minimum) {
+      return `#${candidate.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
+    }
+  }
+  return lighten ? "#ffffff" : "#000000";
+}
+
 export function themeColorWithAlpha(color: string, alpha: number): string {
   const channels = rgbChannels(color);
   if (channels) {
```

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

Status: added; +81; −0

```diff
@@ -1,0 +1,81 @@
+import type { ProjectIconOverride } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { projectAutoMonogram, 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" });
+  });
+});
+
+describe("projectAutoMonogram", () => {
+  it("matches desktop's deriveProjectIdentity", () => {
+    const desktop = [
+      ["t3code", "T3", "teal"],
+      ["Auto Monogram", "AM", "lime"],
+      ["blitzdiff", "BF", "pink"],
+      ["uoalbumdotclub", "UB", "gray"],
+      ["  Tagium  ", "TM", "lime"],
+      ["Café Ünïcode", "CÜ", "fuchsia"],
+      ["web2 app", "W2", "emerald"],
+    ] as const;
+    for (const [title, text, color] of desktop) {
+      expect(projectAutoMonogram(title)).toEqual({ kind: "monogram", text, color });
+    }
+  });
+
+  it("leaves untitled projects to the folder icon, like desktop", () => {
+    expect(projectAutoMonogram("")).toBeNull();
+    expect(projectAutoMonogram("   ")).toBeNull();
+  });
+});
```

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

Status: modified; +40; −1

```diff
@@ -1,4 +1,6 @@
+import { isScratchProject } from "@t3tools/client-runtime/state/projects";
+import { ProjectIconColor, type ProjectIconOverride } from "@t3tools/contracts";
-import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
+import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
 
 export type ProjectIconGlyph =
   | { readonly kind: "emoji"; readonly emoji: string }
@@ -30,6 +32,24 @@
   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 projectAutoMonogram(projectTitle: string): ProjectIconGlyph | null {
+  if (!projectTitle.trim()) return null;
+  return {
+    kind: "monogram",
+    text: projectMonogram(projectTitle),
+    color: projectMonogramColor(projectTitle),
+  };
+}
+
 export function resolveProjectIconGlyph(
   projectIcon: ProjectIconOverride | null | undefined,
   projectTitle: string,
@@ -46,6 +66,25 @@
   }
 }
 
+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 projectAutoMonogram(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/T3ComposerEditor.ios.tsx

Status: modified; +4; −2

```diff
@@ -18,6 +18,7 @@
 import {
   composerChipSizeSuffix,
   contextChipIcon,
+  contextChipAccent,
   contextChipPresentation,
   contextRecordFileName,
 } from "../lib/composerChipPresentation";
@@ -157,6 +158,7 @@
   );
 
   const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]);
+  const chipTextColor = createNativeComposerTheme(theme).text;
   const tokensJson = useMemo(() => {
     const tokens = collectComposerInlineTokens(props.value, {
       preserveTrailingFrom: confirmedTokensRef.current,
@@ -181,7 +183,7 @@
           source: token.source,
           start: token.start,
           end: token.end,
-          accent: presentation.accent,
+          accent: contextChipAccent(presentation.accent, theme, chipTextColor),
           label:
             token.type === "skill"
               ? (skillLabels.get(token.value) ?? token.value)
@@ -194,7 +196,7 @@
         };
       }),
     );
+  }, [props.value, props.context, skillLabels, theme, chipTextColor]);
-  }, [props.value, props.context, skillLabels]);
   // Every render resolves against the snapshot history, so a render whose
   // (value, selection) lags the acknowledged native state is stamped behind
   // the native revision and rejected by the editor instead of re-applying a
```

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

Status: modified; +4; −2

```diff
@@ -19,6 +19,7 @@
 import {
   composerChipSizeSuffix,
   contextChipIcon,
+  contextChipAccent,
   contextChipPresentation,
   contextRecordFileName,
 } from "../lib/composerChipPresentation";
@@ -155,6 +156,7 @@
   );
 
   const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]);
+  const chipTextColor = createNativeComposerTheme(theme).text;
   const tokensJson = useMemo(() => {
     const tokens = collectComposerInlineTokens(props.value, {
       preserveTrailingFrom: confirmedTokensRef.current,
@@ -179,7 +181,7 @@
           source: token.source,
           start: token.start,
           end: token.end,
-          accent: presentation.accent,
+          accent: contextChipAccent(presentation.accent, theme, chipTextColor),
           label:
             token.type === "skill"
               ? (skillLabels.get(token.value) ?? token.value)
@@ -192,7 +194,7 @@
         };
       }),
     );
+  }, [props.value, props.context, skillLabels, theme, chipTextColor]);
-  }, [props.value, props.context, skillLabels]);
   // Every render resolves against the snapshot history, so a render whose
   // (value, selection) lags the acknowledged native state is stamped behind
   // the native revision and rejected by the editor instead of re-applying a
```

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

Status: modified; +37; −10

```diff
@@ -6,12 +6,13 @@
   MarkdownStyle,
 } from "react-native-enriched-markdown";
 import type { ComposerContextRecord } from "@t3tools/contracts";
-import { contextChipPresentation } from "../lib/composerChipPresentation";
+import { contextChipAccent, contextChipPresentation } from "../lib/composerChipPresentation";
 import { CONTEXT_CHIP_ICON_SOURCES } from "../lib/contextChipIcons.generated";
 import { FONT_FAMILIES } from "../lib/fontFamilies";
 import { contextChipIconSource } from "../lib/contextChipIconSource";
 import { markdownFileIconSource } from "../lib/markdownFileIcons";
 import { markdownLinkIconSource } from "../lib/markdownLinkIcons";
+import { useUniwindTheme } from "../lib/useUniwindTheme";
 import { resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation } from "../lib/markdownLinks";
 import type {
   MarkdownLinkCustomization,
@@ -22,19 +23,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 iconUri && customization?.chip
-  return {
+    ? {
+        ...customization,
+        icon: { uri: iconUri },
+        chip: { ...customization.chip, tintsIcon: false },
+      }
+    : customization;
-    label: context.label,
-    icon: contextChipIconSource(context.icon),
-    chip: { accent: context.accent, tintsIcon: context.icon.kind === "chip" },
-  };
 }
 
 function presentation(
@@ -74,6 +98,7 @@
   props: SelectableMarkdownTextProps,
 ) {
   const { linkCustomization, skills, textStyle } = props;
+  const theme = useUniwindTheme();
   const links = useMemo(() => {
     const byUrl = new Map<string, MarkdownLinkCustomization>();
     for (const asset of assets) {
@@ -88,10 +113,12 @@
       new Map(
         [...links].map(([url, link]) => [
           url,
+          link.chip
+            ? (contextChipAccent(link.chip.accent, theme, textStyle.color) ?? textStyle.color)
+            : undefined,
-          link.chip ? (link.chip.accent ?? textStyle.color) : undefined,
         ]),
       ),
-    [links, textStyle],
+    [links, textStyle, theme],
   );
   const linkVariants = useMemo<MarkdownStyle["linkVariants"]>(
     () =>
```

## 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 {
```

## vite.config.ts

Status: modified; +1; −0

```diff
@@ -537,6 +537,7 @@
           "apps/mobile/src/native/T3ComposerEditor.ios.tsx",
           "apps/mobile/src/native/T3ComposerEditor.native.tsx",
           "apps/mobile/src/native/SelectableMarkdownText.tsx",
+          "apps/mobile/src/native/useEnrichedLinkVariants.ts",
         ],
         rules: {
           "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }],
```
