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
5 changes: 4 additions & 1 deletion apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
import { ClientSettingsSchema, EnvironmentId, type ClientSettings } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand All @@ -18,6 +18,9 @@ const clientSettings: ClientSettings = {
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
diffIgnoreWhitespace: true,
environmentAccentColors: {
[EnvironmentId.make("environment-1")]: "#2563eb",
},
favorites: [],
glassOpacity: 80,
providerModelPreferences: {},
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/accentColors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Shared accent-color primitives.
*
* Accent colors are user-chosen hex strings used to tell otherwise identical
* UI affordances apart — provider instances in picker rails, environments in
* the sidebar. They are always stored normalized so rendering can treat a
* present value as directly usable in CSS.
*/

export const ACCENT_COLOR_SWATCHES = [
"#2563eb",
"#16a34a",
"#ea580c",
"#dc2626",
"#7c3aed",
"#0891b2",
] as const;

export const FALLBACK_ACCENT_COLOR = ACCENT_COLOR_SWATCHES[0];

/** Accept only `#rrggbb`; anything else (including "") reads as "no color". */
export function normalizeAccentColor(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined;
}
37 changes: 22 additions & 15 deletions apps/web/src/components/BranchToolbarEnvironmentSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import type { EnvironmentId } from "@t3tools/contracts";
import { CloudIcon, MonitorIcon } from "lucide-react";
import { memo, useMemo } from "react";

import {
environmentAccentStyle,
resolveEnvironmentAccentColor,
useEnvironmentAccentColors,
type EnvironmentAccentColors,
} from "../environmentAccentColors";
import type { EnvironmentOption } from "./BranchToolbar.logic";
import {
Select,
Expand All @@ -22,12 +28,25 @@ interface BranchToolbarEnvironmentSelectorProps {
onEnvironmentChange?: (environmentId: EnvironmentId) => void;
}

function EnvironmentIcon({
accentColors,
environment,
}: {
readonly accentColors: EnvironmentAccentColors;
readonly environment: EnvironmentOption | null;
}) {
const accentColor = resolveEnvironmentAccentColor(accentColors, environment?.environmentId);
const Icon = environment?.isPrimary ? MonitorIcon : CloudIcon;
return <Icon className="size-3" style={environmentAccentStyle(accentColor)} />;
}

export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({
envLocked,
environmentId,
availableEnvironments,
onEnvironmentChange,
}: BranchToolbarEnvironmentSelectorProps) {
const accentColors = useEnvironmentAccentColors();
const activeEnvironment = useMemo(() => {
return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null;
}, [availableEnvironments, environmentId]);
Expand All @@ -44,11 +63,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
if (envLocked || onEnvironmentChange === undefined) {
return (
<span className="inline-flex items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs">
{activeEnvironment?.isPrimary ? (
<MonitorIcon className="size-3" />
) : (
<CloudIcon className="size-3" />
)}
<EnvironmentIcon accentColors={accentColors} environment={activeEnvironment} />
{activeEnvironment?.label ?? "Run on"}
</span>
);
Expand All @@ -62,11 +77,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
items={environmentItems}
>
<SelectTrigger variant="ghost" size="xs" className="font-medium" aria-label="Run on">
{activeEnvironment?.isPrimary ? (
<MonitorIcon className="size-3" />
) : (
<CloudIcon className="size-3" />
)}
<EnvironmentIcon accentColors={accentColors} environment={activeEnvironment} />
<SelectValue />
</SelectTrigger>
<SelectPopup>
Expand All @@ -75,11 +86,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
{availableEnvironments.map((env) => (
<SelectItem key={env.environmentId} value={env.environmentId}>
<span className="inline-flex items-center gap-1.5">
{env.isPrimary ? (
<MonitorIcon className="size-3" />
) : (
<CloudIcon className="size-3" />
)}
<EnvironmentIcon accentColors={accentColors} environment={env} />
{env.label}
</span>
</SelectItem>
Expand Down
22 changes: 19 additions & 3 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { CommandDialogTrigger } from "./ui/command";
import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
import {
environmentAccentStyle,
resolveSharedEnvironmentAccentColor,
useEnvironmentAccentColor,
useEnvironmentAccentColors,
} from "~/environmentAccentColors";
import { primaryServerKeybindingsAtom } from "../state/server";
import {
derivePhysicalProjectKey,
Expand Down Expand Up @@ -396,6 +402,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
const threadEnvironmentLabel = isRemoteThread
? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote"))
: null;
const threadEnvironmentAccentColor = useEnvironmentAccentColor(thread.environmentId);
// For grouped projects, the thread may belong to a different environment
// than the representative project. Look up the thread's own project cwd
// so git status (and thus PR detection) queries the correct path.
Expand Down Expand Up @@ -836,7 +843,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
/>
}
>
<CloudIcon className="size-3 text-muted-foreground/40" />
<CloudIcon
className="size-3 text-muted-foreground/40"
style={environmentAccentStyle(threadEnvironmentAccentColor)}
/>
</TooltipTrigger>
<TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
</Tooltip>
Expand Down Expand Up @@ -1101,6 +1111,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
(settings) => settings.confirmThreadArchive,
);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const environmentAccentColors = useEnvironmentAccentColors();
const remoteEnvironmentAccentColor = resolveSharedEnvironmentAccentColor(
environmentAccentColors,
project.remoteEnvironmentIds,
);
const remoteEnvironmentAccentStyle = environmentAccentStyle(remoteEnvironmentAccentColor);
const deleteProject = useAtomCommand(projectEnvironment.delete, {
reportFailure: false,
});
Expand Down Expand Up @@ -2288,9 +2304,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
}
>
{project.allRemoteMembersAreDesktopLocal ? (
<ContainerIcon className="size-3" />
<ContainerIcon className="size-3" style={remoteEnvironmentAccentStyle} />
) : (
<CloudIcon className="size-3" />
<CloudIcon className="size-3" style={remoteEnvironmentAccentStyle} />
)}
</TooltipTrigger>
<TooltipPopup side="top">
Expand Down
28 changes: 24 additions & 4 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
scopeThreadRef,
scopedThreadKey,
} from "@t3tools/client-runtime/environment";
import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
import type {
EnvironmentId,
ScopedThreadRef,
SidebarProjectGroupingMode,
} from "@t3tools/contracts";
import {
AlarmClockIcon,
AlarmClockOffIcon,
Expand Down Expand Up @@ -86,6 +90,7 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { openCommandPalette } from "../commandPaletteBus";
import { startNewThreadFromContext } from "../lib/chatThreadActions";
import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings";
import { environmentAccentStyle, useEnvironmentAccentColor } from "../environmentAccentColors";
import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
import { useNowMinute } from "../hooks/useNowMinute";
import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
Expand Down Expand Up @@ -212,6 +217,17 @@ function WorkingDuration(props: { startedAt: string | null }) {
return <span className="tabular-nums">{formatWorkingDurationLabel(Date.now() - startedMs)}</span>;
}

/** Server glyph tinted with the environment's accent color, when one is set. */
function EnvironmentServerIcon({ environmentId }: { readonly environmentId: EnvironmentId }) {
const accentColor = useEnvironmentAccentColor(environmentId);
return (
<ServerIcon
className="size-4 shrink-0 stroke-muted-foreground"
style={environmentAccentStyle(accentColor, "stroke")}
/>
);
}

function SidebarV2ThreadTooltip({
thread,
projectTitle,
Expand Down Expand Up @@ -260,7 +276,7 @@ function SidebarV2ThreadTooltip({
) : null}
{environmentLabel ? (
<div className="flex min-w-0 items-center gap-2">
<ServerIcon className="size-4 shrink-0 stroke-muted-foreground" />
<EnvironmentServerIcon environmentId={thread.environmentId} />
<div className="min-w-0 wrap-break-word text-foreground/90">{environmentLabel}</div>
</div>
) : null}
Expand Down Expand Up @@ -525,6 +541,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {

const isRemote =
props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId;
const environmentAccentColor = useEnvironmentAccentColor(thread.environmentId);

const detailsTooltip = (
<SidebarV2ThreadTooltip
Expand Down Expand Up @@ -962,7 +979,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
className="pointer-events-none ml-auto inline-flex shrink-0 items-center gap-1"
>
{isRemote ? (
<span className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70">
<span
className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70"
style={environmentAccentStyle(environmentAccentColor)}
>
<ServerIcon aria-hidden className="size-3.5" />
</span>
) : null}
Expand Down Expand Up @@ -2584,7 +2604,7 @@ export default function SidebarV2() {
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5 text-base text-muted-foreground sm:text-sm">
<ServerIcon className="size-4 shrink-0 stroke-muted-foreground" />
<EnvironmentServerIcon environmentId={member.environmentId} />
<p className="min-w-0 truncate">
{member.environmentLabel ?? "Current environment"}
</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,19 @@ import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent }
import { ColorSelector } from "../color-selector";
import { Button } from "../ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { normalizeProviderAccentColor } from "../../providerInstances";
import {
ACCENT_COLOR_SWATCHES,
FALLBACK_ACCENT_COLOR,
normalizeAccentColor,
} from "../../accentColors";
import { cn } from "../../lib/utils";

const PROVIDER_ACCENT_SWATCHES = [
"#2563eb",
"#16a34a",
"#ea580c",
"#dc2626",
"#7c3aed",
"#0891b2",
] as const;

const FALLBACK_ACCENT_COLOR = PROVIDER_ACCENT_SWATCHES[0];

function clamp(value: number, min = 0, max = 1) {
return Math.min(max, Math.max(min, value));
}

function hexToHsv(hex: string) {
const normalized = normalizeProviderAccentColor(hex) ?? FALLBACK_ACCENT_COLOR;
const normalized = normalizeAccentColor(hex) ?? FALLBACK_ACCENT_COLOR;
const numeric = Number.parseInt(normalized.slice(1), 16);
const red = ((numeric >> 16) & 255) / 255;
const green = ((numeric >> 8) & 255) / 255;
Expand Down Expand Up @@ -80,7 +73,7 @@ function hsvToHex(hue: number, saturation: number, value: number) {
.join("")}`;
}

function ProviderCustomColorPanel(props: {
function CustomColorPanel(props: {
readonly value: string;
readonly onCommit: (value: string) => void;
}) {
Expand Down Expand Up @@ -178,13 +171,13 @@ function ProviderCustomColorPanel(props: {
);
}

function ProviderCustomColorPicker(props: {
function CustomColorPicker(props: {
readonly displayName: string;
readonly value: string | undefined;
readonly selected: boolean;
readonly onCommit: (value: string) => void;
}) {
const normalized = normalizeProviderAccentColor(props.value) ?? FALLBACK_ACCENT_COLOR;
const normalized = normalizeAccentColor(props.value) ?? FALLBACK_ACCENT_COLOR;

return (
<Popover>
Expand Down Expand Up @@ -216,20 +209,30 @@ function ProviderCustomColorPicker(props: {
sideOffset={6}
className="overflow-hidden rounded-md p-0 [--viewport-inline-padding:0px] [&_[data-slot=popover-viewport]]:p-0"
>
<ProviderCustomColorPanel value={normalized} onCommit={props.onCommit} />
<CustomColorPanel value={normalized} onCommit={props.onCommit} />
</PopoverPopup>
</Popover>
);
}

export function ProviderAccentColorPicker(props: {
export function AccentColorPicker(props: {
/** Name of the thing being colored; used to disambiguate the aria labels. */
readonly displayName: string;
readonly value: string | undefined;
readonly onCommit: (value: string) => void;
/** Heading above the swatches. Pass `null` where the surrounding UI already names it. */
readonly label?: string | null;
readonly description?: string;
readonly commitDelayMs?: number;
}) {
const { commitDelayMs = 0, description, displayName, onCommit, value } = props;
const {
commitDelayMs = 0,
description,
displayName,
label = "Accent color",
onCommit,
value,
} = props;
const [optimisticValue, setOptimisticValue] = useState(() => value ?? "");
const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingCommitRef = useRef<string | null>(null);
Expand Down Expand Up @@ -258,7 +261,7 @@ export function ProviderAccentColorPicker(props: {

const commitAccentColor = useCallback(
(value: string) => {
const normalizedValue = normalizeProviderAccentColor(value) ?? "";
const normalizedValue = normalizeAccentColor(value) ?? "";
setOptimisticValue(normalizedValue);

if (commitDelayMs <= 0) {
Expand Down Expand Up @@ -287,27 +290,27 @@ export function ProviderAccentColorPicker(props: {
[commitDelayMs, onCommit],
);

const normalized = normalizeProviderAccentColor(optimisticValue);
const normalized = normalizeAccentColor(optimisticValue);
const selectedValue =
normalized &&
PROVIDER_ACCENT_SWATCHES.includes(normalized as (typeof PROVIDER_ACCENT_SWATCHES)[number])
ACCENT_COLOR_SWATCHES.includes(normalized as (typeof ACCENT_COLOR_SWATCHES)[number])
? normalized
: "";
const customSelected = Boolean(normalized && selectedValue === "");

return (
<div className="grid gap-2">
<span className="text-xs font-medium text-foreground">Accent color</span>
{label === null ? null : <span className="text-xs font-medium text-foreground">{label}</span>}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<ProviderCustomColorPicker
<CustomColorPicker
displayName={displayName}
value={normalized}
selected={customSelected}
onCommit={commitAccentColor}
/>
<ColorSelector
key={selectedValue}
colors={[...PROVIDER_ACCENT_SWATCHES]}
colors={[...ACCENT_COLOR_SWATCHES]}
defaultValue={selectedValue}
size="lg"
onColorSelect={commitAccentColor}
Expand Down
Loading
Loading