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 ( + + + {props.tile.glyph.kind === "emoji" ? ( + + {props.tile.glyph.emoji} + + ) : ( + + )} + + + ); +} + +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 {