Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -850,13 +850,14 @@
buildProjectActionItems({
projects,
valuePrefix: "project",
icon: (project) => (
<ProjectFavicon
environmentId={project.environmentId}
cwd={project.cwd}
name={project.name}
className={ITEM_ICON_CLASS}
/>
),

Check warning on line 860 in apps/web/src/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react(no-unstable-nested-components)

Do not define components during render.
runProject: openProjectFromSearch,
}),
[openProjectFromSearch, projects],
Expand All @@ -867,13 +868,14 @@
buildProjectActionItems({
projects,
valuePrefix: "new-thread-in",
icon: (project) => (
<ProjectFavicon
environmentId={project.environmentId}
cwd={project.cwd}
name={project.name}
className={ITEM_ICON_CLASS}
/>
),

Check warning on line 878 in apps/web/src/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react(no-unstable-nested-components)

Do not define components during render.
runProject: async (project) => {
await startNewThreadInProjectFromContext(
{
Expand Down Expand Up @@ -904,24 +906,25 @@
...(activeThreadId ? { activeThreadId } : {}),
projectTitleById,
sortOrder: settings.sidebarThreadSortOrder,
icon: (thread) => {
const project = projectByScopedKey.get(
scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
);
if (!project) {
return <MessageSquareIcon className={ITEM_ICON_CLASS} />;
}
if (project.kind === "general-chat") {
return <MessagesSquareIcon className={ITEM_ICON_CLASS} />;
}
return (
<ProjectFavicon
environmentId={project.environmentId}
cwd={project.cwd}
name={project.name}
className={ITEM_ICON_CLASS}
/>
);
},

Check warning on line 927 in apps/web/src/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react(no-unstable-nested-components)

Do not define components during render.
renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,
runThread: async (thread) => {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/NoActiveThreadState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ export function NoActiveThreadState() {
<ProjectFavicon
cwd={project.cwd}
environmentId={project.environmentId}
name={project.name}
/>
<span className="max-w-56 truncate">{project.name}</span>
</MenuItem>
Expand Down
137 changes: 87 additions & 50 deletions apps/web/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,114 @@
import type { EnvironmentId } from "@threadlines/contracts";
import { FolderIcon } from "lucide-react";
import { useState } from "react";
import type { CSSProperties } from "react";
import { useQuery } from "@tanstack/react-query";
import { environmentUsesRelayTransport, resolveEnvironmentHttpUrl } from "../environments/runtime";
import { cn } from "../lib/utils";
import { projectFaviconQueryOptions } from "../lib/projectReactQuery";

const PROJECT_FAVICON_RESOLVER_VERSION = "3";
const loadedProjectFaviconSrcs = new Set<string>();
/**
* A stable hue for a project, derived from its identity string (the cwd). The
* same checkout keeps the same tint on every surface and across reloads, and
* neighbouring paths land far apart because the multiplier spreads them.
*/
function projectMonogramHue(identity: string): number {
let hash = 0;
for (let index = 0; index < identity.length; index += 1) {
hash = (hash * 31 + identity.charCodeAt(index)) % 360_000;
}
return hash % 360;
}

function projectMonogramGlyph(name: string): string | null {
const first = name.trim().at(0);
return first ? first.toUpperCase() : null;
}

/**
* The stand-in for a project with no icon of its own: one tinted letter.
*
* Quiet by design — low chroma against the sidebar, no border and no shadow —
* so a column of them reads as texture that distinguishes rows, not as a row
* of colourful avatars.
*/
function ProjectMonogram(props: { glyph: string; hue: number; className: string | undefined }) {
return (
<span
aria-hidden="true"
style={
{
"--project-monogram-bg": `oklch(0.65 0.08 ${props.hue} / 0.22)`,
"--project-monogram-fg": `oklch(0.55 0.10 ${props.hue})`,
"--project-monogram-fg-dark": `oklch(0.78 0.08 ${props.hue})`,
} as CSSProperties
}
className={cn(
"inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm bg-[var(--project-monogram-bg)] text-[9px] leading-none font-semibold text-[var(--project-monogram-fg)] dark:text-[var(--project-monogram-fg-dark)]",
props.className,
)}
>
{props.glyph}
</span>
);
}

/**
* The project's own icon, fetched over the environment's WebSocket RPC.
*
* One transport for every environment, primary or saved. A saved environment's
* `/api/project-favicon` route is cross-origin and authenticated over the
* WebSocket, so the browser cannot fetch it from an `<img>` at all; and even
* locally the HTTP route answers a missing icon with a fallback SVG the client
* can't tell apart from a real one, which is what the monogram needs to know.
* Icons are a few KB and the query holds them for an hour, so this costs one
* round trip per checkout.
*/
export function ProjectFavicon(input: {
environmentId: EnvironmentId;
cwd: string;
/** Enables the monogram fallback for projects with no icon of their own. */
name?: string;
className?: string;
}) {
// Relay-paired environments (phonelink) can't reach the favicon HTTP
// route — the relay carries only the WebSocket — so fetch the icon bytes
// over RPC and render them as a data URL instead.
const usesRelay = environmentUsesRelayTransport(input.environmentId);
const faviconQuery = useQuery(
projectFaviconQueryOptions({
environmentId: input.environmentId,
cwd: input.cwd,
enabled: usesRelay,
enabled: input.cwd.length > 0,
}),
);

const src = (() => {
if (usesRelay) {
return faviconQuery.data ?? null;
}
try {
return resolveEnvironmentHttpUrl({
environmentId: input.environmentId,
pathname: "/api/project-favicon",
searchParams: { cwd: input.cwd, v: PROJECT_FAVICON_RESOLVER_VERSION },
});
} catch {
return null;
}
})();
const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
);
// Data URLs carry their bytes inline, so skip the load-tracking dance the
// HTTP path needs to avoid flashing the fallback while the request runs.
const isLoaded =
src !== null && ((src.startsWith("data:") && status !== "error") || status === "loaded");
const src = faviconQuery.data ?? null;
if (src) {
return (
<img
src={src}
alt=""
className={cn("size-3.5 shrink-0 rounded-sm object-contain", input.className)}
/>
);
}

// While the fetch is still out, the project may yet have a real icon, and a
// monogram that gets replaced by it reads as wrong-then-right. Hold the
// neutral folder until "no icon" is an answer, not a guess.
if (faviconQuery.isLoading) {
return (
<FolderIcon className={cn("size-3.5 shrink-0 text-muted-foreground/50", input.className)} />
);
}

if (!src) {
const glyph = input.name ? projectMonogramGlyph(input.name) : null;
if (glyph) {
return (
<FolderIcon
className={`size-3.5 shrink-0 text-muted-foreground/50 ${input.className ?? ""}`}
<ProjectMonogram
glyph={glyph}
hue={projectMonogramHue(input.cwd)}
className={input.className}
/>
);
}

return (
<>
{!isLoaded ? (
<FolderIcon
className={`size-3.5 shrink-0 text-muted-foreground/50 ${input.className ?? ""}`}
/>
) : null}
<img
src={src}
alt=""
className={`size-3.5 shrink-0 rounded-sm object-contain ${isLoaded ? "" : "hidden"} ${input.className ?? ""}`}
onLoad={() => {
loadedProjectFaviconSrcs.add(src);
setStatus("loaded");
}}
onError={() => setStatus("error")}
/>
</>
<FolderIcon className={cn("size-3.5 shrink-0 text-muted-foreground/50", input.className)} />
);
}
6 changes: 5 additions & 1 deletion apps/web/src/components/RecentThreadsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ export function RecentThreadsList({
</span>
) : project ? (
<span className="flex max-w-28 shrink-0 items-center gap-1.5 text-xs text-muted-foreground/60">
<ProjectFavicon cwd={project.cwd} environmentId={project.environmentId} />
<ProjectFavicon
cwd={project.cwd}
environmentId={project.environmentId}
name={project.name}
/>
<span className="truncate">{project.name}</span>
</span>
) : null}
Expand Down
Loading
Loading