authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-08 03:29:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 03:29:46-07:00
logeb115063634c416c6362cc407f8572cb0c136ddf
treea4c4f1b0c0fb52f1a9b6e282075446e3c2b26428
parentd7a59c63c6a227ac295041660228a4aaa427ead0
signature Signed by PGP key B5690EEEBB952194

refactor(web): pass the project record to ProjectFavicon so icons cannot drift (#10714)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

12 files changed, 126 insertions(+), 342 deletions(-)

apps/web/src/components/ChatView.tsx+1-4
......@@ -8093,10 +8093,7 @@ export default function ChatView(props: ChatViewProps) {
80938093 {...(routeKind === "draft" && draftId ? { draftId } : {})}
80948094 activeThreadTitle={activeThread.title}
80958095 isServerThread={isServerThread}
8096 activeProjectName={activeProject?.title}
8097 activeProjectCwd={activeProject?.workspaceRoot ?? null}
8098 activeProjectFaviconPath={activeProject?.faviconPath ?? null}
8099 activeProjectIcon={activeProject?.projectIcon ?? null}
8096 activeProject={activeProject}
81008097 openInCwd={gitCwd}
81018098 activeProjectScripts={activeProjectScripts}
81028099 preferredScriptId={
apps/web/src/components/CommandPalette.tsx+5-31
......@@ -178,16 +178,7 @@ import type { Project } from "../types";
178178const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];
179179
180180function projectFavicon(project: Project) {
181 return (
182 <ProjectFavicon
183 environmentId={project.environmentId}
184 cwd={project.workspaceRoot}
185 projectName={project.title}
186 faviconPath={project.faviconPath}
187 projectIcon={project.projectIcon}
188 className="size-4"
189 />
190 );
181 return <ProjectFavicon project={project} className="size-4" />;
191182}
192183
193184function getEnvironmentBrowsePlatform(os: string | null | undefined): string {
......@@ -927,18 +918,8 @@ function OpenCommandPaletteDialog(props: {
927918 new Map<ProjectId, string>(projects.map((project) => [project.id, project.workspaceRoot])),
928919 [projects],
929920 );
930 const projectFaviconPathById = useMemo(
931 () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)),
932 [projects],
933 );
934 const projectIconByKey = useMemo(
935 () =>
936 new Map(
937 projects.map(
938 (project) =>
939 [`${project.environmentId}:${project.id}`, project.projectIcon ?? null] as const,
940 ),
941 ),
921 const projectByKey = useMemo(
922 () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])),
942923 [projects],
943924 );
944925 const projectTitleById = useMemo(
......@@ -1174,12 +1155,7 @@ function OpenCommandPaletteDialog(props: {
11741155 ) ?? null;
11751156 return (
11761157 <ThreadCommandSubtitle
1177 environmentId={thread.environmentId}
1178 projectCwd={projectCwdById.get(thread.projectId) ?? null}
1179 projectFaviconPath={projectFaviconPathById.get(thread.projectId) ?? null}
1180 projectIcon={
1181 projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
1182 }
1158 project={projectByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null}
11831159 projectTitle={projectTitle ?? null}
11841160 branch={thread.branch}
11851161 worktreePath={thread.worktreePath}
......@@ -1217,9 +1193,7 @@ function OpenCommandPaletteDialog(props: {
12171193 activeThreadId,
12181194 clientSettings.sidebarThreadSortOrder,
12191195 navigate,
1220 projectCwdById,
1221 projectFaviconPathById,
1222 projectIconByKey,
1196 projectByKey,
12231197 projectTitleById,
12241198 providerEntryByEnvironmentAndInstanceId,
12251199 threadContentMatchByKey,
apps/web/src/components/LegacySidebar.tsx+1-7
......@@ -2322,13 +2322,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
23222322 />
23232323 )}
23242324 <span className="flex shrink-0">
2325 <ProjectFavicon
2326 environmentId={project.environmentId}
2327 cwd={project.workspaceRoot}
2328 projectName={project.title}
2329 faviconPath={project.faviconPath}
2330 projectIcon={project.projectIcon}
2331 />
2325 <ProjectFavicon project={project} />
23322326 </span>
23332327 <span className="flex min-w-0 flex-1 items-center gap-2">
23342328 <span className="truncate text-sm font-medium text-sidebar-foreground/90">
apps/web/src/components/ProjectFavicon.test.tsx+28-24
......@@ -66,7 +66,14 @@ vi.mock("../state/assets", () => ({
6666 },
6767}));
6868
69import { ProjectFavicon } from "./ProjectFavicon";
69import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
70
71function makeProject(
72 overrides: Partial<ProjectFaviconProject> &
73 Pick<ProjectFaviconProject, "workspaceRoot" | "title">,
74): ProjectFaviconProject {
75 return { environmentId: "environment-test" as EnvironmentId, ...overrides };
76}
7077
7178type ProjectFaviconImageProps = {
7279 readonly cacheKey: string;
......@@ -91,9 +98,7 @@ function resolveImageComponent(): {
9198} {
9299 hooks.beginRender();
93100 const element = ProjectFavicon({
94 environmentId: "environment-test" as EnvironmentId,
95 cwd: "/workspace-test",
96 projectName: "workspace-test",
101 project: makeProject({ workspaceRoot: "/workspace-test", title: "workspace-test" }),
97102 }) as ReactElement<ProjectFaviconImageProps>;
98103 hooks.reset();
99104
......@@ -121,9 +126,7 @@ describe("ProjectFavicon", () => {
121126 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
122127
123128 const element = ProjectFavicon({
124 environmentId: "environment-test" as EnvironmentId,
125 cwd: "/workspace/analytics-db",
126 projectName: "analytics-db",
129 project: makeProject({ workspaceRoot: "/workspace/analytics-db", title: "analytics-db" }),
127130 }) as ReactElement<{
128131 readonly colorClassName?: string;
129132 readonly emoji?: string;
......@@ -139,9 +142,7 @@ describe("ProjectFavicon", () => {
139142 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
140143
141144 const element = ProjectFavicon({
142 environmentId: "environment-test" as EnvironmentId,
143 cwd: "/workspace/agent-runtime",
144 projectName: "agent-runtime",
145 project: makeProject({ workspaceRoot: "/workspace/agent-runtime", title: "agent-runtime" }),
145146 }) as ReactElement<{
146147 readonly colorClassName?: string;
147148 readonly emoji?: string;
......@@ -155,11 +156,12 @@ describe("ProjectFavicon", () => {
155156
156157 it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => {
157158 const element = ProjectFavicon({
158 environmentId: "environment-test" as EnvironmentId,
159 cwd: "/workspace/test",
160 projectName: "test",
161 faviconPath: "brand/icon.svg",
162 projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" },
159 project: makeProject({
160 workspaceRoot: "/workspace/test",
161 title: "test",
162 faviconPath: "brand/icon.svg",
163 projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" },
164 }),
163165 }) as ReactElement<{
164166 readonly children: ReactElement<{
165167 readonly children: ReactElement<{ readonly name: string; readonly className: string }>;
......@@ -174,11 +176,12 @@ describe("ProjectFavicon", () => {
174176
175177 it("renders a saved emoji ahead of an uploaded favicon", () => {
176178 const element = ProjectFavicon({
177 environmentId: "environment-test" as EnvironmentId,
178 cwd: "/workspace/test",
179 projectName: "test",
180 faviconPath: "brand/icon.svg",
181 projectIcon: { kind: "emoji", emoji: "🦄" },
179 project: makeProject({
180 workspaceRoot: "/workspace/test",
181 title: "test",
182 faviconPath: "brand/icon.svg",
183 projectIcon: { kind: "emoji", emoji: "🦄" },
184 }),
182185 }) as ReactElement<{ readonly emoji: string }>;
183186
184187 expect(element.props.emoji).toBe("🦄");
......@@ -208,10 +211,11 @@ describe("ProjectFavicon", () => {
208211
209212 it("requests a saved favicon path when one is set", () => {
210213 ProjectFavicon({
211 environmentId: "environment-test" as EnvironmentId,
212 cwd: "/workspace-test",
213 projectName: "workspace-test",
214 faviconPath: "brand/icon.svg",
214 project: makeProject({
215 workspaceRoot: "/workspace-test",
216 title: "workspace-test",
217 faviconPath: "brand/icon.svg",
218 }),
215219 });
216220
217221 expect(testState.lastTarget).toMatchObject({
apps/web/src/components/ProjectFavicon.tsx+31-14
......@@ -1,4 +1,5 @@
1import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
1import type { ProjectIconColor } from "@t3tools/contracts";
2import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
23import {
34 getProjectFaviconResourceKey,
45 isProjectFaviconFallbackUrl,
......@@ -94,21 +95,33 @@ const PROJECT_ICON_COLOR_BY_NAME: Record<ProjectIconName, ProjectIconColor> = {
9495 web: "sky",
9596};
9697
98// The slice of a project that decides its icon. Every surface must pass the
99// project record itself (or a snapshot spread from it) so the saved title, favicon
100// and icon override always travel together. Passing a display label as the title
101// changes the automatic icon, which is how the command palette drifted once.
102export type ProjectFaviconProject = Pick<
103 EnvironmentProject,
104 "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon"
105>;
106
97107export function ProjectFavicon(input: {
98 environmentId: EnvironmentId;
99 cwd: string;
100 projectName: string;
101 faviconPath?: string | null | undefined;
102 projectIcon?: ProjectIconOverride | null | undefined;
108 project: ProjectFaviconProject;
103109 className?: string | undefined;
104110 fallbackIcon?: ComponentType<{ className?: string }>;
105111}) {
106 const src = useAtomValue(projectFaviconUrlAtom(input));
107 if (input.projectIcon?.kind === "emoji") {
108 return <ProjectFaviconFallback className={input.className} emoji={input.projectIcon.emoji} />;
112 const { project } = input;
113 const src = useAtomValue(
114 projectFaviconUrlAtom({
115 environmentId: project.environmentId,
116 cwd: project.workspaceRoot,
117 faviconPath: project.faviconPath,
118 }),
119 );
120 if (project.projectIcon?.kind === "emoji") {
121 return <ProjectFaviconFallback className={input.className} emoji={project.projectIcon.emoji} />;
109122 }
110 if (input.projectIcon?.kind === "lucide") {
111 const colorClassName = projectIconColorClassName(input.projectIcon.color);
123 if (project.projectIcon?.kind === "lucide") {
124 const colorClassName = projectIconColorClassName(project.projectIcon.color);
112125 const iconClassName = cn(
113126 "inline-flex size-3.5 shrink-0 items-center justify-center",
114127 colorClassName,
......@@ -118,7 +131,7 @@ export function ProjectFavicon(input: {
118131 <span aria-hidden="true" className={iconClassName}>
119132 <Suspense fallback={<DynamicProjectIconFallback />}>
120133 <DynamicIcon
121 name={input.projectIcon.name as IconName}
134 name={project.projectIcon.name as IconName}
122135 className={cn("size-full", colorClassName)}
123136 fallback={DynamicProjectIconFallback}
124137 />
......@@ -128,7 +141,7 @@ export function ProjectFavicon(input: {
128141 }
129142 const automaticIconName = input.fallbackIcon
130143 ? null
131 : selectProjectIcon(input.projectName, input.cwd);
144 : selectProjectIcon(project.title, project.workspaceRoot);
132145 const FallbackIcon =
133146 input.fallbackIcon ??
134147 (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined);
......@@ -149,7 +162,11 @@ export function ProjectFavicon(input: {
149162 );
150163 }
151164
152 const cacheKey = getProjectFaviconResourceKey(input.environmentId, input.cwd, input.faviconPath);
165 const cacheKey = getProjectFaviconResourceKey(
166 project.environmentId,
167 project.workspaceRoot,
168 project.faviconPath,
169 );
153170
154171 return (
155172 <ProjectFaviconImage
apps/web/src/components/Sidebar.tsx+37-160
......@@ -131,6 +131,7 @@ import {
131131} from "../threadRoutes";
132132import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";
133133import type { SidebarThreadSummary } from "../types";
134import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
134135import { cn } from "~/lib/utils";
135136import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
136137import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
......@@ -193,7 +194,7 @@ import {
193194 snoozeWakeLabel,
194195 type SnoozePreset,
195196} from "./Sidebar.snooze";
196import { ProjectFavicon } from "./ProjectFavicon";
197import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
197198import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
198199import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
199200import {
......@@ -295,11 +296,8 @@ function terminalProcessLabel(count: number): string {
295296
296297function SidebarThreadTooltip({
297298 thread,
298 projectTitle,
299 project,
299300 projectDisplayName,
300 projectCwd,
301 projectFaviconPath,
302 projectIcon,
303301 environmentLabel,
304302 environmentMachine,
305303 providerEntry,
......@@ -311,11 +309,8 @@ function SidebarThreadTooltip({
311309 terminalProcessCount,
312310}: {
313311 thread: SidebarThreadSummary;
314 projectTitle: string | null;
312 project: ProjectFaviconProject | null;
315313 projectDisplayName: string | null;
316 projectCwd: string | null;
317 projectFaviconPath: string | null;
318 projectIcon: ProjectIconOverride | null;
319314 environmentLabel: string | null;
320315 environmentMachine: EnvironmentMachineKind;
321316 providerEntry: ProviderInstanceEntry | null;
......@@ -345,14 +340,7 @@ function SidebarThreadTooltip({
345340 <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground">
346341 {projectDisplayName ? (
347342 <div className="flex min-w-0 items-center gap-2">
348 <ProjectFavicon
349 environmentId={thread.environmentId}
350 cwd={projectCwd ?? ""}
351 projectName={projectTitle ?? ""}
352 faviconPath={projectFaviconPath}
353 projectIcon={projectIcon}
354 className="size-3 shrink-0"
355 />
343 {project ? <ProjectFavicon project={project} className="size-3 shrink-0" /> : null}
356344 <div className="min-w-0 truncate text-foreground/75">{projectDisplayName}</div>
357345 </div>
358346 ) : null}
......@@ -689,11 +677,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
689677 draftId: DraftId;
690678 session: DraftSessionState;
691679 composer: ComposerThreadDraftState;
692 projectTitle: string | null;
680 project: ProjectFaviconProject | null;
693681 projectDisplayName: string | null;
694 projectCwd: string | null;
695 projectFaviconPath: string | null;
696 projectIcon: ProjectIconOverride | null;
697682 isActive: boolean;
698683 onNavigate: (draftId: DraftId) => void;
699684 onDiscard: (draftId: DraftId) => void;
......@@ -751,14 +736,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
751736 <div className="relative z-10 px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
752737 <div className="flex h-5 min-w-0 items-center gap-1.5">
753738 <SquarePenIcon aria-hidden className={draftPenClassName} />
754 <ProjectFavicon
755 environmentId={session.environmentId}
756 cwd={props.projectCwd ?? ""}
757 projectName={props.projectTitle ?? ""}
758 faviconPath={props.projectFaviconPath}
759 projectIcon={props.projectIcon}
760 className="size-4 shrink-0"
761 />
739 {props.project ? (
740 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
741 ) : null}
762742 <span className="min-w-0 flex-1 truncate text-xs font-medium text-secondary-label">
763743 {props.projectDisplayName}
764744 </span>
......@@ -798,11 +778,8 @@ interface SidebarDraftRowData {
798778// subscription + closing divider) so per-keystroke composer updates
799779// re-render only this block, never the whole sidebar. Vanishes at count 0.
800780const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
801 projectTitleByKey: ReadonlyMap<string, string>;
781 projectByKey: ReadonlyMap<string, EnvironmentProject>;
802782 projectDisplayNameByKey: ReadonlyMap<string, string>;
803 projectCwdByKey: ReadonlyMap<string, string>;
804 projectFaviconPathByKey: ReadonlyMap<string, string | null | undefined>;
805 projectIconByKey: ReadonlyMap<string, ProjectIconOverride | null | undefined>;
806783 scopedProjectKeys: ReadonlySet<string> | null;
807784 routeDraftId: string | null;
808785 onNavigateToDraft: (draftId: DraftId) => void;
......@@ -896,11 +873,8 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
896873 draftId={draftId}
897874 session={session}
898875 composer={composer}
899 projectTitle={props.projectTitleByKey.get(projectKey) ?? null}
876 project={props.projectByKey.get(projectKey) ?? null}
900877 projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null}
901 projectCwd={props.projectCwdByKey.get(projectKey) ?? null}
902 projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null}
903 projectIcon={props.projectIconByKey.get(projectKey) ?? null}
904878 isActive={draftId === props.routeDraftId}
905879 onNavigate={props.onNavigateToDraft}
906880 onDiscard={handleDiscard}
......@@ -987,10 +961,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
987961 currentEnvironmentId: string | null;
988962 environmentLabel: string | null;
989963 environmentMachine: EnvironmentMachineKind;
990 projectCwd: string | null;
991 projectFaviconPath: string | null;
992 projectIcon: ProjectIconOverride | null;
993 projectTitle: string | null;
964 project: EnvironmentProject | null;
994965 projectDisplayName: string | null;
995966 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
996967 timestampFormat: TimestampFormat;
......@@ -1061,7 +1032,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
10611032 [clearComposerContent, threadRef],
10621033 );
10631034
1064 const gitCwd = thread.worktreePath ?? props.projectCwd;
1035 const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
10651036 const linkedPullRequestStatus = useLinkedThreadPullRequest(
10661037 thread.environmentId,
10671038 thread.linkedPullRequest ?? thread.branchPullRequest,
......@@ -1197,11 +1168,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11971168 const detailsTooltip = (
11981169 <SidebarThreadTooltip
11991170 thread={thread}
1200 projectTitle={props.projectTitle}
1171 project={props.project}
12011172 projectDisplayName={props.projectDisplayName}
1202 projectCwd={props.projectCwd}
1203 projectFaviconPath={props.projectFaviconPath}
1204 projectIcon={props.projectIcon}
12051173 environmentLabel={props.environmentLabel}
12061174 environmentMachine={props.environmentMachine}
12071175 providerEntry={providerEntry}
......@@ -1569,14 +1537,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
15691537 "opacity-40 grayscale group-hover/sidebar-row:opacity-100 group-hover/sidebar-row:grayscale-0",
15701538 )}
15711539 >
1572 <ProjectFavicon
1573 environmentId={thread.environmentId}
1574 cwd={props.projectCwd ?? ""}
1575 projectName={props.projectTitle ?? ""}
1576 faviconPath={props.projectFaviconPath}
1577 projectIcon={props.projectIcon}
1578 className="size-4"
1579 />
1540 {props.project ? <ProjectFavicon project={props.project} className="size-4" /> : null}
15801541 </span>
15811542 {draftIndicator}
15821543 {title}
......@@ -1722,14 +1683,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
17221683 <div className="relative z-10 h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
17231684 <div className="flex h-5 min-w-0 items-center gap-1.5">
17241685 {draftIndicator}
1725 <ProjectFavicon
1726 environmentId={thread.environmentId}
1727 cwd={props.projectCwd ?? ""}
1728 projectName={props.projectTitle ?? ""}
1729 faviconPath={props.projectFaviconPath}
1730 projectIcon={props.projectIcon}
1731 className="size-4 shrink-0"
1732 />
1686 {props.project ? (
1687 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
1688 ) : null}
17331689 {props.projectDisplayName ? (
17341690 <span
17351691 className={cn(
......@@ -1953,10 +1909,7 @@ function latestTurnDiff(
19531909
19541910const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
19551911 thread: SidebarThreadSummary;
1956 projectCwd: string | null;
1957 projectFaviconPath: string | null;
1958 projectIcon: ProjectIconOverride | null;
1959 projectTitle: string | null;
1912 project: EnvironmentProject | null;
19601913 projectDisplayName: string | null;
19611914 environmentLabel: string | null;
19621915 environmentMachine: EnvironmentMachineKind;
......@@ -1973,7 +1926,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
19731926 );
19741927 // Same details tooltip as the regular rows: a search hit is still a thread,
19751928 // and the hover card is how you disambiguate identically-titled results.
1976 const gitCwd = thread.worktreePath ?? props.projectCwd;
1929 const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
19771930 const gitStatus = useEnvironmentQuery(
19781931 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
19791932 ? vcsEnvironment.status({
......@@ -2039,14 +1992,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
20391992 />
20401993 }
20411994 >
2042 <ProjectFavicon
2043 environmentId={thread.environmentId}
2044 cwd={props.projectCwd ?? ""}
2045 projectName={props.projectTitle ?? ""}
2046 faviconPath={props.projectFaviconPath}
2047 projectIcon={props.projectIcon}
2048 className="size-4 shrink-0"
2049 />
1995 {props.project ? (
1996 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
1997 ) : null}
20501998 <span className="min-w-0 flex-1 truncate">{thread.title}</span>
20511999 <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">
20522000 {threadTimeLabel(thread)}
......@@ -2054,11 +2002,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
20542002 </TooltipTrigger>
20552003 <SidebarThreadTooltip
20562004 thread={thread}
2057 projectTitle={props.projectTitle}
2005 project={props.project}
20582006 projectDisplayName={props.projectDisplayName}
2059 projectCwd={props.projectCwd}
2060 projectFaviconPath={props.projectFaviconPath}
2061 projectIcon={props.projectIcon}
20622007 environmentLabel={props.environmentLabel}
20632008 environmentMachine={props.environmentMachine}
20642009 providerEntry={providerEntry}
......@@ -2265,34 +2210,10 @@ export default function Sidebar() {
22652210 ),
22662211 [serverConfigs],
22672212 );
2268 const projectCwdByKey = useMemo(
2269 () =>
2270 new Map(
2271 projects.map((project) => [
2272 `${project.environmentId}:${project.id}`,
2273 project.workspaceRoot,
2274 ]),
2275 ),
2276 [projects],
2277 );
2278 const projectFaviconPathByKey = useMemo(
2279 () =>
2280 new Map(
2281 projects.map((project) => [`${project.environmentId}:${project.id}`, project.faviconPath]),
2282 ),
2283 [projects],
2284 );
2285 const projectIconByKey = useMemo(
2286 () =>
2287 new Map(
2288 projects.map((project) => [`${project.environmentId}:${project.id}`, project.projectIcon]),
2289 ),
2290 [projects],
2291 );
2292 // Icons use saved titles. Group labels can include a repository owner or a different title.
2293 const projectTitleByKey = useMemo(
2294 () =>
2295 new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])),
2213 // Rows read the project record for its icon and cwd. Group labels can include
2214 // a repository owner or a different title, so they travel separately.
2215 const projectByKey = useMemo(
2216 () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])),
22962217 [projects],
22972218 );
22982219 const projectDisplayNameByKey = useMemo(
......@@ -3912,7 +3833,7 @@ export default function Sidebar() {
39123833 if (!thread) return;
39133834 const threadWorkspacePath =
39143835 thread.worktreePath ??
3915 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
3836 projectByKey.get(`${thread.environmentId}:${thread.projectId}`)?.workspaceRoot ??
39163837 null;
39173838 // Un-settle pins the thread active until real activity clears the pin.
39183839 // Environments without
......@@ -4135,7 +4056,7 @@ export default function Sidebar() {
41354056 handleMultiSelectContextMenu,
41364057 markThreadUnread,
41374058 openProjectSettings,
4138 projectCwdByKey,
4059 projectByKey,
41394060 serverConfigs,
41404061 startThreadRename,
41414062 updateThreadMetadata,
......@@ -4392,14 +4313,7 @@ export default function Sidebar() {
43924313 >
43934314 {scopedProjectGroup ? (
43944315 <span className="flex shrink-0">
4395 <ProjectFavicon
4396 environmentId={scopedProjectGroup.environmentId}
4397 cwd={scopedProjectGroup.workspaceRoot}
4398 projectName={scopedProjectGroup.title}
4399 faviconPath={scopedProjectGroup.faviconPath}
4400 projectIcon={scopedProjectGroup.projectIcon}
4401 className="size-4"
4402 />
4316 <ProjectFavicon project={scopedProjectGroup} className="size-4" />
44034317 </span>
44044318 ) : (
44054319 <FolderIcon className="size-4 shrink-0" />
......@@ -4457,14 +4371,7 @@ export default function Sidebar() {
44574371 }}
44584372 >
44594373 {project ? (
4460 <ProjectFavicon
4461 environmentId={project.environmentId}
4462 cwd={project.workspaceRoot}
4463 projectName={project.title}
4464 faviconPath={project.faviconPath}
4465 projectIcon={project.projectIcon}
4466 className="size-4 shrink-0"
4467 />
4374 <ProjectFavicon project={project} className="size-4 shrink-0" />
44684375 ) : (
44694376 <FolderIcon className="size-4 shrink-0" />
44704377 )}
......@@ -4539,21 +4446,8 @@ export default function Sidebar() {
45394446 <SidebarSearchResultRow
45404447 key={threadKey}
45414448 thread={thread}
4542 projectCwd={
4543 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
4544 }
4545 projectFaviconPath={
4546 projectFaviconPathByKey.get(
4547 `${thread.environmentId}:${thread.projectId}`,
4548 ) ?? null
4549 }
4550 projectIcon={
4551 projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
4552 null
4553 }
4554 projectTitle={
4555 projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
4556 null
4449 project={
4450 projectByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
45574451 }
45584452 projectDisplayName={
45594453 projectDisplayNameByKey.get(
......@@ -4689,24 +4583,10 @@ export default function Sidebar() {
46894583 environmentMachine={
46904584 environmentMachineById.get(thread.environmentId) ?? "server"
46914585 }
4692 projectCwd={
4693 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
4694 null
4695 }
4696 projectFaviconPath={
4697 projectFaviconPathByKey.get(
4698 `${thread.environmentId}:${thread.projectId}`,
4699 ) ?? null
4700 }
4701 projectIcon={
4702 projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
4586 project={
4587 projectByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
47034588 null
47044589 }
4705 projectTitle={
4706 projectTitleByKey.get(
4707 `${thread.environmentId}:${thread.projectId}`,
4708 ) ?? null
4709 }
47104590 projectDisplayName={
47114591 projectDisplayNameByKey.get(
47124592 `${thread.environmentId}:${thread.projectId}`,
......@@ -4758,11 +4638,8 @@ export default function Sidebar() {
47584638 const items: ReactNode[] = [
47594639 <SidebarDraftBlock
47604640 key="draft-sessions"
4761 projectTitleByKey={projectTitleByKey}
4641 projectByKey={projectByKey}
47624642 projectDisplayNameByKey={projectDisplayNameByKey}
4763 projectCwdByKey={projectCwdByKey}
4764 projectFaviconPathByKey={projectFaviconPathByKey}
4765 projectIconByKey={projectIconByKey}
47664643 scopedProjectKeys={scopedProjectKeys}
47674644 routeDraftId={routeDraftIdForRows}
47684645 onNavigateToDraft={navigateToDraft}
apps/web/src/components/ThreadCommandSubtitle.tsx+5-15
......@@ -1,6 +1,6 @@
1import type { EnvironmentId, ProviderDriverKind } from "@t3tools/contracts";
1import type { ProviderDriverKind } from "@t3tools/contracts";
22import { FolderGit2Icon, FolderIcon, GitBranchIcon } from "lucide-react";
3import { ProjectFavicon } from "./ProjectFavicon";
3import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
44import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
55import { cn } from "~/lib/utils";
66
......@@ -34,10 +34,7 @@ function WorkspaceIcon(props: { variant: ThreadCommandSubtitleVariant; isWorktre
3434}
3535
3636export function ThreadCommandSubtitle(props: {
37 environmentId: EnvironmentId;
38 projectCwd: string | null;
39 projectFaviconPath?: string | null;
40 projectIcon?: import("@t3tools/contracts").ProjectIconOverride | null;
37 project: ProjectFaviconProject | null;
4138 projectTitle: string | null;
4239 branch: string | null;
4340 worktreePath: string | null;
......@@ -68,15 +65,8 @@ export function ThreadCommandSubtitle(props: {
6865 >
6966 {projectLabel ? (
7067 <span className="inline-flex min-w-0 items-center gap-1">
71 {props.projectCwd ? (
72 <ProjectFavicon
73 environmentId={props.environmentId}
74 cwd={props.projectCwd}
75 projectName={projectLabel}
76 faviconPath={props.projectFaviconPath}
77 projectIcon={props.projectIcon}
78 className="size-3 shrink-0"
79 />
68 {props.project ? (
69 <ProjectFavicon project={props.project} className="size-3 shrink-0" />
8070 ) : null}
8171 <span className="min-w-0 truncate">{projectLabel}</span>
8272 </span>
apps/web/src/components/chat/ChatHeader.tsx+7-17
......@@ -6,6 +6,7 @@ import {
66 type ThreadId,
77} from "@t3tools/contracts";
88import { scopeThreadRef } from "@t3tools/client-runtime/environment";
9import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
910import {
1011 isAtomCommandInterrupted,
1112 squashAtomCommandFailure,
......@@ -54,10 +55,7 @@ interface ChatHeaderProps {
5455 activeThreadTitle: string;
5556 /** Drafts have no server thread yet, so the title carries no action menu. */
5657 isServerThread: boolean;
57 activeProjectName: string | undefined;
58 activeProjectCwd: string | null;
59 activeProjectFaviconPath: string | null;
60 activeProjectIcon: import("@t3tools/contracts").ProjectIconOverride | null;
58 activeProject: EnvironmentProject | null;
6159 openInCwd: string | null;
6260 activeProjectScripts: ReadonlyArray<ProjectScript> | undefined;
6361 preferredScriptId: string | null;
......@@ -126,10 +124,7 @@ export const ChatHeader = memo(function ChatHeader({
126124 draftId,
127125 activeThreadTitle,
128126 isServerThread,
129 activeProjectName,
130 activeProjectCwd,
131 activeProjectFaviconPath,
132 activeProjectIcon,
127 activeProject,
133128 openInCwd,
134129 activeProjectScripts,
135130 preferredScriptId,
......@@ -161,6 +156,8 @@ export const ChatHeader = memo(function ChatHeader({
161156 });
162157 }, [panelAnimationDurationMs, panelAnimationsActive]);
163158 const primaryEnvironmentId = usePrimaryEnvironmentId();
159 const activeProjectName = activeProject?.title;
160 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
164161 const fileScripts = useT3ProjectFileScripts(
165162 activeThreadEnvironmentId,
166163 activeProjectScripts ? activeProjectCwd : null,
......@@ -327,7 +324,7 @@ export const ChatHeader = memo(function ChatHeader({
327324 {/* The project always leads the header: knowing which project a
328325 thread lives in is priority zero, and the thread title alone
329326 doesn't answer it. */}
330 {activeProjectName ? (
327 {activeProject ? (
331328 <>
332329 <WorkspaceBreadcrumbItem className="shrink">
333330 <Tooltip>
......@@ -341,14 +338,7 @@ export const ChatHeader = memo(function ChatHeader({
341338 />
342339 }
343340 >
344 <ProjectFavicon
345 environmentId={activeThreadEnvironmentId}
346 cwd={activeProjectCwd ?? ""}
347 projectName={activeProjectName}
348 faviconPath={activeProjectFaviconPath}
349 projectIcon={activeProjectIcon}
350 className="size-3.5"
351 />
341 <ProjectFavicon project={activeProject} className="size-3.5" />
352342 <span className="max-w-40 truncate">{activeProjectName}</span>
353343 </TooltipTrigger>
354344 <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+6-30
......@@ -25,7 +25,7 @@ import {
2525import { type ElementType, useState } from "react";
2626
2727import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
28import { ProjectFavicon } from "../ProjectFavicon";
28import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon";
2929import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
3030import { Button } from "../ui/button";
3131
......@@ -57,12 +57,7 @@ export interface PullRequestFilterOption<Value extends string> {
5757 readonly label: string;
5858 /** Uses the option's native icon tone. */
5959 readonly Icon: ElementType<{ className?: string }>;
60 readonly favicon?: {
61 readonly environmentId: EnvironmentId;
62 readonly cwd: string;
63 readonly faviconPath?: string | null;
64 readonly projectIcon?: ProjectIconOverride | null;
65 };
60 readonly project?: ProjectFaviconProject;
6661 /** Why it cannot be chosen, carried onto the item as its title. */
6762 readonly unavailable?: string | undefined;
6863}
......@@ -72,15 +67,8 @@ export function PullRequestFilterOptionIcon<Value extends string>({
7267}: {
7368 option: PullRequestFilterOption<Value>;
7469}) {
75 return option.favicon ? (
76 <ProjectFavicon
77 environmentId={option.favicon.environmentId}
78 cwd={option.favicon.cwd}
79 projectName={option.label}
80 faviconPath={option.favicon.faviconPath}
81 projectIcon={option.favicon.projectIcon}
82 className="size-3.5"
83 />
70 return option.project ? (
71 <ProjectFavicon project={option.project} className="size-3.5" />
8472 ) : (
8573 <option.Icon aria-hidden className="size-3.5" />
8674 );
......@@ -452,14 +440,7 @@ export function PullRequestFiltersMenu({
452440 serverOptions: ReadonlyArray<PullRequestFilterOption<string>>;
453441 onServer: (server: EnvironmentId | undefined) => void;
454442 /** The projects of every connected environment, each carrying the one its favicon is read from. */
455 projects: ReadonlyArray<{
456 readonly id: ProjectId;
457 readonly environmentId: EnvironmentId;
458 readonly title: string;
459 readonly workspaceRoot: string;
460 readonly faviconPath?: string | null | undefined;
461 readonly projectIcon?: ProjectIconOverride | null | undefined;
462 }>;
443 projects: ReadonlyArray<ProjectFaviconProject & { readonly id: ProjectId }>;
463444 projectId: ProjectId | undefined;
464445 /**
465446 * The server the selected project belongs to. A project id is only unique within its own
......@@ -514,12 +495,7 @@ export function PullRequestFiltersMenu({
514495 value: pullRequestProjectKey(project),
515496 label: project.title,
516497 Icon: FolderGit2Icon,
517 favicon: {
518 environmentId: project.environmentId,
519 cwd: project.workspaceRoot,
520 faviconPath: project.faviconPath ?? null,
521 projectIcon: project.projectIcon ?? null,
522 },
498 project,
523499 ...(unavailable.has(pullRequestProjectKey(project))
524500 ? { unavailable: unavailable.get(pullRequestProjectKey(project)) }
525501 : {}),
apps/web/src/components/settings/ProjectSettingsPanel.tsx+1-8
......@@ -967,14 +967,7 @@ function ProjectDetail({
967967 }
968968 control={
969969 <div className="flex items-center gap-2">
970 <ProjectFavicon
971 environmentId={representative.environmentId}
972 cwd={representative.workspaceRoot}
973 projectName={representative.title}
974 faviconPath={faviconPath}
975 projectIcon={projectIcon}
976 className="size-6"
977 />
970 <ProjectFavicon project={representative} className="size-6" />
978971 <Button
979972 size="sm"
980973 variant="outline"
apps/web/src/components/settings/ProjectsSettings.tsx+1-10
......@@ -145,16 +145,7 @@ export function ProjectsSettings({
145145 options={groups.map((group) => ({
146146 value: group.projectKey,
147147 label: group.displayName,
148 icon: (
149 <ProjectFavicon
150 environmentId={group.environmentId}
151 cwd={group.workspaceRoot}
152 projectName={group.title}
153 faviconPath={group.faviconPath}
154 projectIcon={group.projectIcon}
155 className="size-3.5"
156 />
157 ),
148 icon: <ProjectFavicon project={group} className="size-3.5" />,
158149 }))}
159150 onChange={(value) => onScopeChange(value, machineId)}
160151 />
apps/web/src/components/settings/SettingsPanels.tsx+3-22
......@@ -2853,18 +2853,7 @@ export function ArchivedThreadsPanel() {
28532853 const projectsByEnvironmentAndId = new Map(
28542854 archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
28552855 snapshot.projects.map(
2856 (project) =>
2857 [
2858 `${environmentId}:${project.id}`,
2859 {
2860 id: project.id,
2861 environmentId,
2862 name: project.title,
2863 cwd: project.workspaceRoot,
2864 faviconPath: project.faviconPath,
2865 projectIcon: project.projectIcon,
2866 },
2867 ] as const,
2856 (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const,
28682857 ),
28692858 ),
28702859 );
......@@ -2983,16 +2972,8 @@ export function ArchivedThreadsPanel() {
29832972 <SettingsSection
29842973 key={project.id}
29852974 id={index === 0 ? searchableSetting("archive").id : undefined}
2986 title={project.name}
2987 icon={
2988 <ProjectFavicon
2989 environmentId={project.environmentId}
2990 cwd={project.cwd}
2991 projectName={project.name}
2992 faviconPath={project.faviconPath}
2993 projectIcon={project.projectIcon}
2994 />
2995 }
2975 title={project.title}
2976 icon={<ProjectFavicon project={project} />}
29962977 >
29972978 {projectThreads.map((thread) => (
29982979 <SettingsRow