Skip to content
Open
2 changes: 2 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -108,6 +109,7 @@ function AppContent() {
</IncomingShareProvider>
<Dialog.Host />
<ThreadArrangementHost />
<ProjectChipIconRasterizer />
</NativeLayoutMetricsProvider>
</GlobalVoiceInputControl>
{/* Anchored-menu overlays render here — in-window, so the
Expand Down
25 changes: 20 additions & 5 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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") {
Expand All @@ -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}
/>
);
Expand Down Expand Up @@ -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})`;
Expand Down Expand Up @@ -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 ? (
Expand Down Expand Up @@ -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) => {
Expand Down
49 changes: 49 additions & 0 deletions apps/mobile/src/lib/enrichedLinkPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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([]);
});
});
51 changes: 49 additions & 2 deletions apps/mobile/src/lib/enrichedLinkPresentation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ComposerContextRecord } from "@t3tools/contracts";
import type { ComposerContextRecord, EnvironmentId, ThreadId } from "@t3tools/contracts";
import {
composerChipSizeSuffix,
contextChipIcon,
Expand All @@ -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,
Expand All @@ -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<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, "\\$&")}$`;
}
Expand Down
59 changes: 59 additions & 0 deletions apps/mobile/src/lib/projectIcon.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
36 changes: 35 additions & 1 deletion apps/mobile/src/lib/projectIcon.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -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,
Expand All @@ -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 }
Expand Down
Loading
Loading