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) {...@@ -8093,10 +8093,7 @@ export default function ChatView(props: ChatViewProps) {
8093 {...(routeKind === "draft" && draftId ? { draftId } : {})}8093 {...(routeKind === "draft" && draftId ? { draftId } : {})}
8094 activeThreadTitle={activeThread.title}8094 activeThreadTitle={activeThread.title}
8095 isServerThread={isServerThread}8095 isServerThread={isServerThread}
8096 activeProjectName={activeProject?.title}8096 activeProject={activeProject}
8097 activeProjectCwd={activeProject?.workspaceRoot ?? null}
8098 activeProjectFaviconPath={activeProject?.faviconPath ?? null}
8099 activeProjectIcon={activeProject?.projectIcon ?? null}
8100 openInCwd={gitCwd}8097 openInCwd={gitCwd}
8101 activeProjectScripts={activeProjectScripts}8098 activeProjectScripts={activeProjectScripts}
8102 preferredScriptId={8099 preferredScriptId={
apps/web/src/components/CommandPalette.tsx+5-31
...@@ -178,16 +178,7 @@ import type { Project } from "../types";...@@ -178,16 +178,7 @@ import type { Project } from "../types";
178const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];178const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];
179179
180function projectFavicon(project: Project) {180function projectFavicon(project: Project) {
181 return (181 return <ProjectFavicon project={project} className="size-4" />;
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 );
191}182}
192183
193function getEnvironmentBrowsePlatform(os: string | null | undefined): string {184function getEnvironmentBrowsePlatform(os: string | null | undefined): string {
...@@ -927,18 +918,8 @@ function OpenCommandPaletteDialog(props: {...@@ -927,18 +918,8 @@ function OpenCommandPaletteDialog(props: {
927 new Map<ProjectId, string>(projects.map((project) => [project.id, project.workspaceRoot])),918 new Map<ProjectId, string>(projects.map((project) => [project.id, project.workspaceRoot])),
928 [projects],919 [projects],
929 );920 );
930 const projectFaviconPathById = useMemo(921 const projectByKey = useMemo(
931 () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)),922 () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])),
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 ),
942 [projects],923 [projects],
943 );924 );
944 const projectTitleById = useMemo(925 const projectTitleById = useMemo(
...@@ -1174,12 +1155,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1174,12 +1155,7 @@ function OpenCommandPaletteDialog(props: {
1174 ) ?? null;1155 ) ?? null;
1175 return (1156 return (
1176 <ThreadCommandSubtitle1157 <ThreadCommandSubtitle
1177 environmentId={thread.environmentId}1158 project={projectByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null}
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 }
1183 projectTitle={projectTitle ?? null}1159 projectTitle={projectTitle ?? null}
1184 branch={thread.branch}1160 branch={thread.branch}
1185 worktreePath={thread.worktreePath}1161 worktreePath={thread.worktreePath}
...@@ -1217,9 +1193,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1217,9 +1193,7 @@ function OpenCommandPaletteDialog(props: {
1217 activeThreadId,1193 activeThreadId,
1218 clientSettings.sidebarThreadSortOrder,1194 clientSettings.sidebarThreadSortOrder,
1219 navigate,1195 navigate,
1220 projectCwdById,1196 projectByKey,
1221 projectFaviconPathById,
1222 projectIconByKey,
1223 projectTitleById,1197 projectTitleById,
1224 providerEntryByEnvironmentAndInstanceId,1198 providerEntryByEnvironmentAndInstanceId,
1225 threadContentMatchByKey,1199 threadContentMatchByKey,
apps/web/src/components/LegacySidebar.tsx+1-7
...@@ -2322,13 +2322,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2322,13 +2322,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2322 />2322 />
2323 )}2323 )}
2324 <span className="flex shrink-0">2324 <span className="flex shrink-0">
2325 <ProjectFavicon2325 <ProjectFavicon project={project} />
2326 environmentId={project.environmentId}
2327 cwd={project.workspaceRoot}
2328 projectName={project.title}
2329 faviconPath={project.faviconPath}
2330 projectIcon={project.projectIcon}
2331 />
2332 </span>2326 </span>
2333 <span className="flex min-w-0 flex-1 items-center gap-2">2327 <span className="flex min-w-0 flex-1 items-center gap-2">
2334 <span className="truncate text-sm font-medium text-sidebar-foreground/90">2328 <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", () => ({...@@ -66,7 +66,14 @@ vi.mock("../state/assets", () => ({
66 },66 },
67}));67}));
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
71type ProjectFaviconImageProps = {78type ProjectFaviconImageProps = {
72 readonly cacheKey: string;79 readonly cacheKey: string;
...@@ -91,9 +98,7 @@ function resolveImageComponent(): {...@@ -91,9 +98,7 @@ function resolveImageComponent(): {
91} {98} {
92 hooks.beginRender();99 hooks.beginRender();
93 const element = ProjectFavicon({100 const element = ProjectFavicon({
94 environmentId: "environment-test" as EnvironmentId,101 project: makeProject({ workspaceRoot: "/workspace-test", title: "workspace-test" }),
95 cwd: "/workspace-test",
96 projectName: "workspace-test",
97 }) as ReactElement<ProjectFaviconImageProps>;102 }) as ReactElement<ProjectFaviconImageProps>;
98 hooks.reset();103 hooks.reset();
99104
...@@ -121,9 +126,7 @@ describe("ProjectFavicon", () => {...@@ -121,9 +126,7 @@ describe("ProjectFavicon", () => {
121 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;126 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
122127
123 const element = ProjectFavicon({128 const element = ProjectFavicon({
124 environmentId: "environment-test" as EnvironmentId,129 project: makeProject({ workspaceRoot: "/workspace/analytics-db", title: "analytics-db" }),
125 cwd: "/workspace/analytics-db",
126 projectName: "analytics-db",
127 }) as ReactElement<{130 }) as ReactElement<{
128 readonly colorClassName?: string;131 readonly colorClassName?: string;
129 readonly emoji?: string;132 readonly emoji?: string;
...@@ -139,9 +142,7 @@ describe("ProjectFavicon", () => {...@@ -139,9 +142,7 @@ describe("ProjectFavicon", () => {
139 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;142 testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`;
140143
141 const element = ProjectFavicon({144 const element = ProjectFavicon({
142 environmentId: "environment-test" as EnvironmentId,145 project: makeProject({ workspaceRoot: "/workspace/agent-runtime", title: "agent-runtime" }),
143 cwd: "/workspace/agent-runtime",
144 projectName: "agent-runtime",
145 }) as ReactElement<{146 }) as ReactElement<{
146 readonly colorClassName?: string;147 readonly colorClassName?: string;
147 readonly emoji?: string;148 readonly emoji?: string;
...@@ -155,11 +156,12 @@ describe("ProjectFavicon", () => {...@@ -155,11 +156,12 @@ describe("ProjectFavicon", () => {
155156
156 it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => {157 it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => {
157 const element = ProjectFavicon({158 const element = ProjectFavicon({
158 environmentId: "environment-test" as EnvironmentId,159 project: makeProject({
159 cwd: "/workspace/test",160 workspaceRoot: "/workspace/test",
160 projectName: "test",161 title: "test",
161 faviconPath: "brand/icon.svg",162 faviconPath: "brand/icon.svg",
162 projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" },163 projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" },
164 }),
163 }) as ReactElement<{165 }) as ReactElement<{
164 readonly children: ReactElement<{166 readonly children: ReactElement<{
165 readonly children: ReactElement<{ readonly name: string; readonly className: string }>;167 readonly children: ReactElement<{ readonly name: string; readonly className: string }>;
...@@ -174,11 +176,12 @@ describe("ProjectFavicon", () => {...@@ -174,11 +176,12 @@ describe("ProjectFavicon", () => {
174176
175 it("renders a saved emoji ahead of an uploaded favicon", () => {177 it("renders a saved emoji ahead of an uploaded favicon", () => {
176 const element = ProjectFavicon({178 const element = ProjectFavicon({
177 environmentId: "environment-test" as EnvironmentId,179 project: makeProject({
178 cwd: "/workspace/test",180 workspaceRoot: "/workspace/test",
179 projectName: "test",181 title: "test",
180 faviconPath: "brand/icon.svg",182 faviconPath: "brand/icon.svg",
181 projectIcon: { kind: "emoji", emoji: "🦄" },183 projectIcon: { kind: "emoji", emoji: "🦄" },
184 }),
182 }) as ReactElement<{ readonly emoji: string }>;185 }) as ReactElement<{ readonly emoji: string }>;
183186
184 expect(element.props.emoji).toBe("🦄");187 expect(element.props.emoji).toBe("🦄");
...@@ -208,10 +211,11 @@ describe("ProjectFavicon", () => {...@@ -208,10 +211,11 @@ describe("ProjectFavicon", () => {
208211
209 it("requests a saved favicon path when one is set", () => {212 it("requests a saved favicon path when one is set", () => {
210 ProjectFavicon({213 ProjectFavicon({
211 environmentId: "environment-test" as EnvironmentId,214 project: makeProject({
212 cwd: "/workspace-test",215 workspaceRoot: "/workspace-test",
213 projectName: "workspace-test",216 title: "workspace-test",
214 faviconPath: "brand/icon.svg",217 faviconPath: "brand/icon.svg",
218 }),
215 });219 });
216220
217 expect(testState.lastTarget).toMatchObject({221 expect(testState.lastTarget).toMatchObject({
apps/web/src/components/ProjectFavicon.tsx+31-14
...@@ -1,4 +1,5 @@...@@ -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";
2import {3import {
3 getProjectFaviconResourceKey,4 getProjectFaviconResourceKey,
4 isProjectFaviconFallbackUrl,5 isProjectFaviconFallbackUrl,
...@@ -94,21 +95,33 @@ const PROJECT_ICON_COLOR_BY_NAME: Record<ProjectIconName, ProjectIconColor> = {...@@ -94,21 +95,33 @@ const PROJECT_ICON_COLOR_BY_NAME: Record<ProjectIconName, ProjectIconColor> = {
94 web: "sky",95 web: "sky",
95};96};
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
97export function ProjectFavicon(input: {107export function ProjectFavicon(input: {
98 environmentId: EnvironmentId;108 project: ProjectFaviconProject;
99 cwd: string;
100 projectName: string;
101 faviconPath?: string | null | undefined;
102 projectIcon?: ProjectIconOverride | null | undefined;
103 className?: string | undefined;109 className?: string | undefined;
104 fallbackIcon?: ComponentType<{ className?: string }>;110 fallbackIcon?: ComponentType<{ className?: string }>;
105}) {111}) {
106 const src = useAtomValue(projectFaviconUrlAtom(input));112 const { project } = input;
107 if (input.projectIcon?.kind === "emoji") {113 const src = useAtomValue(
108 return <ProjectFaviconFallback className={input.className} emoji={input.projectIcon.emoji} />;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} />;
109 }122 }
110 if (input.projectIcon?.kind === "lucide") {123 if (project.projectIcon?.kind === "lucide") {
111 const colorClassName = projectIconColorClassName(input.projectIcon.color);124 const colorClassName = projectIconColorClassName(project.projectIcon.color);
112 const iconClassName = cn(125 const iconClassName = cn(
113 "inline-flex size-3.5 shrink-0 items-center justify-center",126 "inline-flex size-3.5 shrink-0 items-center justify-center",
114 colorClassName,127 colorClassName,
...@@ -118,7 +131,7 @@ export function ProjectFavicon(input: {...@@ -118,7 +131,7 @@ export function ProjectFavicon(input: {
118 <span aria-hidden="true" className={iconClassName}>131 <span aria-hidden="true" className={iconClassName}>
119 <Suspense fallback={<DynamicProjectIconFallback />}>132 <Suspense fallback={<DynamicProjectIconFallback />}>
120 <DynamicIcon133 <DynamicIcon
121 name={input.projectIcon.name as IconName}134 name={project.projectIcon.name as IconName}
122 className={cn("size-full", colorClassName)}135 className={cn("size-full", colorClassName)}
123 fallback={DynamicProjectIconFallback}136 fallback={DynamicProjectIconFallback}
124 />137 />
...@@ -128,7 +141,7 @@ export function ProjectFavicon(input: {...@@ -128,7 +141,7 @@ export function ProjectFavicon(input: {
128 }141 }
129 const automaticIconName = input.fallbackIcon142 const automaticIconName = input.fallbackIcon
130 ? null143 ? null
131 : selectProjectIcon(input.projectName, input.cwd);144 : selectProjectIcon(project.title, project.workspaceRoot);
132 const FallbackIcon =145 const FallbackIcon =
133 input.fallbackIcon ??146 input.fallbackIcon ??
134 (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined);147 (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined);
...@@ -149,7 +162,11 @@ export function ProjectFavicon(input: {...@@ -149,7 +162,11 @@ export function ProjectFavicon(input: {
149 );162 );
150 }163 }
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
154 return (171 return (
155 <ProjectFaviconImage172 <ProjectFaviconImage
apps/web/src/components/Sidebar.tsx+37-160
...@@ -131,6 +131,7 @@ import {...@@ -131,6 +131,7 @@ import {
131} from "../threadRoutes";131} from "../threadRoutes";
132import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";132import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";
133import type { SidebarThreadSummary } from "../types";133import type { SidebarThreadSummary } from "../types";
134import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
134import { cn } from "~/lib/utils";135import { cn } from "~/lib/utils";
135import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";136import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
136import { buildThreadActionMenuItems } from "./threadActionMenu.logic";137import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
...@@ -193,7 +194,7 @@ import {...@@ -193,7 +194,7 @@ import {
193 snoozeWakeLabel,194 snoozeWakeLabel,
194 type SnoozePreset,195 type SnoozePreset,
195} from "./Sidebar.snooze";196} from "./Sidebar.snooze";
196import { ProjectFavicon } from "./ProjectFavicon";197import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
197import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";198import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
198import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";199import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
199import {200import {
...@@ -295,11 +296,8 @@ function terminalProcessLabel(count: number): string {...@@ -295,11 +296,8 @@ function terminalProcessLabel(count: number): string {
295296
296function SidebarThreadTooltip({297function SidebarThreadTooltip({
297 thread,298 thread,
298 projectTitle,299 project,
299 projectDisplayName,300 projectDisplayName,
300 projectCwd,
301 projectFaviconPath,
302 projectIcon,
303 environmentLabel,301 environmentLabel,
304 environmentMachine,302 environmentMachine,
305 providerEntry,303 providerEntry,
...@@ -311,11 +309,8 @@ function SidebarThreadTooltip({...@@ -311,11 +309,8 @@ function SidebarThreadTooltip({
311 terminalProcessCount,309 terminalProcessCount,
312}: {310}: {
313 thread: SidebarThreadSummary;311 thread: SidebarThreadSummary;
314 projectTitle: string | null;312 project: ProjectFaviconProject | null;
315 projectDisplayName: string | null;313 projectDisplayName: string | null;
316 projectCwd: string | null;
317 projectFaviconPath: string | null;
318 projectIcon: ProjectIconOverride | null;
319 environmentLabel: string | null;314 environmentLabel: string | null;
320 environmentMachine: EnvironmentMachineKind;315 environmentMachine: EnvironmentMachineKind;
321 providerEntry: ProviderInstanceEntry | null;316 providerEntry: ProviderInstanceEntry | null;
...@@ -345,14 +340,7 @@ function SidebarThreadTooltip({...@@ -345,14 +340,7 @@ function SidebarThreadTooltip({
345 <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground">340 <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground">
346 {projectDisplayName ? (341 {projectDisplayName ? (
347 <div className="flex min-w-0 items-center gap-2">342 <div className="flex min-w-0 items-center gap-2">
348 <ProjectFavicon343 {project ? <ProjectFavicon project={project} className="size-3 shrink-0" /> : null}
349 environmentId={thread.environmentId}
350 cwd={projectCwd ?? ""}
351 projectName={projectTitle ?? ""}
352 faviconPath={projectFaviconPath}
353 projectIcon={projectIcon}
354 className="size-3 shrink-0"
355 />
356 <div className="min-w-0 truncate text-foreground/75">{projectDisplayName}</div>344 <div className="min-w-0 truncate text-foreground/75">{projectDisplayName}</div>
357 </div>345 </div>
358 ) : null}346 ) : null}
...@@ -689,11 +677,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -689,11 +677,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
689 draftId: DraftId;677 draftId: DraftId;
690 session: DraftSessionState;678 session: DraftSessionState;
691 composer: ComposerThreadDraftState;679 composer: ComposerThreadDraftState;
692 projectTitle: string | null;680 project: ProjectFaviconProject | null;
693 projectDisplayName: string | null;681 projectDisplayName: string | null;
694 projectCwd: string | null;
695 projectFaviconPath: string | null;
696 projectIcon: ProjectIconOverride | null;
697 isActive: boolean;682 isActive: boolean;
698 onNavigate: (draftId: DraftId) => void;683 onNavigate: (draftId: DraftId) => void;
699 onDiscard: (draftId: DraftId) => void;684 onDiscard: (draftId: DraftId) => void;
...@@ -751,14 +736,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -751,14 +736,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
751 <div className="relative z-10 px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">736 <div className="relative z-10 px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
752 <div className="flex h-5 min-w-0 items-center gap-1.5">737 <div className="flex h-5 min-w-0 items-center gap-1.5">
753 <SquarePenIcon aria-hidden className={draftPenClassName} />738 <SquarePenIcon aria-hidden className={draftPenClassName} />
754 <ProjectFavicon739 {props.project ? (
755 environmentId={session.environmentId}740 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
756 cwd={props.projectCwd ?? ""}741 ) : null}
757 projectName={props.projectTitle ?? ""}
758 faviconPath={props.projectFaviconPath}
759 projectIcon={props.projectIcon}
760 className="size-4 shrink-0"
761 />
762 <span className="min-w-0 flex-1 truncate text-xs font-medium text-secondary-label">742 <span className="min-w-0 flex-1 truncate text-xs font-medium text-secondary-label">
763 {props.projectDisplayName}743 {props.projectDisplayName}
764 </span>744 </span>
...@@ -798,11 +778,8 @@ interface SidebarDraftRowData {...@@ -798,11 +778,8 @@ interface SidebarDraftRowData {
798// subscription + closing divider) so per-keystroke composer updates778// subscription + closing divider) so per-keystroke composer updates
799// re-render only this block, never the whole sidebar. Vanishes at count 0.779// re-render only this block, never the whole sidebar. Vanishes at count 0.
800const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {780const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
801 projectTitleByKey: ReadonlyMap<string, string>;781 projectByKey: ReadonlyMap<string, EnvironmentProject>;
802 projectDisplayNameByKey: ReadonlyMap<string, string>;782 projectDisplayNameByKey: ReadonlyMap<string, string>;
803 projectCwdByKey: ReadonlyMap<string, string>;
804 projectFaviconPathByKey: ReadonlyMap<string, string | null | undefined>;
805 projectIconByKey: ReadonlyMap<string, ProjectIconOverride | null | undefined>;
806 scopedProjectKeys: ReadonlySet<string> | null;783 scopedProjectKeys: ReadonlySet<string> | null;
807 routeDraftId: string | null;784 routeDraftId: string | null;
808 onNavigateToDraft: (draftId: DraftId) => void;785 onNavigateToDraft: (draftId: DraftId) => void;
...@@ -896,11 +873,8 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {...@@ -896,11 +873,8 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
896 draftId={draftId}873 draftId={draftId}
897 session={session}874 session={session}
898 composer={composer}875 composer={composer}
899 projectTitle={props.projectTitleByKey.get(projectKey) ?? null}876 project={props.projectByKey.get(projectKey) ?? null}
900 projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null}877 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}
904 isActive={draftId === props.routeDraftId}878 isActive={draftId === props.routeDraftId}
905 onNavigate={props.onNavigateToDraft}879 onNavigate={props.onNavigateToDraft}
906 onDiscard={handleDiscard}880 onDiscard={handleDiscard}
...@@ -987,10 +961,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -987,10 +961,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
987 currentEnvironmentId: string | null;961 currentEnvironmentId: string | null;
988 environmentLabel: string | null;962 environmentLabel: string | null;
989 environmentMachine: EnvironmentMachineKind;963 environmentMachine: EnvironmentMachineKind;
990 projectCwd: string | null;964 project: EnvironmentProject | null;
991 projectFaviconPath: string | null;
992 projectIcon: ProjectIconOverride | null;
993 projectTitle: string | null;
994 projectDisplayName: string | null;965 projectDisplayName: string | null;
995 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;966 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
996 timestampFormat: TimestampFormat;967 timestampFormat: TimestampFormat;
...@@ -1061,7 +1032,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1061,7 +1032,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1061 [clearComposerContent, threadRef],1032 [clearComposerContent, threadRef],
1062 );1033 );
10631034
1064 const gitCwd = thread.worktreePath ?? props.projectCwd;1035 const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
1065 const linkedPullRequestStatus = useLinkedThreadPullRequest(1036 const linkedPullRequestStatus = useLinkedThreadPullRequest(
1066 thread.environmentId,1037 thread.environmentId,
1067 thread.linkedPullRequest ?? thread.branchPullRequest,1038 thread.linkedPullRequest ?? thread.branchPullRequest,
...@@ -1197,11 +1168,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1197,11 +1168,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1197 const detailsTooltip = (1168 const detailsTooltip = (
1198 <SidebarThreadTooltip1169 <SidebarThreadTooltip
1199 thread={thread}1170 thread={thread}
1200 projectTitle={props.projectTitle}1171 project={props.project}
1201 projectDisplayName={props.projectDisplayName}1172 projectDisplayName={props.projectDisplayName}
1202 projectCwd={props.projectCwd}
1203 projectFaviconPath={props.projectFaviconPath}
1204 projectIcon={props.projectIcon}
1205 environmentLabel={props.environmentLabel}1173 environmentLabel={props.environmentLabel}
1206 environmentMachine={props.environmentMachine}1174 environmentMachine={props.environmentMachine}
1207 providerEntry={providerEntry}1175 providerEntry={providerEntry}
...@@ -1569,14 +1537,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1569,14 +1537,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1569 "opacity-40 grayscale group-hover/sidebar-row:opacity-100 group-hover/sidebar-row:grayscale-0",1537 "opacity-40 grayscale group-hover/sidebar-row:opacity-100 group-hover/sidebar-row:grayscale-0",
1570 )}1538 )}
1571 >1539 >
1572 <ProjectFavicon1540 {props.project ? <ProjectFavicon project={props.project} className="size-4" /> : null}
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 />
1580 </span>1541 </span>
1581 {draftIndicator}1542 {draftIndicator}
1582 {title}1543 {title}
...@@ -1722,14 +1683,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1722,14 +1683,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1722 <div className="relative z-10 h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">1683 <div className="relative z-10 h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
1723 <div className="flex h-5 min-w-0 items-center gap-1.5">1684 <div className="flex h-5 min-w-0 items-center gap-1.5">
1724 {draftIndicator}1685 {draftIndicator}
1725 <ProjectFavicon1686 {props.project ? (
1726 environmentId={thread.environmentId}1687 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
1727 cwd={props.projectCwd ?? ""}1688 ) : null}
1728 projectName={props.projectTitle ?? ""}
1729 faviconPath={props.projectFaviconPath}
1730 projectIcon={props.projectIcon}
1731 className="size-4 shrink-0"
1732 />
1733 {props.projectDisplayName ? (1689 {props.projectDisplayName ? (
1734 <span1690 <span
1735 className={cn(1691 className={cn(
...@@ -1953,10 +1909,7 @@ function latestTurnDiff(...@@ -1953,10 +1909,7 @@ function latestTurnDiff(
19531909
1954const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {1910const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
1955 thread: SidebarThreadSummary;1911 thread: SidebarThreadSummary;
1956 projectCwd: string | null;1912 project: EnvironmentProject | null;
1957 projectFaviconPath: string | null;
1958 projectIcon: ProjectIconOverride | null;
1959 projectTitle: string | null;
1960 projectDisplayName: string | null;1913 projectDisplayName: string | null;
1961 environmentLabel: string | null;1914 environmentLabel: string | null;
1962 environmentMachine: EnvironmentMachineKind;1915 environmentMachine: EnvironmentMachineKind;
...@@ -1973,7 +1926,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -1973,7 +1926,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
1973 );1926 );
1974 // Same details tooltip as the regular rows: a search hit is still a thread,1927 // Same details tooltip as the regular rows: a search hit is still a thread,
1975 // and the hover card is how you disambiguate identically-titled results.1928 // 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;
1977 const gitStatus = useEnvironmentQuery(1930 const gitStatus = useEnvironmentQuery(
1978 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null1931 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
1979 ? vcsEnvironment.status({1932 ? vcsEnvironment.status({
...@@ -2039,14 +1992,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -2039,14 +1992,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2039 />1992 />
2040 }1993 }
2041 >1994 >
2042 <ProjectFavicon1995 {props.project ? (
2043 environmentId={thread.environmentId}1996 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
2044 cwd={props.projectCwd ?? ""}1997 ) : null}
2045 projectName={props.projectTitle ?? ""}
2046 faviconPath={props.projectFaviconPath}
2047 projectIcon={props.projectIcon}
2048 className="size-4 shrink-0"
2049 />
2050 <span className="min-w-0 flex-1 truncate">{thread.title}</span>1998 <span className="min-w-0 flex-1 truncate">{thread.title}</span>
2051 <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">1999 <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">
2052 {threadTimeLabel(thread)}2000 {threadTimeLabel(thread)}
...@@ -2054,11 +2002,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -2054,11 +2002,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2054 </TooltipTrigger>2002 </TooltipTrigger>
2055 <SidebarThreadTooltip2003 <SidebarThreadTooltip
2056 thread={thread}2004 thread={thread}
2057 projectTitle={props.projectTitle}2005 project={props.project}
2058 projectDisplayName={props.projectDisplayName}2006 projectDisplayName={props.projectDisplayName}
2059 projectCwd={props.projectCwd}
2060 projectFaviconPath={props.projectFaviconPath}
2061 projectIcon={props.projectIcon}
2062 environmentLabel={props.environmentLabel}2007 environmentLabel={props.environmentLabel}
2063 environmentMachine={props.environmentMachine}2008 environmentMachine={props.environmentMachine}
2064 providerEntry={providerEntry}2009 providerEntry={providerEntry}
...@@ -2265,34 +2210,10 @@ export default function Sidebar() {...@@ -2265,34 +2210,10 @@ export default function Sidebar() {
2265 ),2210 ),
2266 [serverConfigs],2211 [serverConfigs],
2267 );2212 );
2268 const projectCwdByKey = useMemo(2213 // Rows read the project record for its icon and cwd. Group labels can include
2269 () =>2214 // a repository owner or a different title, so they travel separately.
2270 new Map(2215 const projectByKey = useMemo(
2271 projects.map((project) => [2216 () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, 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])),
2296 [projects],2217 [projects],
2297 );2218 );
2298 const projectDisplayNameByKey = useMemo(2219 const projectDisplayNameByKey = useMemo(
...@@ -3912,7 +3833,7 @@ export default function Sidebar() {...@@ -3912,7 +3833,7 @@ export default function Sidebar() {
3912 if (!thread) return;3833 if (!thread) return;
3913 const threadWorkspacePath =3834 const threadWorkspacePath =
3914 thread.worktreePath ??3835 thread.worktreePath ??
3915 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??3836 projectByKey.get(`${thread.environmentId}:${thread.projectId}`)?.workspaceRoot ??
3916 null;3837 null;
3917 // Un-settle pins the thread active until real activity clears the pin.3838 // Un-settle pins the thread active until real activity clears the pin.
3918 // Environments without3839 // Environments without
...@@ -4135,7 +4056,7 @@ export default function Sidebar() {...@@ -4135,7 +4056,7 @@ export default function Sidebar() {
4135 handleMultiSelectContextMenu,4056 handleMultiSelectContextMenu,
4136 markThreadUnread,4057 markThreadUnread,
4137 openProjectSettings,4058 openProjectSettings,
4138 projectCwdByKey,4059 projectByKey,
4139 serverConfigs,4060 serverConfigs,
4140 startThreadRename,4061 startThreadRename,
4141 updateThreadMetadata,4062 updateThreadMetadata,
...@@ -4392,14 +4313,7 @@ export default function Sidebar() {...@@ -4392,14 +4313,7 @@ export default function Sidebar() {
4392 >4313 >
4393 {scopedProjectGroup ? (4314 {scopedProjectGroup ? (
4394 <span className="flex shrink-0">4315 <span className="flex shrink-0">
4395 <ProjectFavicon4316 <ProjectFavicon project={scopedProjectGroup} className="size-4" />
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 />
4403 </span>4317 </span>
4404 ) : (4318 ) : (
4405 <FolderIcon className="size-4 shrink-0" />4319 <FolderIcon className="size-4 shrink-0" />
...@@ -4457,14 +4371,7 @@ export default function Sidebar() {...@@ -4457,14 +4371,7 @@ export default function Sidebar() {
4457 }}4371 }}
4458 >4372 >
4459 {project ? (4373 {project ? (
4460 <ProjectFavicon4374 <ProjectFavicon project={project} className="size-4 shrink-0" />
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 />
4468 ) : (4375 ) : (
4469 <FolderIcon className="size-4 shrink-0" />4376 <FolderIcon className="size-4 shrink-0" />
4470 )}4377 )}
...@@ -4539,21 +4446,8 @@ export default function Sidebar() {...@@ -4539,21 +4446,8 @@ export default function Sidebar() {
4539 <SidebarSearchResultRow4446 <SidebarSearchResultRow
4540 key={threadKey}4447 key={threadKey}
4541 thread={thread}4448 thread={thread}
4542 projectCwd={4449 project={
4543 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null4450 projectByKey.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
4557 }4451 }
4558 projectDisplayName={4452 projectDisplayName={
4559 projectDisplayNameByKey.get(4453 projectDisplayNameByKey.get(
...@@ -4689,24 +4583,10 @@ export default function Sidebar() {...@@ -4689,24 +4583,10 @@ export default function Sidebar() {
4689 environmentMachine={4583 environmentMachine={
4690 environmentMachineById.get(thread.environmentId) ?? "server"4584 environmentMachineById.get(thread.environmentId) ?? "server"
4691 }4585 }
4692 projectCwd={4586 project={
4693 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??4587 projectByKey.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}`) ??
4703 null4588 null
4704 }4589 }
4705 projectTitle={
4706 projectTitleByKey.get(
4707 `${thread.environmentId}:${thread.projectId}`,
4708 ) ?? null
4709 }
4710 projectDisplayName={4590 projectDisplayName={
4711 projectDisplayNameByKey.get(4591 projectDisplayNameByKey.get(
4712 `${thread.environmentId}:${thread.projectId}`,4592 `${thread.environmentId}:${thread.projectId}`,
...@@ -4758,11 +4638,8 @@ export default function Sidebar() {...@@ -4758,11 +4638,8 @@ export default function Sidebar() {
4758 const items: ReactNode[] = [4638 const items: ReactNode[] = [
4759 <SidebarDraftBlock4639 <SidebarDraftBlock
4760 key="draft-sessions"4640 key="draft-sessions"
4761 projectTitleByKey={projectTitleByKey}4641 projectByKey={projectByKey}
4762 projectDisplayNameByKey={projectDisplayNameByKey}4642 projectDisplayNameByKey={projectDisplayNameByKey}
4763 projectCwdByKey={projectCwdByKey}
4764 projectFaviconPathByKey={projectFaviconPathByKey}
4765 projectIconByKey={projectIconByKey}
4766 scopedProjectKeys={scopedProjectKeys}4643 scopedProjectKeys={scopedProjectKeys}
4767 routeDraftId={routeDraftIdForRows}4644 routeDraftId={routeDraftIdForRows}
4768 onNavigateToDraft={navigateToDraft}4645 onNavigateToDraft={navigateToDraft}
apps/web/src/components/ThreadCommandSubtitle.tsx+5-15
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { EnvironmentId, ProviderDriverKind } from "@t3tools/contracts";1import type { ProviderDriverKind } from "@t3tools/contracts";
2import { FolderGit2Icon, FolderIcon, GitBranchIcon } from "lucide-react";2import { FolderGit2Icon, FolderIcon, GitBranchIcon } from "lucide-react";
3import { ProjectFavicon } from "./ProjectFavicon";3import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
4import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";4import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
5import { cn } from "~/lib/utils";5import { cn } from "~/lib/utils";
66
...@@ -34,10 +34,7 @@ function WorkspaceIcon(props: { variant: ThreadCommandSubtitleVariant; isWorktre...@@ -34,10 +34,7 @@ function WorkspaceIcon(props: { variant: ThreadCommandSubtitleVariant; isWorktre
34}34}
3535
36export function ThreadCommandSubtitle(props: {36export function ThreadCommandSubtitle(props: {
37 environmentId: EnvironmentId;37 project: ProjectFaviconProject | null;
38 projectCwd: string | null;
39 projectFaviconPath?: string | null;
40 projectIcon?: import("@t3tools/contracts").ProjectIconOverride | null;
41 projectTitle: string | null;38 projectTitle: string | null;
42 branch: string | null;39 branch: string | null;
43 worktreePath: string | null;40 worktreePath: string | null;
...@@ -68,15 +65,8 @@ export function ThreadCommandSubtitle(props: {...@@ -68,15 +65,8 @@ export function ThreadCommandSubtitle(props: {
68 >65 >
69 {projectLabel ? (66 {projectLabel ? (
70 <span className="inline-flex min-w-0 items-center gap-1">67 <span className="inline-flex min-w-0 items-center gap-1">
71 {props.projectCwd ? (68 {props.project ? (
72 <ProjectFavicon69 <ProjectFavicon project={props.project} className="size-3 shrink-0" />
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 />
80 ) : null}70 ) : null}
81 <span className="min-w-0 truncate">{projectLabel}</span>71 <span className="min-w-0 truncate">{projectLabel}</span>
82 </span>72 </span>
apps/web/src/components/chat/ChatHeader.tsx+7-17
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 type ThreadId,6 type ThreadId,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { scopeThreadRef } from "@t3tools/client-runtime/environment";8import { scopeThreadRef } from "@t3tools/client-runtime/environment";
9import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
9import {10import {
10 isAtomCommandInterrupted,11 isAtomCommandInterrupted,
11 squashAtomCommandFailure,12 squashAtomCommandFailure,
...@@ -54,10 +55,7 @@ interface ChatHeaderProps {...@@ -54,10 +55,7 @@ interface ChatHeaderProps {
54 activeThreadTitle: string;55 activeThreadTitle: string;
55 /** Drafts have no server thread yet, so the title carries no action menu. */56 /** Drafts have no server thread yet, so the title carries no action menu. */
56 isServerThread: boolean;57 isServerThread: boolean;
57 activeProjectName: string | undefined;58 activeProject: EnvironmentProject | null;
58 activeProjectCwd: string | null;
59 activeProjectFaviconPath: string | null;
60 activeProjectIcon: import("@t3tools/contracts").ProjectIconOverride | null;
61 openInCwd: string | null;59 openInCwd: string | null;
62 activeProjectScripts: ReadonlyArray<ProjectScript> | undefined;60 activeProjectScripts: ReadonlyArray<ProjectScript> | undefined;
63 preferredScriptId: string | null;61 preferredScriptId: string | null;
...@@ -126,10 +124,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -126,10 +124,7 @@ export const ChatHeader = memo(function ChatHeader({
126 draftId,124 draftId,
127 activeThreadTitle,125 activeThreadTitle,
128 isServerThread,126 isServerThread,
129 activeProjectName,127 activeProject,
130 activeProjectCwd,
131 activeProjectFaviconPath,
132 activeProjectIcon,
133 openInCwd,128 openInCwd,
134 activeProjectScripts,129 activeProjectScripts,
135 preferredScriptId,130 preferredScriptId,
...@@ -161,6 +156,8 @@ export const ChatHeader = memo(function ChatHeader({...@@ -161,6 +156,8 @@ export const ChatHeader = memo(function ChatHeader({
161 });156 });
162 }, [panelAnimationDurationMs, panelAnimationsActive]);157 }, [panelAnimationDurationMs, panelAnimationsActive]);
163 const primaryEnvironmentId = usePrimaryEnvironmentId();158 const primaryEnvironmentId = usePrimaryEnvironmentId();
159 const activeProjectName = activeProject?.title;
160 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
164 const fileScripts = useT3ProjectFileScripts(161 const fileScripts = useT3ProjectFileScripts(
165 activeThreadEnvironmentId,162 activeThreadEnvironmentId,
166 activeProjectScripts ? activeProjectCwd : null,163 activeProjectScripts ? activeProjectCwd : null,
...@@ -327,7 +324,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -327,7 +324,7 @@ export const ChatHeader = memo(function ChatHeader({
327 {/* The project always leads the header: knowing which project a324 {/* The project always leads the header: knowing which project a
328 thread lives in is priority zero, and the thread title alone325 thread lives in is priority zero, and the thread title alone
329 doesn't answer it. */}326 doesn't answer it. */}
330 {activeProjectName ? (327 {activeProject ? (
331 <>328 <>
332 <WorkspaceBreadcrumbItem className="shrink">329 <WorkspaceBreadcrumbItem className="shrink">
333 <Tooltip>330 <Tooltip>
...@@ -341,14 +338,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -341,14 +338,7 @@ export const ChatHeader = memo(function ChatHeader({
341 />338 />
342 }339 }
343 >340 >
344 <ProjectFavicon341 <ProjectFavicon project={activeProject} className="size-3.5" />
345 environmentId={activeThreadEnvironmentId}
346 cwd={activeProjectCwd ?? ""}
347 projectName={activeProjectName}
348 faviconPath={activeProjectFaviconPath}
349 projectIcon={activeProjectIcon}
350 className="size-3.5"
351 />
352 <span className="max-w-40 truncate">{activeProjectName}</span>342 <span className="max-w-40 truncate">{activeProjectName}</span>
353 </TooltipTrigger>343 </TooltipTrigger>
354 <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>344 <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+6-30
...@@ -25,7 +25,7 @@ import {...@@ -25,7 +25,7 @@ import {
25import { type ElementType, useState } from "react";25import { type ElementType, useState } from "react";
2626
27import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";27import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
28import { ProjectFavicon } from "../ProjectFavicon";28import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon";
29import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";29import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
30import { Button } from "../ui/button";30import { Button } from "../ui/button";
3131
...@@ -57,12 +57,7 @@ export interface PullRequestFilterOption<Value extends string> {...@@ -57,12 +57,7 @@ export interface PullRequestFilterOption<Value extends string> {
57 readonly label: string;57 readonly label: string;
58 /** Uses the option's native icon tone. */58 /** Uses the option's native icon tone. */
59 readonly Icon: ElementType<{ className?: string }>;59 readonly Icon: ElementType<{ className?: string }>;
60 readonly favicon?: {60 readonly project?: ProjectFaviconProject;
61 readonly environmentId: EnvironmentId;
62 readonly cwd: string;
63 readonly faviconPath?: string | null;
64 readonly projectIcon?: ProjectIconOverride | null;
65 };
66 /** Why it cannot be chosen, carried onto the item as its title. */61 /** Why it cannot be chosen, carried onto the item as its title. */
67 readonly unavailable?: string | undefined;62 readonly unavailable?: string | undefined;
68}63}
...@@ -72,15 +67,8 @@ export function PullRequestFilterOptionIcon<Value extends string>({...@@ -72,15 +67,8 @@ export function PullRequestFilterOptionIcon<Value extends string>({
72}: {67}: {
73 option: PullRequestFilterOption<Value>;68 option: PullRequestFilterOption<Value>;
74}) {69}) {
75 return option.favicon ? (70 return option.project ? (
76 <ProjectFavicon71 <ProjectFavicon project={option.project} className="size-3.5" />
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 />
84 ) : (72 ) : (
85 <option.Icon aria-hidden className="size-3.5" />73 <option.Icon aria-hidden className="size-3.5" />
86 );74 );
...@@ -452,14 +440,7 @@ export function PullRequestFiltersMenu({...@@ -452,14 +440,7 @@ export function PullRequestFiltersMenu({
452 serverOptions: ReadonlyArray<PullRequestFilterOption<string>>;440 serverOptions: ReadonlyArray<PullRequestFilterOption<string>>;
453 onServer: (server: EnvironmentId | undefined) => void;441 onServer: (server: EnvironmentId | undefined) => void;
454 /** The projects of every connected environment, each carrying the one its favicon is read from. */442 /** The projects of every connected environment, each carrying the one its favicon is read from. */
455 projects: ReadonlyArray<{443 projects: ReadonlyArray<ProjectFaviconProject & { readonly id: ProjectId }>;
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 }>;
463 projectId: ProjectId | undefined;444 projectId: ProjectId | undefined;
464 /**445 /**
465 * The server the selected project belongs to. A project id is only unique within its own446 * The server the selected project belongs to. A project id is only unique within its own
...@@ -514,12 +495,7 @@ export function PullRequestFiltersMenu({...@@ -514,12 +495,7 @@ export function PullRequestFiltersMenu({
514 value: pullRequestProjectKey(project),495 value: pullRequestProjectKey(project),
515 label: project.title,496 label: project.title,
516 Icon: FolderGit2Icon,497 Icon: FolderGit2Icon,
517 favicon: {498 project,
518 environmentId: project.environmentId,
519 cwd: project.workspaceRoot,
520 faviconPath: project.faviconPath ?? null,
521 projectIcon: project.projectIcon ?? null,
522 },
523 ...(unavailable.has(pullRequestProjectKey(project))499 ...(unavailable.has(pullRequestProjectKey(project))
524 ? { unavailable: unavailable.get(pullRequestProjectKey(project)) }500 ? { unavailable: unavailable.get(pullRequestProjectKey(project)) }
525 : {}),501 : {}),
apps/web/src/components/settings/ProjectSettingsPanel.tsx+1-8
...@@ -967,14 +967,7 @@ function ProjectDetail({...@@ -967,14 +967,7 @@ function ProjectDetail({
967 }967 }
968 control={968 control={
969 <div className="flex items-center gap-2">969 <div className="flex items-center gap-2">
970 <ProjectFavicon970 <ProjectFavicon project={representative} className="size-6" />
971 environmentId={representative.environmentId}
972 cwd={representative.workspaceRoot}
973 projectName={representative.title}
974 faviconPath={faviconPath}
975 projectIcon={projectIcon}
976 className="size-6"
977 />
978 <Button971 <Button
979 size="sm"972 size="sm"
980 variant="outline"973 variant="outline"
apps/web/src/components/settings/ProjectsSettings.tsx+1-10
...@@ -145,16 +145,7 @@ export function ProjectsSettings({...@@ -145,16 +145,7 @@ export function ProjectsSettings({
145 options={groups.map((group) => ({145 options={groups.map((group) => ({
146 value: group.projectKey,146 value: group.projectKey,
147 label: group.displayName,147 label: group.displayName,
148 icon: (148 icon: <ProjectFavicon project={group} className="size-3.5" />,
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 ),
158 }))}149 }))}
159 onChange={(value) => onScopeChange(value, machineId)}150 onChange={(value) => onScopeChange(value, machineId)}
160 />151 />
apps/web/src/components/settings/SettingsPanels.tsx+3-22
...@@ -2853,18 +2853,7 @@ export function ArchivedThreadsPanel() {...@@ -2853,18 +2853,7 @@ export function ArchivedThreadsPanel() {
2853 const projectsByEnvironmentAndId = new Map(2853 const projectsByEnvironmentAndId = new Map(
2854 archivedSnapshots.flatMap(({ environmentId, snapshot }) =>2854 archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
2855 snapshot.projects.map(2855 snapshot.projects.map(
2856 (project) =>2856 (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const,
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,
2868 ),2857 ),
2869 ),2858 ),
2870 );2859 );
...@@ -2983,16 +2972,8 @@ export function ArchivedThreadsPanel() {...@@ -2983,16 +2972,8 @@ export function ArchivedThreadsPanel() {
2983 <SettingsSection2972 <SettingsSection
2984 key={project.id}2973 key={project.id}
2985 id={index === 0 ? searchableSetting("archive").id : undefined}2974 id={index === 0 ? searchableSetting("archive").id : undefined}
2986 title={project.name}2975 title={project.title}
2987 icon={2976 icon={<ProjectFavicon project={project} />}
2988 <ProjectFavicon
2989 environmentId={project.environmentId}
2990 cwd={project.cwd}
2991 projectName={project.name}
2992 faviconPath={project.faviconPath}
2993 projectIcon={project.projectIcon}
2994 />
2995 }
2996 >2977 >
2997 {projectThreads.map((thread) => (2978 {projectThreads.map((thread) => (
2998 <SettingsRow2979 <SettingsRow