Skip to content
Open
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/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,14 @@ describe("AssetAccess", () => {
const updatedFavicon = "<svg>b</svg>";
expect(updatedFavicon).toHaveLength(initialFavicon.length);
yield* fileSystem.writeFileString(faviconPath, initialFavicon);
yield* fileSystem.writeFileString(path.join(root, "t3.json"), '{ "accentColor": "#1688f0" }');
const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath);

const faviconResult = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: root },
});
expect(faviconResult.sourcePath).toBe("favicon.svg");
expect(faviconResult.projectAccent).toBe("#1688f0");
expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/);
expect(
yield* issueAssetUrl({
Expand Down Expand Up @@ -427,6 +429,7 @@ describe("AssetAccess", () => {
cause: platformCause,
});
const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({
resolveAccent: () => Effect.succeed(null),
resolvePath: () => Effect.fail(resolutionCause),
});

Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AssetResource } from "@t3tools/contracts";
import type { AssetResource, ProjectAccent } from "@t3tools/contracts";
import {
AssetAttachmentNotFoundError,
AssetPreviewTypeValidationError,
Expand Down Expand Up @@ -195,6 +195,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
let claims: AssetClaims;
let fileName: string;
let sourcePath: string | undefined;
let projectAccent: ProjectAccent | undefined;

switch (input.resource._tag) {
case "workspace-file": {
Expand Down Expand Up @@ -306,6 +307,16 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
),
);
const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
projectAccent = yield* faviconResolver.resolveAccent(workspaceRoot).pipe(
Effect.mapError(
(cause) =>
new AssetProjectFaviconResolutionError({
resource: input.resource,
cause,
}),
),
Effect.map((color) => color ?? undefined),
);
const faviconPath = yield* faviconResolver
.resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined)
.pipe(
Expand Down Expand Up @@ -424,6 +435,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
expiresAt,
...(sourcePath !== undefined ? { sourcePath } : {}),
...(projectAccent !== undefined ? { projectAccent } : {}),
};
});

Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/project/ProjectFaviconResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,51 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
);

it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
describe("resolveAccent", () => {
it.effect("reads the validated t3.json accent color", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "t3.json", '{ "accentColor": "#1688f0" }');

const accentColor = yield* resolver.resolveAccent(cwd);
expect(accentColor).toBe("#1688f0");
}),
);

it.effect("reads exact accent colors for every row state", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(
cwd,
"t3.json",
'{ "accentColor": { "idle": "#071525", "hover": "#102b46", "selected": "#173b60" } }',
);

const accentColor = yield* resolver.resolveAccent(cwd);
expect(accentColor).toEqual({
idle: "#071525",
hover: "#102b46",
selected: "#173b60",
});
}),
);

it.effect("returns null when the project file is missing or invalid", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;

const missingAccentColor = yield* resolver.resolveAccent(cwd);
expect(missingAccentColor).toBeNull();
yield* writeTextFile(cwd, "t3.json", '{ "accentColor": "blue" }');
const invalidAccentColor = yield* resolver.resolveAccent(cwd);
expect(invalidAccentColor).toBeNull();
}),
);
});

describe("resolvePath", () => {
it.effect("prefers well-known favicon files", () =>
Effect.gen(function* () {
Expand Down
37 changes: 26 additions & 11 deletions apps/server/src/project/ProjectFaviconResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*
* @module ProjectFaviconResolver
*/
import type { ProjectAccent } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -96,6 +97,9 @@ export class ProjectFaviconResolver extends Context.Service<
cwd: string,
faviconPath?: string,
) => Effect.Effect<string | null, ProjectFaviconResolutionError>;
readonly resolveAccent: (
cwd: string,
) => Effect.Effect<ProjectAccent | null, ProjectFaviconResolutionError>;
}
>()("t3/project/ProjectFaviconResolver") {}

Expand Down Expand Up @@ -129,6 +133,18 @@ export const make = Effect.gen(function* () {
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader;

const normalizeWorkspaceRoot = (cwd: string) =>
workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
Effect.mapError(
(cause) =>
new ProjectFaviconResolutionError({
operation: "normalize-workspace",
workspaceRoot: cwd,
cause,
}),
),
);

const resolveIconHref = (href: string): ReadonlyArray<string> => {
const clean = href.replace(/^\//, "");
return [path.join("public", clean), clean];
Expand Down Expand Up @@ -181,16 +197,7 @@ export const make = Effect.gen(function* () {
const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
"ProjectFaviconResolver.resolvePath",
)(function* (cwd, faviconPath) {
const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
Effect.mapError(
(cause) =>
new ProjectFaviconResolutionError({
operation: "normalize-workspace",
workspaceRoot: cwd,
cause,
}),
),
);
const projectCwd = yield* normalizeWorkspaceRoot(cwd);
// A grouped project's saved path can be absent from one checkout. Use it
// where it exists and retain automatic discovery for the other checkouts.
if (faviconPath !== undefined) {
Expand Down Expand Up @@ -267,7 +274,15 @@ export const make = Effect.gen(function* () {
return null;
});

return ProjectFaviconResolver.of({ resolvePath });
const resolveAccent: ProjectFaviconResolver["Service"]["resolveAccent"] = Effect.fn(
"ProjectFaviconResolver.resolveAccent",
)(function* (cwd) {
const projectCwd = yield* normalizeWorkspaceRoot(cwd);
const projectFile = yield* projectFileLoader.load(projectCwd);
return Option.isSome(projectFile) ? (projectFile.value.accentColor ?? null) : null;
});

return ProjectFaviconResolver.of({ resolveAccent, resolvePath });
});

export const layer = Layer.effect(ProjectFaviconResolver, make);
12 changes: 10 additions & 2 deletions apps/web/src/assets/assetUrls.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useAtomValue } from "@effect/atom-react";
import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
import type { AssetResource, EnvironmentId, ProjectAccent } from "@t3tools/contracts";
import { AsyncResult } from "effect/unstable/reactivity";
import { useMemo } from "react";

Expand All @@ -12,7 +12,12 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
export type AssetUrlState =
| { readonly _tag: "Loading" }
| { readonly _tag: "Failure" }
| { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string };
| {
readonly _tag: "Success";
readonly url: string;
readonly sourcePath?: string;
readonly projectAccent?: ProjectAccent;
};

export function useAssetUrlState(
environmentId: EnvironmentId,
Expand All @@ -38,6 +43,9 @@ export function useAssetUrlState(
_tag: "Success",
url,
...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),
...(result.value.projectAccent !== undefined
? { projectAccent: result.value.projectAccent }
: {}),
};
}

Expand Down
23 changes: 22 additions & 1 deletion apps/web/src/components/ProjectFavicon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ vi.mock("../assets/assetUrls", () => ({
},
}));

import { ProjectFavicon } from "./ProjectFavicon";
import { ProjectFavicon, projectAccentFromAsset } from "./ProjectFavicon";

type ProjectFaviconImageProps = {
readonly cacheKey: string;
Expand Down Expand Up @@ -143,4 +143,25 @@ describe("ProjectFavicon", () => {
path: "brand/icon.svg",
});
});

it("reads simple and advanced accents from the shared project asset", () => {
expect(
projectAccentFromAsset({
_tag: "Success",
url: testState.faviconUrl,
projectAccent: "#1688f0",
}),
).toBe("#1688f0");
expect(
projectAccentFromAsset({
_tag: "Success",
url: testState.faviconUrl,
projectAccent: {
idle: "#071525",
hover: "#102b46",
selected: "#173b60",
},
}),
).toEqual({ idle: "#071525", hover: "#102b46", selected: "#173b60" });
});
});
27 changes: 24 additions & 3 deletions apps/web/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { EnvironmentId } from "@t3tools/contracts";
import type { EnvironmentId, ProjectAccent } from "@t3tools/contracts";
import {
getProjectFaviconCacheKey,
isProjectFaviconFallbackUrl,
Expand All @@ -11,14 +11,29 @@ import { cn } from "~/lib/utils";

const loadedProjectFaviconSrcs = new Map<string, string>();

export function ProjectFavicon(input: {
interface ProjectFaviconInput {
environmentId: EnvironmentId;
cwd: string;
faviconPath?: string | null | undefined;
className?: string | undefined;
fallbackIcon?: ComponentType<{ className?: string }>;
}) {
}

export function ProjectFavicon(input: ProjectFaviconInput) {
const state = useProjectFaviconAsset(input);
return renderProjectFavicon(input, state);
}

export function ProjectFaviconFromAsset(
input: ProjectFaviconInput & { readonly state: ReturnType<typeof useProjectFaviconAsset> },
) {
return renderProjectFavicon(input, input.state);
}

function renderProjectFavicon(
input: ProjectFaviconInput,
state: ReturnType<typeof useProjectFaviconAsset>,
) {
const src = state._tag === "Success" ? state.url : null;
const FallbackIcon = input.fallbackIcon ?? FolderIcon;

Expand Down Expand Up @@ -51,6 +66,12 @@ export function useProjectFaviconAsset(input: {
});
}

export function projectAccentFromAsset(
state: ReturnType<typeof useProjectFaviconAsset>,
): ProjectAccent | null {
return state._tag === "Success" ? (state.projectAccent ?? null) : null;
}

function ProjectFaviconFallback({
className,
icon: Icon,
Expand Down
34 changes: 31 additions & 3 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,12 @@ import {
snoozeWakeLabel,
type SnoozePreset,
} from "./Sidebar.snooze";
import { ProjectFavicon } from "./ProjectFavicon";
import {
ProjectFavicon,
ProjectFaviconFromAsset,
projectAccentFromAsset,
useProjectFaviconAsset,
} from "./ProjectFavicon";
import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
import {
Expand All @@ -171,6 +176,7 @@ import {
type ProviderInstanceEntry,
} from "../providerInstances";
import { useThreadRunningTerminalIds } from "../state/terminalSessions";
import { projectAccentRowState, projectAccentRowStyle } from "../projectAccent";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
Expand Down Expand Up @@ -788,6 +794,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
});
const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
const terminalProcessCount = runningTerminalIds.length;
const projectFaviconAsset = useProjectFaviconAsset({
environmentId: thread.environmentId,
cwd: props.projectCwd ?? "",
faviconPath: props.projectFaviconPath,
});
const projectAccent = projectAccentFromAsset(projectFaviconAsset);

const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
Expand Down Expand Up @@ -1206,6 +1218,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
role="button"
tabIndex={0}
data-testid="sidebar-row-slim"
data-project-accent={projectAccent === null ? undefined : "true"}
data-project-accent-state={projectAccentRowState(
projectAccent,
props.isActive,
isSelected,
)}
style={projectAccentRowStyle(projectAccent)}
aria-busy={isRegeneratingTitle || undefined}
className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}
onClick={handleClick}
Expand All @@ -1224,12 +1243,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
"opacity-40 grayscale group-hover/sidebar-row:opacity-100 group-hover/sidebar-row:grayscale-0",
)}
>
<ProjectFavicon
<ProjectFaviconFromAsset
environmentId={thread.environmentId}
cwd={props.projectCwd ?? ""}
faviconPath={props.projectFaviconPath}
className="size-4"
fallbackIcon={MessageSquareIcon}
state={projectFaviconAsset}
/>
</span>
{title}
Expand Down Expand Up @@ -1366,6 +1386,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
role="button"
tabIndex={0}
data-testid="sidebar-row-card"
data-project-accent={projectAccent === null ? undefined : "true"}
data-project-accent-state={projectAccentRowState(
projectAccent,
props.isActive,
isSelected,
)}
style={projectAccentRowStyle(projectAccent)}
aria-busy={isRegeneratingTitle || undefined}
className={rowSurfaceClassName}
onClick={handleClick}
Expand All @@ -1377,11 +1404,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
>
<div className="relative z-10 h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
<div className="flex h-5 min-w-0 items-center gap-1.5">
<ProjectFavicon
<ProjectFaviconFromAsset
environmentId={thread.environmentId}
cwd={props.projectCwd ?? ""}
faviconPath={props.projectFaviconPath}
className="size-4 shrink-0"
state={projectFaviconAsset}
/>
{props.projectTitle ? (
<span
Expand Down
Loading
Loading