authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-25 07:01:48-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 16:31:48+05:30
log1c1270663880903b16568594c4bf5f322407903a
tree07d613f1ed06fa9f7dfbb73d5849c0dea1b7b85d
parente55e7315e4a4c3870711acd6825ea5347c2d4812
signature Signed by PGP key B5690EEEBB952194

fix(mobile): render assigned project icons in chat list (#12810)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

6 files changed, 158 insertions(+), 7 deletions(-)

apps/mobile/src/components/ProjectFavicon.tsx+66-3
......@@ -1,8 +1,9 @@
11import { SymbolView } from "./AppSymbol";
2import { AppText } from "./AppText";
23import { Image } from "expo-image";
34import { useLayoutEffect, useMemo, useState } from "react";
45import { View } from "react-native";
5import type { EnvironmentId } from "@t3tools/contracts";
6import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts";
67import {
78 getProjectFaviconCacheKey,
89 getProjectFaviconResourceKey,
......@@ -11,6 +12,12 @@ import {
1112import { useAtomValue } from "@effect/atom-react";
1213import { Atom } from "effect/unstable/reactivity";
1314import { projectFaviconUrlAtom } from "../state/assets";
15import {
16 countGlyphs,
17 projectIconColorClassNames,
18 resolveProjectIconGlyph,
19 type ProjectIconGlyph,
20} from "../lib/projectIcon";
1421
1522import {
1623 beginProjectFaviconRequest,
......@@ -30,10 +37,12 @@ export function ProjectFavicon(props: {
3037 readonly projectTitle: string;
3138 readonly workspaceRoot?: string | null;
3239 readonly faviconPath?: string | null;
40 readonly projectIcon?: ProjectIconOverride | null;
3341}) {
3442 const size = props.size ?? 42;
43 const glyph = resolveProjectIconGlyph(props.projectIcon, props.projectTitle);
3544 const faviconUrl = useAtomValue(
36 props.workspaceRoot == null
45 props.workspaceRoot == null || glyph !== null
3746 ? EMPTY_FAVICON_URL
3847 : projectFaviconUrlAtom({
3948 environmentId: props.environmentId,
......@@ -51,6 +60,10 @@ export function ProjectFavicon(props: {
5160 : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
5261 : null;
5362
63 if (glyph !== null) {
64 return <ProjectIconGlyphView glyph={glyph} size={size} />;
65 }
66
5467 return (
5568 <ProjectFaviconImage
5669 key={cacheKey}
......@@ -63,6 +76,56 @@ export function ProjectFavicon(props: {
6376 );
6477}
6578
79function ProjectIconGlyphView(props: { readonly glyph: ProjectIconGlyph; readonly size: number }) {
80 const { glyph, size } = props;
81 if (glyph.kind === "emoji") {
82 return (
83 <View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
84 <AppText
85 allowFontScaling={false}
86 style={{
87 fontSize: size * 0.8,
88 lineHeight: size,
89 textAlign: "center",
90 includeFontPadding: false,
91 }}
92 >
93 {glyph.emoji}
94 </AppText>
95 </View>
96 );
97 }
98
99 const colors = projectIconColorClassNames(glyph.color);
100 return (
101 <View
102 className={colors.background}
103 style={{
104 width: size,
105 height: size,
106 borderRadius: size * 0.25,
107 alignItems: "center",
108 justifyContent: "center",
109 }}
110 >
111 <AppText
112 allowFontScaling={false}
113 numberOfLines={1}
114 className={`font-mono ${colors.text}`}
115 style={{
116 fontWeight: "700",
117 fontSize: size * (countGlyphs(glyph.text) === 1 ? 0.6 : 0.515625),
118 lineHeight: size,
119 textAlign: "center",
120 includeFontPadding: false,
121 }}
122 >
123 {glyph.text}
124 </AppText>
125 </View>
126 );
127}
128
66129function ProjectFaviconImage(props: {
67130 readonly cacheKey: string | null;
68131 readonly faviconUrl: string | null;
......@@ -105,7 +168,7 @@ function ProjectFaviconImage(props: {
105168 {!showImage ? (
106169 <SymbolView
107170 name={{ ios: "folder.fill", android: props.open ? "folder_open" : "folder" }}
108 size={props.size * 0.78}
171 size={props.size}
109172 tintColorClassName={"accent-icon-subtle"}
110173 type="monochrome"
111174 />
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+1
......@@ -151,6 +151,7 @@ function ProjectGroupLabel(props: {
151151 <ProjectFavicon
152152 environmentId={props.project.environmentId}
153153 faviconPath={props.project.faviconPath}
154 projectIcon={props.project.projectIcon}
154155 projectTitle={props.project.title}
155156 size={18}
156157 workspaceRoot={props.project.workspaceRoot}
apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx+2-1
......@@ -96,9 +96,10 @@ function ProjectOverviewContent(props: {
9696 <View className="flex-row items-center gap-4 px-2">
9797 <ProjectFavicon
9898 environmentId={representative.environmentId}
99 projectTitle={displayName}
99 projectTitle={representative.title}
100100 workspaceRoot={representative.workspaceRoot}
101101 faviconPath={representative.faviconPath}
102 projectIcon={representative.projectIcon}
102103 size={48}
103104 />
104105 <View className="min-w-0 flex-1">
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+2
......@@ -333,6 +333,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
333333 <ProjectFavicon
334334 environmentId={scope.representative.environmentId}
335335 faviconPath={scope.representative.faviconPath}
336 projectIcon={scope.representative.projectIcon}
336337 size={24}
337338 projectTitle={scope.title}
338339 workspaceRoot={scope.representative.workspaceRoot}
......@@ -357,6 +358,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
357358 <ProjectFavicon
358359 environmentId={scope.representative.environmentId}
359360 faviconPath={scope.representative.faviconPath}
361 projectIcon={scope.representative.projectIcon}
360362 size={20}
361363 projectTitle={scope.title}
362364 workspaceRoot={scope.representative.workspaceRoot}
apps/mobile/src/features/threads/thread-list-v2-items.tsx+6-3
......@@ -294,8 +294,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
294294 <ProjectFavicon
295295 environmentId={pendingTask.environmentId}
296296 faviconPath={props.project.faviconPath}
297 projectIcon={props.project.projectIcon}
297298 size={15}
298 projectTitle={projectTitle}
299 projectTitle={props.project.title}
299300 workspaceRoot={props.project.workspaceRoot}
300301 />
301302 ) : null}
......@@ -890,8 +891,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
890891 <ProjectFavicon
891892 environmentId={thread.environmentId}
892893 faviconPath={props.project.faviconPath}
894 projectIcon={props.project.projectIcon}
893895 size={15}
894 projectTitle={props.projectTitle ?? props.project.title}
896 projectTitle={props.project.title}
895897 workspaceRoot={props.project.workspaceRoot}
896898 />
897899 ) : null}
......@@ -1124,8 +1126,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
11241126 <ProjectFavicon
11251127 environmentId={thread.environmentId}
11261128 faviconPath={props.project.faviconPath}
1129 projectIcon={props.project.projectIcon}
11271130 size={15}
1128 projectTitle={props.projectTitle ?? props.project.title}
1131 projectTitle={props.project.title}
11291132 workspaceRoot={props.project.workspaceRoot}
11301133 />
11311134 </View>
apps/mobile/src/lib/projectIcon.ts created+81
......@@ -0,0 +1,81 @@
1import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
2
3export type ProjectIconGlyph =
4 | { readonly kind: "emoji"; readonly emoji: string }
5 | { readonly kind: "monogram"; readonly text: string; readonly color: ProjectIconColor };
6
7/**
8 * Visible glyph count for sizing monogram text. Hermes has no Intl.Segmenter, so combining
9 * marks are folded into their base character instead of full grapheme segmentation.
10 */
11export function countGlyphs(text: string): number {
12 return Array.from(text.replace(/\p{M}/gu, "")).length;
13}
14
15/** Mirrors the automatic monogram web derives from a project name when it has no favicon. */
16export function projectMonogram(projectName: string): string {
17 const words =
18 projectName
19 .normalize("NFKC")
20 .trim()
21 .match(/[\p{L}\p{N}]+/gu) ?? [];
22 const firstWord = words[0];
23 if (!firstWord) return "PR";
24
25 const glyphs = Array.from(firstWord);
26 const first = glyphs[0] ?? "P";
27 const second =
28 glyphs.slice(1).find((glyph) => /\p{N}/u.test(glyph)) ??
29 (words.length > 1 ? Array.from(words.at(-1) ?? "")[0] : glyphs.at(-1)) ??
30 first;
31 return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join("");
32}
33
34/**
35 * Picks what mobile draws for an assigned project icon. Mobile does not bundle
36 * the Lucide set, so a Lucide override keeps its color and falls back to the
37 * project's monogram instead of the folder glyph.
38 */
39export function resolveProjectIconGlyph(
40 projectIcon: ProjectIconOverride | null | undefined,
41 projectTitle: string,
42): ProjectIconGlyph | null {
43 switch (projectIcon?.kind) {
44 case "emoji":
45 return { kind: "emoji", emoji: projectIcon.emoji };
46 case "monogram":
47 return { kind: "monogram", text: projectIcon.text, color: projectIcon.color };
48 case "lucide":
49 return { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color };
50 case undefined:
51 return null;
52 }
53}
54
55const PROJECT_ICON_COLOR_CLASSES: Record<
56 ProjectIconColor,
57 { readonly text: string; readonly background: string }
58> = {
59 gray: { text: "text-gray-500", background: "bg-gray-500/15" },
60 red: { text: "text-red-500", background: "bg-red-500/15" },
61 orange: { text: "text-orange-500", background: "bg-orange-500/15" },
62 amber: { text: "text-amber-500", background: "bg-amber-500/15" },
63 yellow: { text: "text-yellow-500", background: "bg-yellow-500/15" },
64 lime: { text: "text-lime-500", background: "bg-lime-500/15" },
65 green: { text: "text-green-500", background: "bg-green-500/15" },
66 emerald: { text: "text-emerald-500", background: "bg-emerald-500/15" },
67 teal: { text: "text-teal-500", background: "bg-teal-500/15" },
68 cyan: { text: "text-cyan-500", background: "bg-cyan-500/15" },
69 sky: { text: "text-sky-500", background: "bg-sky-500/15" },
70 blue: { text: "text-blue-500", background: "bg-blue-500/15" },
71 indigo: { text: "text-indigo-500", background: "bg-indigo-500/15" },
72 violet: { text: "text-violet-500", background: "bg-violet-500/15" },
73 purple: { text: "text-purple-500", background: "bg-purple-500/15" },
74 fuchsia: { text: "text-fuchsia-500", background: "bg-fuchsia-500/15" },
75 pink: { text: "text-pink-500", background: "bg-pink-500/15" },
76 rose: { text: "text-rose-500", background: "bg-rose-500/15" },
77};
78
79export function projectIconColorClassNames(color: ProjectIconColor) {
80 return PROJECT_ICON_COLOR_CLASSES[color];
81}