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 @@...@@ -1,8 +1,9 @@
1import { SymbolView } from "./AppSymbol";1import { SymbolView } from "./AppSymbol";
2import { AppText } from "./AppText";
2import { Image } from "expo-image";3import { Image } from "expo-image";
3import { useLayoutEffect, useMemo, useState } from "react";4import { useLayoutEffect, useMemo, useState } from "react";
4import { View } from "react-native";5import { View } from "react-native";
5import type { EnvironmentId } from "@t3tools/contracts";6import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts";
6import {7import {
7 getProjectFaviconCacheKey,8 getProjectFaviconCacheKey,
8 getProjectFaviconResourceKey,9 getProjectFaviconResourceKey,
...@@ -11,6 +12,12 @@ import {...@@ -11,6 +12,12 @@ import {
11import { useAtomValue } from "@effect/atom-react";12import { useAtomValue } from "@effect/atom-react";
12import { Atom } from "effect/unstable/reactivity";13import { Atom } from "effect/unstable/reactivity";
13import { projectFaviconUrlAtom } from "../state/assets";14import { projectFaviconUrlAtom } from "../state/assets";
15import {
16 countGlyphs,
17 projectIconColorClassNames,
18 resolveProjectIconGlyph,
19 type ProjectIconGlyph,
20} from "../lib/projectIcon";
1421
15import {22import {
16 beginProjectFaviconRequest,23 beginProjectFaviconRequest,
...@@ -30,10 +37,12 @@ export function ProjectFavicon(props: {...@@ -30,10 +37,12 @@ export function ProjectFavicon(props: {
30 readonly projectTitle: string;37 readonly projectTitle: string;
31 readonly workspaceRoot?: string | null;38 readonly workspaceRoot?: string | null;
32 readonly faviconPath?: string | null;39 readonly faviconPath?: string | null;
40 readonly projectIcon?: ProjectIconOverride | null;
33}) {41}) {
34 const size = props.size ?? 42;42 const size = props.size ?? 42;
43 const glyph = resolveProjectIconGlyph(props.projectIcon, props.projectTitle);
35 const faviconUrl = useAtomValue(44 const faviconUrl = useAtomValue(
36 props.workspaceRoot == null45 props.workspaceRoot == null || glyph !== null
37 ? EMPTY_FAVICON_URL46 ? EMPTY_FAVICON_URL
38 : projectFaviconUrlAtom({47 : projectFaviconUrlAtom({
39 environmentId: props.environmentId,48 environmentId: props.environmentId,
...@@ -51,6 +60,10 @@ export function ProjectFavicon(props: {...@@ -51,6 +60,10 @@ export function ProjectFavicon(props: {
51 : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)60 : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
52 : null;61 : null;
5362
63 if (glyph !== null) {
64 return <ProjectIconGlyphView glyph={glyph} size={size} />;
65 }
66
54 return (67 return (
55 <ProjectFaviconImage68 <ProjectFaviconImage
56 key={cacheKey}69 key={cacheKey}
...@@ -63,6 +76,56 @@ export function ProjectFavicon(props: {...@@ -63,6 +76,56 @@ export function ProjectFavicon(props: {
63 );76 );
64}77}
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
66function ProjectFaviconImage(props: {129function ProjectFaviconImage(props: {
67 readonly cacheKey: string | null;130 readonly cacheKey: string | null;
68 readonly faviconUrl: string | null;131 readonly faviconUrl: string | null;
...@@ -105,7 +168,7 @@ function ProjectFaviconImage(props: {...@@ -105,7 +168,7 @@ function ProjectFaviconImage(props: {
105 {!showImage ? (168 {!showImage ? (
106 <SymbolView169 <SymbolView
107 name={{ ios: "folder.fill", android: props.open ? "folder_open" : "folder" }}170 name={{ ios: "folder.fill", android: props.open ? "folder_open" : "folder" }}
108 size={props.size * 0.78}171 size={props.size}
109 tintColorClassName={"accent-icon-subtle"}172 tintColorClassName={"accent-icon-subtle"}
110 type="monochrome"173 type="monochrome"
111 />174 />
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+1
...@@ -151,6 +151,7 @@ function ProjectGroupLabel(props: {...@@ -151,6 +151,7 @@ function ProjectGroupLabel(props: {
151 <ProjectFavicon151 <ProjectFavicon
152 environmentId={props.project.environmentId}152 environmentId={props.project.environmentId}
153 faviconPath={props.project.faviconPath}153 faviconPath={props.project.faviconPath}
154 projectIcon={props.project.projectIcon}
154 projectTitle={props.project.title}155 projectTitle={props.project.title}
155 size={18}156 size={18}
156 workspaceRoot={props.project.workspaceRoot}157 workspaceRoot={props.project.workspaceRoot}
apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx+2-1
...@@ -96,9 +96,10 @@ function ProjectOverviewContent(props: {...@@ -96,9 +96,10 @@ function ProjectOverviewContent(props: {
96 <View className="flex-row items-center gap-4 px-2">96 <View className="flex-row items-center gap-4 px-2">
97 <ProjectFavicon97 <ProjectFavicon
98 environmentId={representative.environmentId}98 environmentId={representative.environmentId}
99 projectTitle={displayName}99 projectTitle={representative.title}
100 workspaceRoot={representative.workspaceRoot}100 workspaceRoot={representative.workspaceRoot}
101 faviconPath={representative.faviconPath}101 faviconPath={representative.faviconPath}
102 projectIcon={representative.projectIcon}
102 size={48}103 size={48}
103 />104 />
104 <View className="min-w-0 flex-1">105 <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...@@ -333,6 +333,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
333 <ProjectFavicon333 <ProjectFavicon
334 environmentId={scope.representative.environmentId}334 environmentId={scope.representative.environmentId}
335 faviconPath={scope.representative.faviconPath}335 faviconPath={scope.representative.faviconPath}
336 projectIcon={scope.representative.projectIcon}
336 size={24}337 size={24}
337 projectTitle={scope.title}338 projectTitle={scope.title}
338 workspaceRoot={scope.representative.workspaceRoot}339 workspaceRoot={scope.representative.workspaceRoot}
...@@ -357,6 +358,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -357,6 +358,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
357 <ProjectFavicon358 <ProjectFavicon
358 environmentId={scope.representative.environmentId}359 environmentId={scope.representative.environmentId}
359 faviconPath={scope.representative.faviconPath}360 faviconPath={scope.representative.faviconPath}
361 projectIcon={scope.representative.projectIcon}
360 size={20}362 size={20}
361 projectTitle={scope.title}363 projectTitle={scope.title}
362 workspaceRoot={scope.representative.workspaceRoot}364 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...@@ -294,8 +294,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
294 <ProjectFavicon294 <ProjectFavicon
295 environmentId={pendingTask.environmentId}295 environmentId={pendingTask.environmentId}
296 faviconPath={props.project.faviconPath}296 faviconPath={props.project.faviconPath}
297 projectIcon={props.project.projectIcon}
297 size={15}298 size={15}
298 projectTitle={projectTitle}299 projectTitle={props.project.title}
299 workspaceRoot={props.project.workspaceRoot}300 workspaceRoot={props.project.workspaceRoot}
300 />301 />
301 ) : null}302 ) : null}
...@@ -890,8 +891,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -890,8 +891,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
890 <ProjectFavicon891 <ProjectFavicon
891 environmentId={thread.environmentId}892 environmentId={thread.environmentId}
892 faviconPath={props.project.faviconPath}893 faviconPath={props.project.faviconPath}
894 projectIcon={props.project.projectIcon}
893 size={15}895 size={15}
894 projectTitle={props.projectTitle ?? props.project.title}896 projectTitle={props.project.title}
895 workspaceRoot={props.project.workspaceRoot}897 workspaceRoot={props.project.workspaceRoot}
896 />898 />
897 ) : null}899 ) : null}
...@@ -1124,8 +1126,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -1124,8 +1126,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
1124 <ProjectFavicon1126 <ProjectFavicon
1125 environmentId={thread.environmentId}1127 environmentId={thread.environmentId}
1126 faviconPath={props.project.faviconPath}1128 faviconPath={props.project.faviconPath}
1129 projectIcon={props.project.projectIcon}
1127 size={15}1130 size={15}
1128 projectTitle={props.projectTitle ?? props.project.title}1131 projectTitle={props.project.title}
1129 workspaceRoot={props.project.workspaceRoot}1132 workspaceRoot={props.project.workspaceRoot}
1130 />1133 />
1131 </View>1134 </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}