diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index bfe5d1774a6b..a82301c2f8e6 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -9,6 +9,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
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 @@ function AppContent() {
+
{/* Anchored-menu overlays render here — in-window, so the
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index 9ced36004731..6b636c3167f3 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -4,7 +4,11 @@ import { useOpenPullRequest } from "../../native/useOpenPullRequest";
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,
@@ -230,7 +234,7 @@ import {
pickWorkspaceBasenameMatch,
WORKSPACE_BASENAME_LOOKUP_LIMIT,
} from "@t3tools/client-runtime/workspace-basename-lookup";
-import { useLiveThreadLinkLabels } from "../../state/entities";
+import { useLiveThreadLinkLabels, useThreadChipIcons } 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 AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
}) {
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 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
markdown={markdown}
skills={props.skills}
textStyle={props.markdownStyles.nativeTextStyle}
- {...props.linkHandlers}
+ {...linkHandlers}
renderImage={props.renderImage}
/>
);
@@ -1491,6 +1501,7 @@ function UserMessageContent(props: UserMessageContentProps) {
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 @@ function UserMessageContent(props: UserMessageContentProps) {
}
},
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 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
});
const markdownLinkHandlers: MarkdownLinkHandlers = {
onLinkPress: onMarkdownLinkPress,
+ linkCustomization: enrichedThreadLinkCustomization,
onImagePress: (href) => onMarkdownLinkPress(href, true),
resolveImageSource,
fileContextMenu: (href) => {
diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts
index 3ceb536926df..89155b11a3cc 100644
--- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts
+++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts
@@ -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 @@ describe("Enriched link presentation", () => {
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([]);
+ });
});
diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts
index db16d0181591..b93841ad8f8e 100644
--- a/apps/mobile/src/lib/enrichedLinkPresentation.ts
+++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts
@@ -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 @@ import {
UNAVAILABLE_CONTEXT_CHIP_PRESENTATION,
} from "./composerChipPresentation";
import type { SelectableMarkdownSkill } from "../native/SelectableMarkdownText.types";
-import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";
+import {
+ formatComposerContextHref,
+ parseComposerContextHref,
+} from "@t3tools/shared/composerContextReferences";
+import { hasThreadLinks, parseThreadLinkHref } from "@t3tools/shared/threadLinks";
export function enrichedContextLinkPresentation(
href: string,
@@ -29,6 +33,49 @@ export function enrichedContextLinkPresentation(
};
}
+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,
+): ReadonlyArray {
+ const targets = new Map();
+ 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, "\\$&")}$`;
}
diff --git a/apps/mobile/src/lib/projectIcon.test.ts b/apps/mobile/src/lib/projectIcon.test.ts
new file mode 100644
index 000000000000..d249550a9f97
--- /dev/null
+++ b/apps/mobile/src/lib/projectIcon.test.ts
@@ -0,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" });
+ });
+});
diff --git a/apps/mobile/src/lib/projectIcon.ts b/apps/mobile/src/lib/projectIcon.ts
index 525e0d253e6d..2ad426acffcf 100644
--- a/apps/mobile/src/lib/projectIcon.ts
+++ b/apps/mobile/src/lib/projectIcon.ts
@@ -1,4 +1,6 @@
-import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
+import { isScratchProject } from "@t3tools/client-runtime/state/projects";
+import { ProjectIconColor, type ProjectIconOverride } from "@t3tools/contracts";
+import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
export type ProjectIconGlyph =
| { readonly kind: "emoji"; readonly emoji: string }
@@ -30,6 +32,15 @@ export function projectMonogram(projectName: string): string {
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 function resolveProjectIconGlyph(
}
}
+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 }
diff --git a/apps/mobile/src/native/ProjectChipIconImages.tsx b/apps/mobile/src/native/ProjectChipIconImages.tsx
new file mode 100644
index 000000000000..25038801bf2f
--- /dev/null
+++ b/apps/mobile/src/native/ProjectChipIconImages.tsx
@@ -0,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;
+ readonly resolve: (uri: string) => void;
+ readonly reject: () => void;
+}
+
+const tiles = new Map();
+let pendingTiles: ReadonlyArray = [];
+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 {
+ const existing = tiles.get(key);
+ if (existing) return existing.promise;
+ let resolve!: (uri: string) => void;
+ let reject!: () => void;
+ const promise = new Promise((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) => );
+}
+
+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 (
+
+
+
+ );
+}
+
+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 (
+ <>
+
+
+ {props.text}
+
+ >
+ );
+}
diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts
index 3e4a0f92b7cf..7266cc0d0e37 100644
--- a/apps/mobile/src/native/useEnrichedLinkVariants.ts
+++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts
@@ -22,19 +22,42 @@ import {
enrichedLinkVariantPattern,
enrichedSkillDisplayName,
enrichedSkillReference,
+ enrichedThreadLinkPresentation,
} from "../lib/enrichedLinkPresentation";
+function chipCustomization(
+ chip: ReturnType,
+): 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,
+) {
+ 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),
- chip: { accent: context.accent, tintsIcon: context.icon.kind === "chip" },
- };
+ return iconUri && customization?.chip
+ ? {
+ ...customization,
+ icon: { uri: iconUri },
+ chip: { ...customization.chip, tintsIcon: false },
+ }
+ : customization;
}
function presentation(
diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts
index a85a6fa09ded..a50cb9e50200 100644
--- a/apps/mobile/src/state/entities.ts
+++ b/apps/mobile/src/state/entities.ts
@@ -7,17 +7,29 @@ import type {
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 { Atom } from "effect/reactivity";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import {
+ 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 @@ const threadTitlesAtom = Atom.family((environmentId: EnvironmentId) => {
}).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 = 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();
+ 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 useLiveThreadLinkLabels(markdown: string, environmentId: Environ
);
}
+export function useThreadChipIcons(
+ environmentId: EnvironmentId,
+ markdown: string,
+ records?: ReadonlyArray,
+): ReadonlyMap {
+ 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 {