From 95305c36fa418301183e4750f13b6a525a20cafe Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 03:57:22 -0400 Subject: [PATCH 1/6] feat(web): per-device provider settings (#4479) Co-authored-by: Claude Opus 5 (1M context) --- .../src/components/ConnectionStatusDot.tsx | 22 + ...roviderInstanceDialog.environment.test.tsx | 54 ++ .../settings/AddProviderInstanceDialog.tsx | 24 +- ...ProviderSettingsPanel.environment.test.tsx | 256 +++++ .../ProviderSettingsPanel.logic.test.ts | 260 +++++ .../settings/ProviderSettingsPanel.logic.ts | 160 ++++ .../settings/ProviderSettingsPanel.tsx | 902 ++++++++++++++++++ .../settings/SettingsPanels.logic.ts | 57 ++ .../components/settings/SettingsPanels.tsx | 683 +------------ .../components/settings/settingsLayout.tsx | 24 +- apps/web/src/routes/settings.providers.tsx | 2 +- apps/web/src/state/server.ts | 2 +- apps/web/src/state/session.ts | 16 +- apps/web/src/test/reactElementTree.ts | 27 + apps/web/src/test/reactHookHarness.ts | 89 ++ packages/client-runtime/src/state/session.ts | 71 +- 16 files changed, 1961 insertions(+), 688 deletions(-) create mode 100644 apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.logic.ts create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.tsx create mode 100644 apps/web/src/test/reactElementTree.ts create mode 100644 apps/web/src/test/reactHookHarness.ts diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx index 0c22f1702e5..2efddcfa736 100644 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -1,6 +1,28 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; + import { cn } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +/** Canonical connection-phase → dot color mapping shared by every status dot. */ +export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): string { + switch (phase) { + case "connected": + return "bg-success"; + case "connecting": + case "reconnecting": + return "bg-warning"; + case "error": + return "bg-destructive"; + default: + return "bg-muted-foreground/40"; + } +} + +/** Ping halo for transitional phases; null renders no ping. */ +export function connectionPhasePingClassName(phase: EnvironmentConnectionPhase): string | null { + return phase === "connecting" || phase === "reconnecting" ? "bg-warning/60 duration-2000" : null; +} + type ConnectionStatusDotProps = { tooltipText?: string | null; dotClassName: string; diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx new file mode 100644 index 00000000000..3c502c624dd --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx @@ -0,0 +1,54 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +const settingsHooks = vi.hoisted(() => ({ + read: vi.fn(() => ({ providerInstances: {} })), + update: vi.fn(() => vi.fn()), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useMemo: reactHookHarness.useMemo, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("../../hooks/useSettings", () => ({ + useEnvironmentSettings: settingsHooks.read, + useUpdateEnvironmentSettings: settingsHooks.update, +})); + +import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; + +const remoteEnvironmentId = EnvironmentId.make("remote-device"); + +describe("AddProviderInstanceDialog environment routing", () => { + beforeEach(() => { + hooks.reset(); + settingsHooks.read.mockClear(); + settingsHooks.update.mockClear(); + }); + + it("reads and writes settings through the supplied environment", () => { + hooks.beginRender(); + AddProviderInstanceDialog({ + open: true, + environmentId: remoteEnvironmentId, + environmentLabel: "Remote device", + onOpenChange: vi.fn(), + }); + + expect(settingsHooks.read).toHaveBeenCalledWith(remoteEnvironmentId); + expect(settingsHooks.update).toHaveBeenCalledWith(remoteEnvironmentId); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index a6da37c1551..158908b5e94 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -6,10 +6,11 @@ import { useMemo, useState } from "react"; import { ProviderInstanceId, ProviderDriverKind, + type EnvironmentId, type ProviderInstanceConfig, } from "@t3tools/contracts"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; @@ -115,13 +116,20 @@ function validateInstanceId(id: string, existing: ReadonlySet): string | } interface AddProviderInstanceDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; + readonly open: boolean; + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly onOpenChange: (open: boolean) => void; } -export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); +export function AddProviderInstanceDialog({ + open, + environmentId, + environmentLabel, + onOpenChange, +}: AddProviderInstanceDialogProps) { + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); @@ -227,8 +235,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns Add provider instance - Configure an additional provider instance — for example, a second Codex install - pointed at a different workspace. + Configure an additional provider instance on {environmentLabel} — for example, a + second Codex install pointed at a different workspace. ({ + providers: null as ReadonlyArray | null, + providersAtom: Symbol("providers"), + refreshProviders: Symbol("refreshProviders"), + updateProvider: Symbol("updateProvider"), +})); + +const commands = vi.hoisted(() => ({ + refresh: vi.fn(), + updateProvider: vi.fn(), +})); + +const settingsState = vi.hoisted(() => ({ + value: null as UnifiedSettings | null, + readEnvironmentIds: [] as EnvironmentId[], + updateEnvironmentIds: [] as EnvironmentId[], + updateSettings: vi.fn(), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => atoms.providers, +})); + +vi.mock("../../state/server", () => ({ + EMPTY_SERVER_PROVIDERS: [], + serverEnvironment: { + providersValueAtom: () => atoms.providersAtom, + refreshProviders: atoms.refreshProviders, + updateProvider: atoms.updateProvider, + }, +})); + +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (atom: symbol) => + atom === atoms.refreshProviders ? commands.refresh : commands.updateProvider, +})); + +vi.mock("../../hooks/useSettings", () => ({ + useEnvironmentSettings: (environmentId: EnvironmentId) => { + settingsState.readEnvironmentIds.push(environmentId); + return settingsState.value; + }, + useUpdateEnvironmentSettings: (environmentId: EnvironmentId) => { + settingsState.updateEnvironmentIds.push(environmentId); + return settingsState.updateSettings; + }, +})); + +vi.mock("../../environments/primary", () => ({ + usePrimarySessionState: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }), +})); + +vi.mock("../../state/session", () => ({ + useEnvironmentSessionState: () => ({ data: null, hasError: false, isPending: true }), +})); + +import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; + +const environmentId = EnvironmentId.make("remote-device"); +const codexId = ProviderInstanceId.make("codex"); +const customId = ProviderInstanceId.make("codex_work"); + +function provider(): ServerProvider { + return { + instanceId: codexId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-07-24T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateCommand: "pnpm add -g @openai/codex@latest", + canUpdate: true, + checkedAt: "2026-07-24T12:00:00.000Z", + message: "Update available.", + }, + }; +} + +function renderPanel(options?: { + readonly readOnly?: boolean; +}): ReactElement> { + hooks.beginRender(); + return EnvironmentProviderSettings({ + environmentId, + environmentLabel: "Remote device", + ...(options?.readOnly === undefined ? {} : { readOnly: options.readOnly }), + }) as ReactElement>; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("EnvironmentProviderSettings routing", () => { + beforeEach(() => { + hooks.reset(); + atoms.providers = null; + settingsState.value = DEFAULT_UNIFIED_SETTINGS; + settingsState.readEnvironmentIds = []; + settingsState.updateEnvironmentIds = []; + settingsState.updateSettings.mockReset(); + commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); + commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); + }); + + it("coalesces a nullable provider snapshot before rendering array-backed UI", () => { + expect(() => renderPanel()).not.toThrow(); + expect(settingsState.readEnvironmentIds).toEqual([environmentId]); + expect(settingsState.updateEnvironmentIds).toEqual([environmentId]); + }); + + it("routes refresh and provider update commands to the selected environment", async () => { + atoms.providers = [provider()]; + const panel = renderPanel(); + const refreshButton = visitElements( + panel, + (element) => element.props["aria-label"] === "Refresh provider status", + ); + expect(refreshButton).not.toBeNull(); + (refreshButton?.props.onClick as (() => void) | undefined)?.(); + await flushPromises(); + + expect(commands.refresh).toHaveBeenCalledWith({ environmentId, input: {} }); + + const providerCard = visitElements( + panel, + (element) => + element.props.instanceId === codexId && typeof element.props.onRunUpdate === "function", + ); + expect(providerCard).not.toBeNull(); + (providerCard?.props.onRunUpdate as (() => void) | undefined)?.(); + await flushPromises(); + + expect(commands.updateProvider).toHaveBeenCalledWith({ + environmentId, + input: { provider: ProviderDriverKind.make("codex"), instanceId: codexId }, + }); + }); + + it("renders the provider layout inert with a limited-permissions notice when read only", () => { + atoms.providers = [provider()]; + const panel = renderPanel({ readOnly: true }); + + const inertWrapper = visitElements(panel, (element) => element.props.inert === true); + expect(inertWrapper).not.toBeNull(); + const providerCard = visitElements(panel, (element) => element.props.instanceId === codexId); + expect(providerCard).not.toBeNull(); + + const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); + expect(notice).not.toBeNull(); + + expect( + visitElements(panel, (element) => element.props["aria-label"] === "Add provider instance"), + ).toBeNull(); + expect( + visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"), + ).toBeNull(); + }); + + it("keeps the editable layout interactive when not read only", () => { + atoms.providers = [provider()]; + const panel = renderPanel(); + expect(visitElements(panel, (element) => element.props.inert === true)).toBeNull(); + expect( + visitElements(panel, (element) => element.props.title === "Limited permissions"), + ).toBeNull(); + }); + + it("deletes and resets provider configuration without erasing shared preferences", () => { + settingsState.value = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + }, + [customId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + }, + }, + providerModelPreferences: { + [customId]: { hiddenModels: ["hidden"], modelOrder: ["model"] }, + }, + favorites: [{ provider: customId, model: "favorite" }], + }; + const panel = renderPanel(); + const customCard = visitElements(panel, (element) => element.props.instanceId === customId); + expect(customCard).not.toBeNull(); + (customCard?.props.onDelete as (() => void) | undefined)?.(); + + expect(settingsState.updateSettings).toHaveBeenLastCalledWith({ + providerInstances: { + [codexId]: settingsState.value.providerInstances?.[codexId], + }, + }); + + settingsState.updateSettings.mockClear(); + const defaultCard = visitElements(panel, (element) => element.props.instanceId === codexId); + const resetAction = defaultCard?.props.headerAction; + const resetButton = visitElements( + resetAction, + (element) => typeof element.props.onClick === "function", + ); + expect(resetButton).not.toBeNull(); + (resetButton?.props.onClick as (() => void) | undefined)?.(); + + const resetPatch = settingsState.updateSettings.mock.lastCall?.[0] as + | Record + | undefined; + expect(Object.keys(resetPatch ?? {}).sort()).toEqual(["providerInstances", "providers"]); + expect(resetPatch).not.toHaveProperty("favorites"); + expect(resetPatch).not.toHaveProperty("providerModelPreferences"); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts new file mode 100644 index 00000000000..bf558f5a4d6 --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -0,0 +1,260 @@ +import { AuthOrchestrationOperateScope, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildProviderEnvironmentOptions, + classifyProviderEnvironmentAccess, + resolvePrimaryOperateAccess, + resolveRemoteOperateAccess, + resolveSelectedProviderEnvironmentId, +} from "./ProviderSettingsPanel.logic"; + +const primaryId = EnvironmentId.make("primary"); +const relayId = EnvironmentId.make("relay"); +const sshId = EnvironmentId.make("ssh"); + +const environments = [ + { environmentId: sshId, label: "Zulu SSH" }, + { environmentId: relayId, label: "Alpha Relay" }, + { environmentId: primaryId, label: "This device" }, +] as const; + +describe("provider environment selection", () => { + it("sorts the primary environment first and the rest by label", () => { + expect( + buildProviderEnvironmentOptions(environments, primaryId).map( + (environment) => environment.environmentId, + ), + ).toEqual([primaryId, relayId, sshId]); + }); + + it("keeps a valid selection, then falls back to primary or the first environment", () => { + const options = buildProviderEnvironmentOptions(environments, primaryId); + + expect(resolveSelectedProviderEnvironmentId(options, sshId, primaryId)).toBe(sshId); + expect( + resolveSelectedProviderEnvironmentId( + options.filter((environment) => environment.environmentId !== sshId), + sshId, + primaryId, + ), + ).toBe(primaryId); + expect(resolveSelectedProviderEnvironmentId(options.slice(1), primaryId, primaryId)).toBe( + relayId, + ); + expect(resolveSelectedProviderEnvironmentId([], null, primaryId)).toBeNull(); + }); +}); + +describe("provider environment access", () => { + it("allows connected environments with config and operate access", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: true, + operateAccess: "granted", + }), + ).toEqual({ kind: "editable" }); + }); + + it("waits for config before exposing controls", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: false, + operateAccess: "granted", + }), + ).toEqual({ kind: "loading", reason: "config" }); + }); + + it("waits for unresolved operate access instead of assuming it is editable", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: true, + operateAccess: "pending", + }), + ).toEqual({ kind: "loading", reason: "permissions" }); + }); + + it("represents known missing operate access as read only", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: true, + operateAccess: "denied", + }), + ).toEqual({ kind: "read-only" }); + }); + + it.each(["available", "offline", "connecting", "reconnecting"] as const)( + "keeps %s environments unavailable", + (connectionPhase) => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase, + hasServerConfig: true, + operateAccess: "granted", + }), + ).toEqual({ kind: "unavailable" }); + }, + ); + + it("separates connection errors from other unavailable states", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "error", + hasServerConfig: true, + operateAccess: "granted", + }), + ).toEqual({ kind: "error" }); + }); +}); + +describe("primary operate access", () => { + const authenticated = { + authenticated: true as const, + scopes: [AuthOrchestrationOperateScope], + }; + + it("keeps cached session data authoritative while SWR revalidates", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: authenticated, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + }); + + it("reports pending only before any session has resolved", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: null, + isPending: true, + hasError: false, + }), + ).toBe("pending"); + }); + + it("treats a failed session fetch as a transport problem, not a denial", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: null, + isPending: false, + hasError: true, + }), + ).toBe("granted"); + }); + + it("denies unauthenticated sessions and sessions without the operate scope", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: { authenticated: false }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: { authenticated: true, scopes: ["orchestration:read"] }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: null, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + }); + + it("grants desktop bridge and remote environments without blocking on the primary session", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: true, + session: null, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + expect( + resolvePrimaryOperateAccess({ + isPrimary: false, + hasDesktopBridge: false, + session: null, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + }); +}); + +describe("remote operate access", () => { + it("derives access from the environment session's granted scopes", () => { + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, + isPending: false, + hasError: false, + }), + ).toBe("granted"); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: ["orchestration:read"] }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: false }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + }); + + it("reports pending before the first session resolve, then keeps cached data", () => { + expect(resolveRemoteOperateAccess({ session: null, isPending: true, hasError: false })).toBe( + "pending", + ); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + }); + + it("stays optimistic when the session fetch fails or an older server omits scopes", () => { + // Transport failures and pre-scope-reporting servers are not permission + // decisions; the environment RPC layer still rejects unauthorized writes. + expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe( + "granted", + ); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true }, + isPending: false, + hasError: false, + }), + ).toBe("granted"); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts new file mode 100644 index 00000000000..1c7dac391f6 --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -0,0 +1,160 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { + AuthOrchestrationOperateScope, + type AuthSessionState, + type EnvironmentId, +} from "@t3tools/contracts"; + +export interface ProviderEnvironmentOptionLike { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +export function buildProviderEnvironmentOptions( + environments: ReadonlyArray, + primaryEnvironmentId: EnvironmentId | null, +): ReadonlyArray { + return environments.toSorted((left, right) => { + const leftIsPrimary = left.environmentId === primaryEnvironmentId; + const rightIsPrimary = right.environmentId === primaryEnvironmentId; + if (leftIsPrimary !== rightIsPrimary) { + return leftIsPrimary ? -1 : 1; + } + return ( + left.label.localeCompare(right.label) || + String(left.environmentId).localeCompare(String(right.environmentId)) + ); + }); +} + +export function resolveSelectedProviderEnvironmentId( + environments: ReadonlyArray, + selectedEnvironmentId: EnvironmentId | null, + primaryEnvironmentId: EnvironmentId | null, +): EnvironmentId | null { + if ( + selectedEnvironmentId !== null && + environments.some((environment) => environment.environmentId === selectedEnvironmentId) + ) { + return selectedEnvironmentId; + } + if ( + primaryEnvironmentId !== null && + environments.some((environment) => environment.environmentId === primaryEnvironmentId) + ) { + return primaryEnvironmentId; + } + return environments[0]?.environmentId ?? null; +} + +export type ProviderEnvironmentAccess = + | { readonly kind: "editable" } + /** `reason` distinguishes waiting on the device from waiting on permissions. */ + | { readonly kind: "loading"; readonly reason: "config" | "permissions" } + | { readonly kind: "read-only" } + | { readonly kind: "unavailable" } + | { readonly kind: "error" }; + +/** + * Whether the session may change provider configuration on an environment. + * `pending` means the answer is still unknown, which must not be presented as + * editable: rendering controls we already know might be rejected only turns a + * permission problem into a failed write. + */ +export type ProviderOperateAccess = "granted" | "denied" | "pending"; + +/** + * Resolve operate access from an environment's `/api/auth/session` answer. + * + * Cached session data wins over an in-flight revalidation. The session atoms + * are SWR-backed, so they report `isPending` on every background refresh; + * treating that as unknown would flip a working panel back to loading and + * discard in-progress edits. + * + * `missingScopesAccess` decides the case where the session resolved but did + * not report scopes: the primary serves the web app itself so its server + * always reports them (absence means denial), while a remote device may run an + * older server version that predates scope reporting, where denial would lock + * out a legitimate session. The environment RPC layer stays authoritative + * either way. + */ +function resolveSessionOperateAccess(input: { + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; + readonly missingScopesAccess: "granted" | "denied"; +}): ProviderOperateAccess { + if (input.session === null) { + if (input.isPending) { + return "pending"; + } + // A failed session fetch is a transport problem, not a permission + // decision — locking the panel read-only would misreport it. Stay + // optimistic; the environment RPC layer still rejects unauthorized writes. + return input.hasError ? "granted" : "denied"; + } + if (!input.session.authenticated) { + return "denied"; + } + if (input.session.scopes === undefined) { + return input.missingScopesAccess; + } + return input.session.scopes.includes(AuthOrchestrationOperateScope) ? "granted" : "denied"; +} + +/** Operate access for the primary environment's own browser session. */ +export function resolvePrimaryOperateAccess(input: { + readonly isPrimary: boolean; + readonly hasDesktopBridge: boolean; + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; +}): ProviderOperateAccess { + if (!input.isPrimary || input.hasDesktopBridge) { + return "granted"; + } + return resolveSessionOperateAccess({ + session: input.session, + isPending: input.isPending, + hasError: input.hasError, + missingScopesAccess: "denied", + }); +} + +/** + * Operate access for a non-primary environment, derived from the scopes its + * `/api/auth/session` endpoint reports for this client's credential. + */ +export function resolveRemoteOperateAccess(input: { + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; +}): ProviderOperateAccess { + return resolveSessionOperateAccess({ + ...input, + missingScopesAccess: "granted", + }); +} + +export function classifyProviderEnvironmentAccess(input: { + readonly connectionPhase: EnvironmentConnectionPhase; + readonly hasServerConfig: boolean; + readonly operateAccess: ProviderOperateAccess; +}): ProviderEnvironmentAccess { + if (input.connectionPhase === "error") { + return { kind: "error" }; + } + if (input.connectionPhase !== "connected") { + return { kind: "unavailable" }; + } + if (!input.hasServerConfig) { + return { kind: "loading", reason: "config" }; + } + if (input.operateAccess === "pending") { + return { kind: "loading", reason: "permissions" }; + } + if (input.operateAccess === "denied") { + return { kind: "read-only" }; + } + return { kind: "editable" }; +} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx new file mode 100644 index 00000000000..c06826d450d --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -0,0 +1,902 @@ +import { useAtomValue } from "@effect/atom-react"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + defaultInstanceIdForDriver, + type EnvironmentId, + PROVIDER_DISPLAY_NAMES, + ProviderDriverKind, + type ProviderInstanceConfig, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + getBackgroundActivityPresetSettings, + resolveServerBackgroundActivitySettings, +} from "@t3tools/shared/backgroundActivitySettings"; +import * as Arr from "effect/Array"; +import * as Duration from "effect/Duration"; +import * as Equal from "effect/Equal"; +import * as Result from "effect/Result"; +import { + CloudIcon, + LaptopIcon, + LoaderIcon, + MonitorIcon, + PlusIcon, + RefreshCwIcon, + TerminalIcon, +} from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; + +import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; +import { isElectron } from "../../env"; +import { usePrimarySessionState } from "../../environments/primary"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { resolveAppModelSelectionState } from "../../modelSelection"; +import { + useEnvironments, + usePrimaryEnvironmentId, + type EnvironmentPresentation, +} from "../../state/environments"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useEnvironmentSessionState } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { getRelativeTimeState } from "../../timestampFormat"; +import { + ConnectionStatusDot, + connectionPhaseDotClassName, + connectionPhasePingClassName, +} from "../ConnectionStatusDot"; +import { + canOneClickUpdateProviderCandidate, + collectProviderUpdateCandidates, + hasOneClickUpdateProviderCandidate, + isProviderUpdateActive, + type ProviderUpdateCandidate, +} from "../ProviderUpdateLaunchNotification.logic"; +import { Button } from "../ui/button"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; +import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; +import { searchableSetting } from "./settingsSearch"; +import { + backgroundActivityOverrideSettings, + buildProviderInstanceUpdatePatch, + durationToSeconds, + normalizeIntervalSeconds, + PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, +} from "./SettingsPanels.logic"; +import { + PolicyTooltip, + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, + useRelativeTimeTick, +} from "./settingsLayout"; +import { + buildProviderEnvironmentOptions, + classifyProviderEnvironmentAccess, + type ProviderEnvironmentAccess, + type ProviderOperateAccess, + resolvePrimaryOperateAccess, + resolveRemoteOperateAccess, + resolveSelectedProviderEnvironmentId, +} from "./ProviderSettingsPanel.logic"; + +function withoutProviderInstanceKey( + record: Readonly> | undefined, + key: ProviderInstanceId, +): Record { + const next = { ...record } as Record; + delete next[key]; + return next; +} + +function withoutProviderInstanceFavorites( + favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, + instanceId: ProviderInstanceId, +) { + return favorites.filter((favorite) => favorite.provider !== instanceId); +} + +const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ + provider: definition.value, +})); + +function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { + useRelativeTimeTick(); + const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); + + if (lastCheckedRelative.status === "missing") { + return null; + } + + if (lastCheckedRelative.status === "invalid") { + return Checked unavailable; + } + + return ( + + {lastCheckedRelative.suffix ? ( + <> + Checked {lastCheckedRelative.value}{" "} + {lastCheckedRelative.suffix} + + ) : ( + <>Checked {lastCheckedRelative.value} + )} + + ); +} + +function providerEnvironmentIcon(environment: EnvironmentPresentation) { + if (environment.entry.target._tag === "PrimaryConnectionTarget") return MonitorIcon; + if (environment.entry.target._tag === "RelayConnectionTarget") return CloudIcon; + if (environment.entry.target._tag === "SshConnectionTarget") return TerminalIcon; + if (isDesktopLocalConnectionTarget(environment.entry.target)) return LaptopIcon; + return CloudIcon; +} + +function providerEnvironmentDetail(environment: EnvironmentPresentation): string { + if (environment.entry.target._tag === "PrimaryConnectionTarget") return "Primary device"; + if (environment.relayManaged) return "T3 Connect"; + if (environment.entry.target._tag === "SshConnectionTarget") return "SSH"; + if (isDesktopLocalConnectionTarget(environment.entry.target)) return "Local device"; + return environment.displayUrl ?? "Remote device"; +} + +function EnvironmentUnavailableRow({ + environment, + access, +}: { + readonly environment: EnvironmentPresentation; + readonly access: Exclude; +}) { + const isLoading = access.kind === "loading"; + const title = isLoading + ? "Loading provider settings" + : access.kind === "error" + ? "Could not connect to this device" + : "Provider settings are unavailable"; + const description = isLoading + ? access.reason === "permissions" + ? "Checking what this session is allowed to change." + : `Waiting for ${environment.label}'s configuration.` + : connectionStatusText(environment.connection); + // No spinner: this state can persist indefinitely for a wedged device, and a + // continuously repainting animation would run the whole time. + return ( + + + + ); +} + +export function ProviderSettingsPanel() { + const { environments, isReady } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const options = useMemo( + () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId), + [environments, primaryEnvironmentId], + ); + // Raw user intent; the effective selection is re-derived every render so a + // device that drops out of the catalog falls back without erasing the pick — + // if it reappears (e.g. after a reconnect) the selection is restored. + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( + primaryEnvironmentId, + ); + const effectiveEnvironmentId = resolveSelectedProviderEnvironmentId( + options, + selectedEnvironmentId, + primaryEnvironmentId, + ); + const selectedEnvironment = + options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; + const onlyPrimaryDevice = + options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; + + return ( + + {!onlyPrimaryDevice ? ( + + {options.length === 0 ? ( + // The catalog hydrates asynchronously, so an empty list before it is + // ready means "not loaded yet", not "nothing is connected". + + ) : ( +
+ {options.map((environment) => { + const Icon = providerEnvironmentIcon(environment); + const selected = environment.environmentId === effectiveEnvironmentId; + const statusText = connectionStatusText(environment.connection); + return ( + + ); + })} +
+ )} +
+ ) : null} + + {selectedEnvironment ? ( + + ) : null} +
+ ); +} + +function SelectedEnvironmentProviderSettings({ + environment, +}: { + readonly environment: EnvironmentPresentation; +}) { + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + if (isPrimary) { + // The desktop app owns its primary server outright; a browser session + // checks the scopes its cookie session was granted. + if (isElectron) { + return ; + } + return ; + } + return ; +} + +function PrimarySessionGatedProviderSettings({ + environment, +}: { + readonly environment: EnvironmentPresentation; +}) { + const primarySessionState = usePrimarySessionState(); + const operateAccess = resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: primarySessionState.data, + isPending: primarySessionState.isPending, + hasError: primarySessionState.error !== null, + }); + return ; +} + +function RemoteSessionGatedProviderSettings({ + environment, +}: { + readonly environment: EnvironmentPresentation; +}) { + const sessionState = useEnvironmentSessionState(environment.environmentId); + const operateAccess = resolveRemoteOperateAccess({ + session: sessionState.data, + isPending: sessionState.isPending, + hasError: sessionState.hasError, + }); + return ; +} + +function AccessGatedProviderSettings({ + environment, + operateAccess, +}: { + readonly environment: EnvironmentPresentation; + readonly operateAccess: ProviderOperateAccess; +}) { + const access = classifyProviderEnvironmentAccess({ + connectionPhase: environment.connection.phase, + hasServerConfig: environment.serverConfig !== null, + operateAccess, + }); + if (access.kind !== "editable" && access.kind !== "read-only") { + return ; + } + return ( + + ); +} + +export function EnvironmentProviderSettings({ + environmentId, + environmentLabel, + readOnly = false, +}: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + /** + * Render the full provider layout, greyed out and inert, when this session's + * credential lacks `orchestration:operate` on the environment. Showing the + * real configuration keeps the view honest; disabling interaction keeps + * every one of its writes from being offered and then rejected. + */ + readonly readOnly?: boolean; +}) { + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const serverProviders = + useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; + const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { + reportFailure: false, + }); + const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); + const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); + const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< + ReadonlySet + >(() => new Set()); + const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); + const refreshingRef = useRef(false); + const updatingDriversRef = useRef>(new Set()); + + const providerUpdateCandidates = useMemo( + () => collectProviderUpdateCandidates(serverProviders), + [serverProviders], + ); + const providerUpdateCandidateByInstanceId = useMemo( + () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), + [providerUpdateCandidates], + ); + const visibleProviderSettings = PROVIDER_SETTINGS.filter( + (providerSettings) => + providerSettings.provider !== "cursor" || + serverProviders.some( + (provider) => + provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), + ), + ); + const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const textGenInstanceId = textGenerationModelSelection.instanceId; + const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); + const providerHealthPreset = getBackgroundActivityPresetSettings( + resolvedBackgroundActivity.profile, + ).providerHealthRefreshInterval; + const providerHealthRefreshIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.providerHealthRefreshInterval, + ); + const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset); + const lastCheckedAt = + serverProviders.length > 0 + ? serverProviders.reduce( + (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), + serverProviders[0]!.checkedAt, + ) + : null; + + const refreshProviders = useCallback(() => { + if (refreshingRef.current) return; + refreshingRef.current = true; + setIsRefreshingProviders(true); + void (async () => { + const result = await refreshServerProviders({ + environmentId, + input: {}, + }); + refreshingRef.current = false; + setIsRefreshingProviders(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + console.warn("Failed to refresh providers", { + operation: "refresh-providers", + environmentId, + ...safeErrorLogAttributes(squashAtomCommandFailure(result)), + }); + } + })(); + }, [environmentId, refreshServerProviders]); + + const runProviderUpdate = useCallback( + async (candidate: ProviderUpdateCandidate) => { + // Ref-based re-entry guard, mirroring refreshProviders: a state updater + // may run after this function returns, so it cannot gate the dispatch. + if (updatingDriversRef.current.has(candidate.driver)) { + return; + } + updatingDriversRef.current.add(candidate.driver); + setUpdatingProviderDrivers((previous) => new Set(previous).add(candidate.driver)); + + const result = await updateProvider({ + environmentId, + input: { + provider: candidate.driver, + instanceId: candidate.instanceId, + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, + description: + error instanceof Error + ? error.message + : "The provider update command could not be started.", + }), + ); + } + updatingDriversRef.current.delete(candidate.driver); + setUpdatingProviderDrivers((previous) => { + if (!previous.has(candidate.driver)) { + return previous; + } + const next = new Set(previous); + next.delete(candidate.driver); + return next; + }); + }, + [environmentId, updateProvider], + ); + + interface InstanceRow { + readonly instanceId: ProviderInstanceId; + readonly instance: ProviderInstanceConfig; + readonly driver: ProviderDriverKind; + readonly isDefault: boolean; + readonly isDirty?: boolean; + } + + const instancesByDriver = new Map< + ProviderDriverKind, + Array<[ProviderInstanceId, ProviderInstanceConfig]> + >(); + for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { + const driver = instance.driver; + const list = instancesByDriver.get(driver) ?? []; + list.push([rawId as ProviderInstanceId, instance]); + instancesByDriver.set(driver, list); + } + + const defaultSlotIdsBySource = new Set( + visibleProviderSettings.map((providerSettings) => + String(defaultInstanceIdForDriver(providerSettings.provider)), + ), + ); + + const rows: InstanceRow[] = []; + const visibleDriverKinds = new Set( + visibleProviderSettings.map((providerSettings) => providerSettings.provider), + ); + + for (const providerSettings of visibleProviderSettings) { + type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; + const legacyProviders = settings.providers as Record; + const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< + string, + LegacyProviderSettings + >; + const driver = providerSettings.provider; + const defaultInstanceId = defaultInstanceIdForDriver(driver); + const explicitInstance = settings.providerInstances?.[defaultInstanceId]; + // A remote device may run a server version whose settings predate this + // driver, so the legacy mirror can be absent. Without either an explicit + // instance or a legacy blob there is nothing to render for the slot. + const legacyConfig = legacyProviders[providerSettings.provider]; + const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]; + const effectiveInstance: ProviderInstanceConfig | undefined = + explicitInstance ?? + (legacyConfig !== undefined + ? ({ + driver, + enabled: legacyConfig.enabled, + config: legacyConfig, + } satisfies ProviderInstanceConfig) + : undefined); + // Only the default slot depends on the legacy blob; custom instances for + // the driver must still render even when the slot has nothing to show. + if (effectiveInstance !== undefined) { + const isDirty = + explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); + rows.push({ + instanceId: defaultInstanceId, + instance: effectiveInstance, + driver, + isDefault: true, + isDirty, + }); + } + for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { + if (id === defaultInstanceId) continue; + rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); + } + } + for (const [driver, list] of instancesByDriver) { + if (visibleDriverKinds.has(driver)) continue; + for (const [id, instance] of list) { + rows.push({ + instanceId: id, + instance, + driver: instance.driver, + isDefault: defaultSlotIdsBySource.has(String(id)), + }); + } + } + + const updateProviderInstance = ( + row: InstanceRow, + next: ProviderInstanceConfig, + options?: { + readonly textGenerationModelSelection?: Parameters< + typeof buildProviderInstanceUpdatePatch + >[0]["textGenerationModelSelection"]; + }, + ) => { + updateSettings( + buildProviderInstanceUpdatePatch({ + settings, + instanceId: row.instanceId, + instance: next, + driver: row.driver, + isDefault: row.isDefault, + textGenerationModelSelection: options?.textGenerationModelSelection, + }), + ); + }; + + const deleteProviderInstance = (id: ProviderInstanceId) => { + updateSettings({ + providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), + }); + }; + + const updateProviderModelPreferences = ( + instanceId: ProviderInstanceId, + next: { + readonly hiddenModels: ReadonlyArray; + readonly modelOrder: ReadonlyArray; + }, + ) => { + const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; + const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; + const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); + updateSettings({ + providerModelPreferences: + hiddenModels.length === 0 && modelOrder.length === 0 + ? rest + : { + ...rest, + [instanceId]: { + hiddenModels, + modelOrder, + }, + }, + }); + }; + + const updateProviderFavoriteModels = ( + instanceId: ProviderInstanceId, + nextFavoriteModels: ReadonlyArray, + ) => { + const favoriteModels = [ + ...new Set( + Arr.filterMap(nextFavoriteModels, (slug) => { + const trimmedSlug = slug.trim(); + return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; + }), + ), + ]; + updateSettings({ + favorites: [ + ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), + ...favoriteModels.map((model) => ({ provider: instanceId, model })), + ], + }); + }; + + const resetDefaultInstance = (driverKind: ProviderDriverKind) => { + type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; + const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< + string, + LegacyProviderSettings | undefined + >; + const defaultInstanceId = defaultInstanceIdForDriver(driverKind); + const defaultLegacyProvider = defaultLegacyProviders[driverKind]; + if (defaultLegacyProvider === undefined) return; + updateSettings({ + providers: { + ...settings.providers, + [driverKind]: defaultLegacyProvider, + } as typeof settings.providers, + providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), + }); + }; + + return ( + <> + + + {!readOnly ? ( + <> + + setIsAddInstanceDialogOpen(true)} + aria-label="Add provider instance" + > + + + } + /> + Add provider instance + + + void refreshProviders()} + aria-label="Refresh provider status" + > + {isRefreshingProviders ? ( + + ) : ( + + )} + + } + /> + Refresh provider status + + + ) : null} + + } + > + {readOnly ? ( + + ) : null} +
+ + Health check interval + + This interval is configured here, then the shared Background activity policy + decides whether provider probes may run when the timer fires. Custom intervals + appear as Advanced in General settings. + + + } + description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." + resetAction={ + providerHealthRefreshIntervalSeconds !== + defaultProviderHealthRefreshIntervalSeconds ? ( + + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: undefined, + }, + ), + ) + } + /> + ) : null + } + control={ +
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+ } + /> + + {rows.map((row) => { + const driverOption = getDriverOption(row.driver); + const liveProvider = serverProviders.find( + (candidate) => candidate.instanceId === row.instanceId, + ); + const updateCandidate = liveProvider + ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) + : undefined; + const isDriverUpdateRunning = + updateCandidate !== undefined && + (updatingProviderDrivers.has(updateCandidate.driver) || + serverProviders.some( + (provider) => + provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), + )); + const showInlineUpdateButton = + updateCandidate !== undefined && + hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); + const canRunInlineUpdate = + updateCandidate !== undefined && + canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && + !updatingProviderDrivers.has(updateCandidate.driver); + const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { + hiddenModels: [], + modelOrder: [], + }; + const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => + favorite.provider === row.instanceId + ? Result.succeed(favorite.model) + : Result.failVoid, + ); + const resetLabel = driverOption?.label ?? String(row.driver); + const headerAction = + row.isDefault && row.isDirty ? ( + resetDefaultInstance(row.driver)} + /> + ) : null; + return ( + + setOpenInstanceDetails((existing) => ({ + ...existing, + [row.instanceId]: open, + })) + } + onUpdate={(next) => { + const wasEnabled = row.instance.enabled ?? true; + const isDisabling = next.enabled === false && wasEnabled; + const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; + if (shouldClearTextGen) { + updateProviderInstance(row, next, { + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + }); + } else { + updateProviderInstance(row, next); + } + }} + onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} + headerAction={headerAction} + hiddenModels={modelPreferences.hiddenModels} + favoriteModels={favoriteModels} + modelOrder={modelPreferences.modelOrder} + onHiddenModelsChange={(hiddenModels) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + hiddenModels, + }) + } + onFavoriteModelsChange={(favoriteModels) => + updateProviderFavoriteModels(row.instanceId, favoriteModels) + } + onModelOrderChange={(modelOrder) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + modelOrder, + }) + } + onRunUpdate={ + showInlineUpdateButton && updateCandidate + ? () => { + if (!canRunInlineUpdate) { + return; + } + void runProviderUpdate(updateCandidate); + } + : undefined + } + isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} + /> + ); + })} +
+
+ + {isAddInstanceDialogOpen ? ( + + ) : null} + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 1d4baefa53a..efb5e12ff33 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -10,10 +10,12 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { + getBackgroundActivityBaseProfile, normalizeBackgroundActivitySettings, normalizeServerBackgroundActivitySettings, resolveServerBackgroundActivitySettings, } from "@t3tools/shared/backgroundActivitySettings"; +import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { @@ -190,3 +192,58 @@ export function buildProviderInstanceUpdatePatch(input: { : {}), }; } + +// ── Background-activity interval helpers ───────────────────────────── +// Shared by the General panel's interval rows and the Providers panel's +// health-check row. + +export const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30; + +type BackgroundActivityOverridePatch = Partial<{ + [K in keyof BackgroundActivitySettings["overrides"]]: + | BackgroundActivitySettings["overrides"][K] + | undefined; +}>; + +export function durationToSeconds(duration: Duration.Duration): number { + return Math.round(Duration.toMillis(duration) / 1_000); +} + +export function normalizeIntervalSeconds(value: number | null, minimum = 0): number { + if (value === null || !Number.isFinite(value)) { + return minimum; + } + return Math.max(minimum, Math.round(value)); +} + +export function backgroundActivityOverrideSettings( + current: BackgroundActivitySettings, + resolved: ReturnType, + overrides: BackgroundActivityOverridePatch, +) { + const nextOverrides: BackgroundActivityOverridePatch = { + automaticGitFetchInterval: resolved.automaticGitFetchInterval, + providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, + hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, + hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, + idleClientTtl: resolved.idleClientTtl, + pauseWhenHostLocked: resolved.pauseWhenHostLocked, + pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, + pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, + pauseWhenOnBattery: resolved.pauseWhenOnBattery, + ...overrides, + }; + for (const [key, value] of Object.entries(nextOverrides)) { + if (value === undefined) { + delete nextOverrides[key as keyof typeof nextOverrides]; + } + } + return { + backgroundActivity: { + schemaVersion: 1 as const, + profile: "custom" as const, + baseProfile: getBackgroundActivityBaseProfile(current), + overrides: nextOverrides as BackgroundActivitySettings["overrides"], + }, + }; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b1c50e8717a..00d9573a1ac 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,30 +1,16 @@ -import { - ArchiveIcon, - ArchiveX, - InfoIcon, - LoaderIcon, - PlusIcon, - RefreshCwIcon, - SettingsIcon, -} from "lucide-react"; +import { ArchiveIcon, ArchiveX, LoaderIcon, SettingsIcon } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { - defaultInstanceIdForDriver, type BackgroundActivityProfile, - type BackgroundActivitySettings, type DesktopUpdateChannel, - PROVIDER_DISPLAY_NAMES, ProviderDriverKind, - type ProviderInstanceConfig, - type ProviderInstanceId, type ScopedThreadRef, type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, settlePromise, @@ -45,16 +31,10 @@ import { MIN_PROMPT_FONT_SIZE, MIN_TERMINAL_FONT_SIZE, } from "@t3tools/contracts/settings"; -import { - getBackgroundActivityBaseProfile, - getBackgroundActivityPresetSettings, - resolveServerBackgroundActivitySettings, -} from "@t3tools/shared/backgroundActivitySettings"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; -import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { @@ -88,15 +68,10 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { - primaryServerObservabilityAtom, - primaryServerProvidersAtom, - serverEnvironment, -} from "../../state/server"; -import { usePrimaryEnvironment } from "../../state/environments"; +import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; -import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Dialog, @@ -131,20 +106,13 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; -import { - canOneClickUpdateProviderCandidate, - collectProviderUpdateCandidates, - hasOneClickUpdateProviderCandidate, - isProviderUpdateActive, - type ProviderUpdateCandidate, -} from "../ProviderUpdateLaunchNotification.logic"; -import { ProviderInstanceCard } from "./ProviderInstanceCard"; -import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { + backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, - buildProviderInstanceUpdatePatch, + durationToSeconds, formatDiagnosticsDescription, + normalizeIntervalSeconds, + PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, @@ -153,16 +121,15 @@ import { resolveBackgroundActivityProfileOption, } from "./SettingsPanels.logic"; import { + PolicyTooltip, SettingResetButton, SettingsPageContainer, SettingsRow, SettingsSection, - useRelativeTimeTick, useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; -import { useAtomCommand } from "../../state/use-atom-command"; const THEME_OPTIONS = [ { @@ -198,11 +165,6 @@ const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record; const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record = { ...BACKGROUND_ACTIVITY_PROFILE_LABELS, @@ -219,7 +181,6 @@ const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record, - overrides: BackgroundActivityOverridePatch, -) { - const nextOverrides: BackgroundActivityOverridePatch = { - automaticGitFetchInterval: resolved.automaticGitFetchInterval, - providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, - hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, - hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, - idleClientTtl: resolved.idleClientTtl, - pauseWhenHostLocked: resolved.pauseWhenHostLocked, - pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, - pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, - pauseWhenOnBattery: resolved.pauseWhenOnBattery, - ...overrides, - }; - for (const [key, value] of Object.entries(nextOverrides)) { - if (value === undefined) { - delete nextOverrides[key as keyof typeof nextOverrides]; - } - } - return { - backgroundActivity: { - schemaVersion: 1 as const, - profile: "custom" as const, - baseProfile: getBackgroundActivityBaseProfile(current), - overrides: nextOverrides as BackgroundActivitySettings["overrides"], - }, - }; -} - -function PolicyTooltip({ children }: { readonly children: string }) { - return ( - - - - - } - /> - - {children} - - - ); -} - -function withoutProviderInstanceKey( - record: Readonly> | undefined, - key: ProviderInstanceId, -): Record { - const next = { ...record } as Record; - delete next[key]; - return next; -} - -function withoutProviderInstanceFavorites( - favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, - instanceId: ProviderInstanceId, -) { - return favorites.filter((favorite) => favorite.provider !== instanceId); -} - -const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ - provider: definition.value, -})); - -function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { - useRelativeTimeTick(); - const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); - - if (lastCheckedRelative.status === "missing") { - return null; - } - - if (lastCheckedRelative.status === "invalid") { - return Checked unavailable; - } - - return ( - - {lastCheckedRelative.suffix ? ( - <> - Checked {lastCheckedRelative.value}{" "} - {lastCheckedRelative.suffix} - - ) : ( - <>Checked {lastCheckedRelative.value} - )} - - ); -} - function AboutVersionTitle() { return ( @@ -2146,522 +1997,6 @@ export function GeneralSettingsPanel() { ); } -export function ProviderSettingsPanel() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - const serverProviders = useAtomValue(primaryServerProvidersAtom); - const primaryEnvironment = usePrimaryEnvironment(); - const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); - const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { - reportFailure: false, - }); - const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); - const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); - const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< - ReadonlySet - >(() => new Set()); - const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); - const refreshingRef = useRef(false); - - const providerUpdateCandidates = useMemo( - () => collectProviderUpdateCandidates(serverProviders), - [serverProviders], - ); - const providerUpdateCandidateByInstanceId = useMemo( - () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), - [providerUpdateCandidates], - ); - const visibleProviderSettings = PROVIDER_SETTINGS.filter( - (providerSettings) => - providerSettings.provider !== "cursor" || - serverProviders.some( - (provider) => - provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), - ), - ); - const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); - const textGenInstanceId = textGenerationModelSelection.instanceId; - const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); - const providerHealthPreset = getBackgroundActivityPresetSettings( - resolvedBackgroundActivity.profile, - ).providerHealthRefreshInterval; - const providerHealthRefreshIntervalSeconds = durationToSeconds( - resolvedBackgroundActivity.providerHealthRefreshInterval, - ); - const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset); - const lastCheckedAt = - serverProviders.length > 0 - ? serverProviders.reduce( - (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), - serverProviders[0]!.checkedAt, - ) - : null; - - const refreshProviders = useCallback(() => { - if (refreshingRef.current) return; - refreshingRef.current = true; - setIsRefreshingProviders(true); - if (!primaryEnvironment) { - refreshingRef.current = false; - setIsRefreshingProviders(false); - return; - } - void (async () => { - const result = await refreshServerProviders({ - environmentId: primaryEnvironment.environmentId, - input: {}, - }); - refreshingRef.current = false; - setIsRefreshingProviders(false); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - console.warn("Failed to refresh providers", { - operation: "refresh-providers", - environmentId: primaryEnvironment.environmentId, - ...safeErrorLogAttributes(squashAtomCommandFailure(result)), - }); - } - })(); - }, [primaryEnvironment, refreshServerProviders]); - - const runProviderUpdate = useCallback( - async (candidate: ProviderUpdateCandidate) => { - if (!primaryEnvironment) return; - let started = false; - setUpdatingProviderDrivers((previous) => { - if (previous.has(candidate.driver)) { - return previous; - } - started = true; - const next = new Set(previous); - next.add(candidate.driver); - return next; - }); - if (!started) { - return; - } - - const result = await updateProvider({ - environmentId: primaryEnvironment.environmentId, - input: { - provider: candidate.driver, - instanceId: candidate.instanceId, - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, - description: - error instanceof Error - ? error.message - : "The provider update command could not be started.", - }), - ); - } - setUpdatingProviderDrivers((previous) => { - if (!previous.has(candidate.driver)) { - return previous; - } - const next = new Set(previous); - next.delete(candidate.driver); - return next; - }); - }, - [primaryEnvironment, updateProvider], - ); - - interface InstanceRow { - readonly instanceId: ProviderInstanceId; - readonly instance: ProviderInstanceConfig; - readonly driver: ProviderDriverKind; - readonly isDefault: boolean; - readonly isDirty?: boolean; - } - - const instancesByDriver = new Map< - ProviderDriverKind, - Array<[ProviderInstanceId, ProviderInstanceConfig]> - >(); - for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { - const driver = instance.driver; - const list = instancesByDriver.get(driver) ?? []; - list.push([rawId as ProviderInstanceId, instance]); - instancesByDriver.set(driver, list); - } - - const defaultSlotIdsBySource = new Set( - visibleProviderSettings.map((providerSettings) => - String(defaultInstanceIdForDriver(providerSettings.provider)), - ), - ); - - const rows: InstanceRow[] = []; - const visibleDriverKinds = new Set( - visibleProviderSettings.map((providerSettings) => providerSettings.provider), - ); - - for (const providerSettings of visibleProviderSettings) { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const legacyProviders = settings.providers as Record; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings - >; - const driver = providerSettings.provider; - const defaultInstanceId = defaultInstanceIdForDriver(driver); - const explicitInstance = settings.providerInstances?.[defaultInstanceId]; - const legacyConfig = legacyProviders[providerSettings.provider]!; - const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!; - const effectiveInstance: ProviderInstanceConfig = - explicitInstance ?? - ({ - driver, - enabled: legacyConfig.enabled, - config: legacyConfig, - } satisfies ProviderInstanceConfig); - const isDirty = - explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); - rows.push({ - instanceId: defaultInstanceId, - instance: effectiveInstance, - driver, - isDefault: true, - isDirty, - }); - for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { - if (id === defaultInstanceId) continue; - rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); - } - } - for (const [driver, list] of instancesByDriver) { - if (visibleDriverKinds.has(driver)) continue; - for (const [id, instance] of list) { - rows.push({ - instanceId: id, - instance, - driver: instance.driver, - isDefault: defaultSlotIdsBySource.has(String(id)), - }); - } - } - - const updateProviderInstance = ( - row: InstanceRow, - next: ProviderInstanceConfig, - options?: { - readonly textGenerationModelSelection?: Parameters< - typeof buildProviderInstanceUpdatePatch - >[0]["textGenerationModelSelection"]; - }, - ) => { - updateSettings( - buildProviderInstanceUpdatePatch({ - settings, - instanceId: row.instanceId, - instance: next, - driver: row.driver, - isDefault: row.isDefault, - textGenerationModelSelection: options?.textGenerationModelSelection, - }), - ); - }; - - const deleteProviderInstance = (id: ProviderInstanceId) => { - updateSettings({ - providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), - providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id), - favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id), - }); - }; - - const updateProviderModelPreferences = ( - instanceId: ProviderInstanceId, - next: { - readonly hiddenModels: ReadonlyArray; - readonly modelOrder: ReadonlyArray; - }, - ) => { - const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; - const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; - const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); - updateSettings({ - providerModelPreferences: - hiddenModels.length === 0 && modelOrder.length === 0 - ? rest - : { - ...rest, - [instanceId]: { - hiddenModels, - modelOrder, - }, - }, - }); - }; - - const updateProviderFavoriteModels = ( - instanceId: ProviderInstanceId, - nextFavoriteModels: ReadonlyArray, - ) => { - const favoriteModels = [ - ...new Set( - Arr.filterMap(nextFavoriteModels, (slug) => { - const trimmedSlug = slug.trim(); - return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; - }), - ), - ]; - updateSettings({ - favorites: [ - ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), - ...favoriteModels.map((model) => ({ provider: instanceId, model })), - ], - }); - }; - - const resetDefaultInstance = (driverKind: ProviderDriverKind) => { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings | undefined - >; - const defaultInstanceId = defaultInstanceIdForDriver(driverKind); - const defaultLegacyProvider = defaultLegacyProviders[driverKind]; - if (defaultLegacyProvider === undefined) return; - updateSettings({ - providers: { - ...settings.providers, - [driverKind]: defaultLegacyProvider, - } as typeof settings.providers, - providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), - providerModelPreferences: withoutProviderInstanceKey( - settings.providerModelPreferences, - defaultInstanceId, - ), - favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId), - }); - }; - - return ( - - - - - setIsAddInstanceDialogOpen(true)} - aria-label="Add provider instance" - > - - - } - /> - Add provider instance - - - void refreshProviders()} - aria-label="Refresh provider status" - > - {isRefreshingProviders ? ( - - ) : ( - - )} - - } - /> - Refresh provider status - - - } - > - - Health check interval - - This interval is configured here, then the shared Background activity policy decides - whether provider probes may run when the timer fires. Custom intervals appear as - Advanced in General settings. - - - } - description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." - resetAction={ - providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( - - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: undefined, - }, - ), - ) - } - /> - ) : null - } - control={ -
- - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, - ), - ) - } - > - - - - - - - seconds -
- } - /> - - {rows.map((row) => { - const driverOption = getDriverOption(row.driver); - const liveProvider = serverProviders.find( - (candidate) => candidate.instanceId === row.instanceId, - ); - const updateCandidate = liveProvider - ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) - : undefined; - const isDriverUpdateRunning = - updateCandidate !== undefined && - (updatingProviderDrivers.has(updateCandidate.driver) || - serverProviders.some( - (provider) => - provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), - )); - const showInlineUpdateButton = - updateCandidate !== undefined && - hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); - const canRunInlineUpdate = - updateCandidate !== undefined && - canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && - !updatingProviderDrivers.has(updateCandidate.driver); - const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { - hiddenModels: [], - modelOrder: [], - }; - const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => - favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, - ); - const resetLabel = driverOption?.label ?? String(row.driver); - const headerAction = - row.isDefault && row.isDirty ? ( - resetDefaultInstance(row.driver)} - /> - ) : null; - return ( - - setOpenInstanceDetails((existing) => ({ - ...existing, - [row.instanceId]: open, - })) - } - onUpdate={(next) => { - const wasEnabled = row.instance.enabled ?? true; - const isDisabling = next.enabled === false && wasEnabled; - const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; - if (shouldClearTextGen) { - updateProviderInstance(row, next, { - textGenerationModelSelection: - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - }); - } else { - updateProviderInstance(row, next); - } - }} - onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} - headerAction={headerAction} - hiddenModels={modelPreferences.hiddenModels} - favoriteModels={favoriteModels} - modelOrder={modelPreferences.modelOrder} - onHiddenModelsChange={(hiddenModels) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - hiddenModels, - }) - } - onFavoriteModelsChange={(favoriteModels) => - updateProviderFavoriteModels(row.instanceId, favoriteModels) - } - onModelOrderChange={(modelOrder) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - modelOrder, - }) - } - onRunUpdate={ - showInlineUpdateButton && updateCandidate - ? () => { - if (!canRunInlineUpdate) { - return; - } - void runProviderUpdate(updateCandidate); - } - : undefined - } - isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} - /> - ); - })} - - - {isAddInstanceDialogOpen ? ( - - ) : null} - - ); -} - export function ArchivedThreadsPanel() { const projects = useProjects(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 84fb95a4741..0bb1a1aa678 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -1,4 +1,4 @@ -import { Undo2Icon } from "lucide-react"; +import { InfoIcon, Undo2Icon } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { createContext, @@ -83,6 +83,28 @@ function useSettingsSearchTarget(id: string | undefined) return targetRef; } +/** Info affordance explaining how a setting interacts with the shared background policy. */ +export function PolicyTooltip({ children }: { readonly children: string }) { + return ( + + + + + } + /> + + {children} + + + ); +} + /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ export function useRelativeTimeTick(intervalMs = 1_000) { const [nowMs, setNowMs] = useState(() => Date.now()); diff --git a/apps/web/src/routes/settings.providers.tsx b/apps/web/src/routes/settings.providers.tsx index a7a86c2b50b..deab014722d 100644 --- a/apps/web/src/routes/settings.providers.tsx +++ b/apps/web/src/routes/settings.providers.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ProviderSettingsPanel } from "../components/settings/SettingsPanels"; +import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel"; function SettingsProvidersRoute() { return ; diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 3271eefd1e1..1071d8209df 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -33,7 +33,7 @@ interface PrimaryServerState { } const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; -const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; +export const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { config: null, latestEvent: null, diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index 37fed3b188f..a7d5a53d10d 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -26,3 +26,17 @@ export function readPreparedConnection(environmentId: EnvironmentId) { appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)), ); } + +/** + * This client's authenticated session on one environment, as reported by that + * environment's `/api/auth/session` endpoint. `data` stays populated across + * SWR revalidations; `isPending` is only meaningful before the first resolve. + */ +export function useEnvironmentSessionState(environmentId: EnvironmentId) { + const result = useAtomValue(environmentSession.sessionStateAtom(environmentId)); + return { + data: Option.getOrNull(AsyncResult.value(result)), + hasError: result._tag === "Failure", + isPending: result.waiting, + }; +} diff --git a/apps/web/src/test/reactElementTree.ts b/apps/web/src/test/reactElementTree.ts new file mode 100644 index 00000000000..33351c35eb1 --- /dev/null +++ b/apps/web/src/test/reactElementTree.ts @@ -0,0 +1,27 @@ +import { isValidElement, type ReactElement } from "react"; + +/** + * Depth-first search over a React element tree produced by calling a component + * as a plain function (see `reactHookHarness`). Descends through props so + * render-prop and slot-style children are reachable. Returns the first element + * the visitor accepts, or null. + */ +export function visitElements( + node: unknown, + visitor: (element: ReactElement>) => boolean, +): ReactElement> | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = visitElements(child, visitor); + if (found) return found; + } + return null; + } + if (!isValidElement>(node)) return null; + if (visitor(node)) return node; + for (const value of Object.values(node.props)) { + const found = visitElements(value, visitor); + if (found) return found; + } + return null; +} diff --git a/apps/web/src/test/reactHookHarness.ts b/apps/web/src/test/reactHookHarness.ts new file mode 100644 index 00000000000..1b4b26fb698 --- /dev/null +++ b/apps/web/src/test/reactHookHarness.ts @@ -0,0 +1,89 @@ +import type { Dispatch, SetStateAction } from "react"; + +/** + * Minimal React hook shim for tests that call components as plain functions + * instead of mounting a renderer. Slots are keyed by call order, mirroring + * React's own rules-of-hooks contract, and `useMemoCache` emulates the React + * Compiler runtime so compiled components can execute unmodified. + * + * This module must stay free of runtime `react` imports: it is loaded from + * inside `vi.mock("react", ...)` factories, and a value import would recurse + * into the in-progress mock. Wire it up in each test file (mock calls cannot + * live here because vitest hoists them per test module): + * + * ```ts + * import { reactHookHarness } from "~/test/reactHookHarness"; + * + * vi.mock("react", async (importOriginal) => { + * const actual = await importOriginal(); + * const { reactHookHarness } = await import("~/test/reactHookHarness"); + * return { + * ...actual, + * useCallback: reactHookHarness.useCallback, + * useMemo: reactHookHarness.useMemo, + * useRef: reactHookHarness.useRef, + * useState: reactHookHarness.useState, + * }; + * }); + * vi.mock("react/compiler-runtime", async () => { + * const { reactHookHarness } = await import("~/test/reactHookHarness"); + * return { c: reactHookHarness.useMemoCache }; + * }); + * ``` + * + * Call `beginRender()` before each component invocation and `reset()` in + * `beforeEach` to drop persisted state between tests. + */ +export function createReactHookHarness() { + let cursor = 0; + let slots: unknown[] = []; + const nextIndex = () => cursor++; + + return { + beginRender() { + cursor = 0; + }, + reset() { + cursor = 0; + slots = []; + }, + useCallback(callback: T): T { + nextIndex(); + return callback; + }, + useMemo(factory: () => T): T { + nextIndex(); + return factory(); + }, + useMemoCache(size: number): unknown[] { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); + } + return slots[index] as unknown[]; + }, + useRef(initialValue: T): { current: T } { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = { current: initialValue }; + } + return slots[index] as { current: T }; + }, + useState(initialValue: T | (() => T)): [T, Dispatch>] { + const index = nextIndex(); + if (index >= slots.length) { + slots[index] = + typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + } + const setValue: Dispatch> = (nextValue) => { + const previous = slots[index] as T; + slots[index] = + typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; + }; + return [slots[index] as T, setValue]; + }, + }; +} + +/** Shared instance so `vi.mock` factories and test bodies see the same slots. */ +export const reactHookHarness = createReactHookHarness(); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 3cb62009a20..31fd297da3f 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,14 +1,19 @@ -import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import type { HttpClient } from "effect/unstable/http"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import type { PreparedConnection } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; import { followStreamInEnvironment } from "./runtime.ts"; export function initialConfigOption( @@ -25,8 +30,39 @@ export function initialConfigOption( ); } +// Bounded like the snapshot fetches: a wedged environment must not pin the +// permissions check (and with it the settings UI) in a loading state for long. +const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; + +/** + * Read the granted scopes of this client's session on one environment via its + * `/api/auth/session` endpoint, authenticated with whatever credential the + * connection was prepared with (cookie, bearer, or DPoP). + */ +export const fetchEnvironmentSessionState = Effect.fn( + "clientRuntime.state.fetchEnvironmentSessionState", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly signer: Option.Option; + readonly timeoutMs?: number; +}) { + const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/auth/session"); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl, + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS, + withEnvironmentCredentials(input.prepared.httpAuthorization, client.auth.session({ headers })), + ); +}); + export function createEnvironmentSessionAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { const initialConfigAtom = Atom.family((environmentId: EnvironmentId) => runtime.atom( @@ -86,10 +122,41 @@ export function createEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), ); + // Keyed on the prepared connection's identity: a reconnect (new credential, + // new base URL) swaps the prepared value, which re-runs the fetch, so scope + // changes from re-pairing are picked up without an explicit refresh. + const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => + runtime + .atom((get) => { + const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId))); + if (prepared === null) { + return Effect.never; + } + return Effect.gen(function* () { + const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); + return yield* fetchEnvironmentSessionState({ prepared, signer }); + }); + }) + .pipe( + Atom.swr({ staleTime: 30_000, revalidateOnMount: true }), + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`environment-session-state:${environmentId}`), + ), + ); + + const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): AuthSessionState | null => + Option.getOrNull(AsyncResult.value(get(sessionStateAtom(environmentId)))) ?? null, + ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)), + ); + return { initialConfigAtom, initialConfigValueAtom, preparedConnectionAtom, preparedConnectionValueAtom, + sessionStateAtom, + sessionStateValueAtom, }; } From b98a0f0d2292d180db0ac7c6ae8ccdbc9f6478f7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 03:58:26 -0400 Subject: [PATCH 2/6] fix(mobile): invisible T3 Connect devices can now be seen and removed (#5563) Co-authored-by: Claude Fable 5 --- .../connection/CloudEnvironmentRows.tsx | 71 ++++++++++++------- .../SettingsEnvironmentsRouteScreen.tsx | 26 +++---- 2 files changed, 60 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 173d093d849..b7cb2837681 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -21,6 +21,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useThemeColor } from "../../lib/useThemeColor"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; @@ -42,6 +43,11 @@ interface CloudEnvironmentRowsProps { * with connect switches, availability status, refresh, and loading/error * states. Shared between the Settings environments screen and the T3 Connect * onboarding sheet. + * + * Already-connected relay environments render even without cloud config or a + * signed-in account — they are registered on this device and must stay + * reachable and removable. Only discovery (the available list, refresh, and + * its errors) requires a signed-in session. */ export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) { // Showcase captures run without a Clerk publishable key, so `ClerkProvider` @@ -50,20 +56,33 @@ export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) { if (props.showcaseSignedIn !== undefined) { return props.showcaseSignedIn ? : null; } + // No cloud config means no `ClerkProvider` either, so `useAuth` would throw. + if (!hasCloudPublicConfig()) { + return ; + } return ; } function SignedInCloudEnvironmentRows(props: CloudEnvironmentRowsProps) { const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - if (!isSignedIn) return null; + if (!isSignedIn) return ; return ; } -function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) { +function ConnectedOnlyCloudEnvironmentRows(props: CloudEnvironmentRowsProps) { + if (props.connectedCloudEnvironments.length === 0) return null; + return ; +} + +function CloudEnvironmentRowsContent( + props: CloudEnvironmentRowsProps & { readonly discoveryAvailable?: boolean }, +) { const controller = useConnectionController(); const iconColor = useThemeColor("--color-icon"); - const availableCloudEnvironments = - props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments; + const discoveryAvailable = props.discoveryAvailable ?? true; + const availableCloudEnvironments = discoveryAvailable + ? (props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments) + : []; const [expandedErrorId, setExpandedErrorId] = useState(null); const hasCloudRows = props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; @@ -89,25 +108,27 @@ function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) { {showHeader ? ( T3 Connect - { - void controller.refreshRelayEnvironments(); - }} - className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" - > - {controller.relayDiscovery.isRefreshing ? ( - - ) : ( - - )} - + {discoveryAvailable ? ( + { + void controller.refreshRelayEnvironments(); + }} + className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" + > + {controller.relayDiscovery.isRefreshing ? ( + + ) : ( + + )} + + ) : null} ) : null} @@ -152,7 +173,9 @@ function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) { {/* Rendered alongside any connected rows — a failed discovery must not hide behind an otherwise-healthy list. */} - {controller.relayDiscovery.error && !controller.relayDiscovery.isRefreshing ? ( + {discoveryAvailable && + controller.relayDiscovery.error && + !controller.relayDiscovery.isRefreshing ? ( Could not load T3 Connect environments diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 93b806f6487..53bbe480646 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -8,7 +8,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; -import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; import { splitEnvironmentSections } from "../connection/environmentSections"; @@ -161,18 +160,19 @@ export function SettingsEnvironmentsRouteScreen() { )} - {hasCloudPublicConfig() || SHOWCASE_ENABLED ? ( - - ) : null} + {/* Always mounted: already-connected relay environments must stay + visible (and removable) even when cloud config is missing or the + user is signed out — the component gates discovery itself. */} + ); From 220efad62b7ce7b9ee4befff75edb0753467df0a Mon Sep 17 00:00:00 2001 From: Mateleo Date: Fri, 7 Aug 2026 10:21:18 +0200 Subject: [PATCH 3/6] fix: add missing space before 'GitHub releases page' link on download page (#4511) --- apps/marketing/src/pages/download.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 111482208cf..5557f5fb6b1 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -79,7 +79,7 @@ import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site";
{rightPanelOpen && !shouldUseRightPanelSheet ? ( diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index eee6ba5886e..c9ded5e7eee 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -12,7 +12,7 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; export const RECENT_THREAD_LIMIT = 12; -export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; +export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; /** diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 60493063664..605127f9737 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -35,6 +35,7 @@ import { FolderPlusIcon, LinkIcon, MessageSquareIcon, + PaletteIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -57,6 +58,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useClientSettings } from "../hooks/useSettings"; +import { useTheme } from "../hooks/useTheme"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; import { filesystemEnvironment } from "../state/filesystem"; @@ -121,6 +123,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons" import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; +import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { resolveDefaultProviderModelSelection } from "../providerInstances"; @@ -386,6 +389,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { theme, themeHalves, resolvedTheme } = useTheme(); const composerHandleRef = useRef(null); const routeTarget = useParams({ strict: false, @@ -428,6 +432,16 @@ export function CommandPalette({ children }: { children: ReactNode }) { previewOpen, }, }); + if (command === "themeEditor.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + return; + } const mode = overlayModeForCommand(command); if (mode === null) { return; @@ -438,7 +452,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, previewOpen, terminalOpen, toggleMode]); + }, [keybindings, previewOpen, resolvedTheme, terminalOpen, theme, themeHalves, toggleMode]); useEffect( () => @@ -567,6 +581,7 @@ function OpenCommandPaletteDialog(props: { const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { theme, themeHalves, resolvedTheme } = useTheme(); const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; @@ -1463,6 +1478,22 @@ function OpenCommandPaletteDialog(props: { }); } + actionItems.push({ + kind: "action", + value: "action:theme-editor", + searchTerms: ["theme", "appearance", "colors", "palette", "customize"], + title: "Toggle theme editor", + icon: , + shortcutCommand: "themeEditor.toggle", + run: async () => { + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 67b82388bbc..0489e8c79cd 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1765,7 +1765,7 @@ function ComposerPromptEditorInner({ } placeholder={ terminalContexts.length > 0 ? null : ( -
+
{placeholder}
) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index a10cdafd783..76191e6d4d7 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -36,6 +36,7 @@ import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, + DIFF_SURFACE_THEME_UNSAFE_CSS, } from "../lib/diffRendering"; import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -86,54 +87,7 @@ interface CollapsedDiffFilesState { const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); -const DIFF_PANEL_UNSAFE_CSS = ` -[data-diffs-header], -[data-diff], -[data-file], -[data-error-wrapper], -[data-virtualizer-buffer] { - --diffs-header-font-family: var(--font-sans) !important; - --diffs-font-family: var(--font-mono) !important; - --diffs-bg: var(--background) !important; - --diffs-light-bg: var(--background) !important; - --diffs-dark-bg: var(--background) !important; - --diffs-token-light-bg: transparent; - --diffs-token-dark-bg: transparent; - - --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); - --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); - --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); - --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); - - --diffs-bg-addition-override: light-dark( - color-mix(in srgb, var(--background) 50%, var(--success)), - color-mix(in srgb, var(--background) 70%, var(--success)) - ); - --diffs-bg-addition-number-override: light-dark( - color-mix(in srgb, var(--background) 35%, var(--success)), - color-mix(in srgb, var(--background) 60%, var(--success)) - ); - --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); - --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); - - --diffs-bg-deletion-override: light-dark( - color-mix(in srgb, var(--background) 50%, var(--destructive)), - color-mix(in srgb, var(--background) 70%, var(--destructive)) - ); - --diffs-bg-deletion-number-override: light-dark( - color-mix(in srgb, var(--background) 35%, var(--destructive)), - color-mix(in srgb, var(--background) 60%, var(--destructive)) - ); - --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); - --diffs-bg-deletion-emphasis-override: color-mix( - in srgb, - var(--background) 80%, - var(--destructive) - ); - - background-color: var(--diffs-bg) !important; -} - +const DIFF_PANEL_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} :is( [data-line], [data-line-annotation], @@ -144,13 +98,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` --diffs-line-bg: light-dark( color-mix( in lab, - var(--background) 88%, - color-mix(in srgb, var(--background) 50%, var(--diffs-modified-base)) + var(--code-background) 88%, + color-mix(in srgb, var(--code-background) 50%, var(--diffs-modified-base)) ), color-mix( in lab, - var(--background) 80%, - color-mix(in srgb, var(--background) 70%, var(--diffs-modified-base)) + var(--code-background) 80%, + color-mix(in srgb, var(--code-background) 70%, var(--diffs-modified-base)) ) ) !important; } @@ -159,13 +113,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` --diffs-line-bg: light-dark( color-mix( in lab, - var(--background) 91%, - color-mix(in srgb, var(--background) 35%, var(--diffs-modified-base)) + var(--code-background) 91%, + color-mix(in srgb, var(--code-background) 35%, var(--diffs-modified-base)) ), color-mix( in lab, - var(--background) 85%, - color-mix(in srgb, var(--background) 60%, var(--diffs-modified-base)) + var(--code-background) 85%, + color-mix(in srgb, var(--code-background) 60%, var(--diffs-modified-base)) ) ) !important; } @@ -192,16 +146,16 @@ const DIFF_PANEL_UNSAFE_CSS = ` } [data-file-info] { - background-color: var(--background) !important; + background-color: var(--code-background) !important; border-block-color: transparent !important; - color: var(--foreground) !important; + color: var(--code-foreground) !important; } [data-diffs-header] { position: sticky !important; top: 0; z-index: 4; - background-color: var(--background) !important; + background-color: var(--code-background) !important; border-bottom-color: transparent !important; align-items: center !important; font-family: var(--font-sans) !important; @@ -213,13 +167,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` } [data-diffs-header]:hover { - background-color: color-mix(in srgb, var(--background) 97%, var(--foreground)) !important; + background-color: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground)) !important; } :is([data-separator="line-info"], [data-separator="line-info-basic"]) { height: 24px !important; margin-block: 0 !important; - background-color: var(--background) !important; + background-color: var(--code-background) !important; } :is([data-separator="line-info"], [data-separator="line-info-basic"]) @@ -233,7 +187,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` gap: 8px; padding-inline: 0 !important; background-color: transparent !important; - color: color-mix(in srgb, var(--foreground) 52%, var(--background)) !important; + color: color-mix(in srgb, var(--code-foreground) 52%, var(--code-background)) !important; font-family: var(--font-sans) !important; font-size: 11px !important; text-decoration: none !important; @@ -257,7 +211,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` height: 1px; flex: 1 1 auto; content: ""; - background-color: color-mix(in srgb, var(--background) 92%, var(--foreground)); + background-color: color-mix(in srgb, var(--code-background) 92%, var(--code-foreground)); } :is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] @@ -286,7 +240,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` [data-expand-button] ):hover [data-separator-content] { - color: color-mix(in srgb, var(--foreground) 76%, var(--background)) !important; + color: color-mix(in srgb, var(--code-foreground) 76%, var(--code-background)) !important; } :is([data-separator="line-info"], [data-separator="line-info-basic"]):has( @@ -297,7 +251,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` [data-expand-button] ):hover [data-unmodified-lines]::after { - background-color: color-mix(in srgb, var(--background) 84%, var(--foreground)); + background-color: color-mix(in srgb, var(--code-background) 84%, var(--code-foreground)); } [data-diffs-header] [data-header-content] { @@ -337,7 +291,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` } [data-title]:hover { - color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important; + color: color-mix(in srgb, var(--code-foreground) 84%, var(--primary)) !important; text-decoration-color: currentColor; } `; @@ -796,11 +750,11 @@ export default function DiffPanel({
{selectedScopeLabel} - + -

{selectedPatchError}

+

{selectedPatchError}

)} {!renderablePatch ? ( diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 1df19a64075..66216e10cb5 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -47,7 +47,7 @@ function ProjectFaviconFallback({ readonly className?: string | undefined; readonly icon: ComponentType<{ className?: string }>; }) { - return ; + return ; } function ProjectFaviconImage({ diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 3a1d7115098..7f21177e7b1 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -343,6 +343,9 @@ export default function ProjectScriptsControl({ variant="outline" className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" aria-label={`Run ${primaryScript.name}`} + // The tooltip wrapper replaces data-slot="button", so themed + // toolbar styling needs its own hook. + data-toolbar-control="" onClick={() => onRunScript(primaryScript)} /> } @@ -447,6 +450,9 @@ export default function ProjectScriptsControl({ variant="outline" className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" aria-label="Add action" + // The tooltip wrapper replaces data-slot="button", so themed + // toolbar styling needs its own hook. + data-toolbar-control="" onClick={openAddDialog} /> } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cffab8bd577..232ea0998ef 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -225,7 +225,7 @@ const PROJECT_GROUPING_MODE_LABELS: Record = separate: "Keep separate", }; const SIDEBAR_ICON_ACTION_BUTTON_CLASS = - "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; + "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { useEnvironmentThread(threadRef.environmentId, threadRef.threadId); @@ -857,9 +857,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ) : ( {formatRelativeTimeLabel( @@ -2245,7 +2243,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> - + {projectStatus.label} @@ -2262,7 +2260,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {project.displayName} {project.groupedProjectCount > 1 ? ( - + {project.groupedProjectCount} projects ) : null} @@ -2281,7 +2279,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? "Local sandbox project" : "Remote project" } - className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100" + className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-icon-muted transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100" /> } > @@ -2600,7 +2598,7 @@ function ProjectSortMenu({ + } > @@ -2824,7 +2822,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( + // Lifted above the stage backdrop, whose fade bleeds below the + // header and would otherwise paint across the search row's outline. + } @@ -2977,9 +2977,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( )} {projectsLength === 0 && ( -
- No projects yet -
+
No projects yet
)}
diff --git a/apps/web/src/components/SidebarStageBackdrop.test.tsx b/apps/web/src/components/SidebarStageBackdrop.test.tsx index 114fd5f9241..c34eec58316 100644 --- a/apps/web/src/components/SidebarStageBackdrop.test.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.test.tsx @@ -5,7 +5,6 @@ import { resolveEnvironmentIdentificationPillLabel, resolveSidebarStageBackdropVariant, StageBackdropArt, - StageBackdropButtonArt, } from "./SidebarStageBackdrop"; describe("SidebarStageBackdrop", () => { @@ -29,7 +28,7 @@ describe("SidebarStageBackdrop", () => { const markup = renderToStaticMarkup( <> - + , ); const ids = Array.from(markup.matchAll(/\sid="([^"]+)"/g), (match) => match[1]); diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index 9fb448e940d..ee669e94bd4 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -62,10 +62,6 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar return variant === "nightly" ? : ; } -export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) { - return variant === "nightly" ? : ; -} - const NIGHTLY_STARS: ReadonlyArray<{ cx: number; cy: number; @@ -97,7 +93,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [ { x: 246, y: 26 }, ]; -function NightlySkyArt({ compact = false }: { compact?: boolean }) { +function NightlySkyArt() { const idPrefix = useId().replaceAll(":", ""); const skyId = `${idPrefix}-stage-night-sky`; const glowId = `${idPrefix}-stage-night-glow`; @@ -111,7 +107,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { className="h-full w-full" fill="none" preserveAspectRatio="xMinYMin slice" - viewBox={compact ? "96 0 8192 96" : STAGE_BACKDROP_VIEW_BOX} + viewBox={STAGE_BACKDROP_VIEW_BOX} xmlns="http://www.w3.org/2000/svg" > @@ -195,7 +191,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { ); } -function DevBlueprintArt({ compact = false }: { compact?: boolean }) { +function DevBlueprintArt() { const idPrefix = useId().replaceAll(":", ""); const paperId = `${idPrefix}-stage-bp-paper`; const glowId = `${idPrefix}-stage-bp-glow`; @@ -212,7 +208,7 @@ function DevBlueprintArt({ compact = false }: { compact?: boolean }) { className="stage-blueprint h-full w-full" fill="none" preserveAspectRatio="xMinYMin slice" - viewBox={compact ? "64 0 8192 96" : STAGE_BACKDROP_VIEW_BOX} + viewBox={STAGE_BACKDROP_VIEW_BOX} xmlns="http://www.w3.org/2000/svg" > diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f84c54a3338..0b1df2f57c3 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -768,7 +768,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { isUnread || isWoke ? "text-foreground" : shouldRecede - ? "text-muted-foreground/80" + ? "text-secondary-label" : status === "failed" ? "text-foreground/95" : "text-foreground/90", @@ -779,7 +779,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? "text-foreground" : isUnread ? "text-muted-foreground" - : "text-muted-foreground/70", + : "text-secondary-label/70", ), isRegeneratingTitle && "opacity-[0.55]", )} @@ -799,8 +799,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { "shrink-0 text-xs tabular-nums hover:underline", variant === "slim" && variantAction === "unsettle" ? props.isActive - ? "text-muted-foreground/70" - : cn("text-muted-foreground/35 transition-colors", settledPrHoverClass) + ? "text-secondary-label" + : cn("text-secondary-label transition-colors", settledPrHoverClass) : prStatus.colorClass, )} aria-label={prStatus.tooltip} @@ -871,7 +871,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { @@ -983,7 +983,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {props.projectTitle ? ( @@ -1012,7 +1012,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { isWokeStatus ? "pointer-events-auto" : "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0", - "self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity", + "self-center justify-self-end tabular-nums text-secondary-label transition-opacity", snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", )} > @@ -1101,14 +1101,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null}
-
+
{/* While working, the current plan step outranks the branch: it's the one line that says what the thread is doing. */} {status === "working" && thread.planProgress ? ( {thread.planProgress.step} {/* Completed count, matching the transcript chip's n/m. */} - + {" "} {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} @@ -2834,7 +2834,9 @@ export default function SidebarV2() { + // Lifted above the stage backdrop, whose fade bleeds below the + // header and would otherwise paint across the search row's outline. +
@@ -2969,7 +2971,7 @@ export default function SidebarV2() { type="button" aria-label={`Project actions for ${project.displayName}`} title={`Project actions for ${project.displayName}`} - className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/55 outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring" + className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-icon-muted outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { void handleProjectActions(event, project); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 072241426e2..25b4abb3fbe 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -135,6 +135,10 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri return value ?? fallback; } +function readThemeColor(styles: CSSStyleDeclaration, variable: string, fallback: string): string { + return normalizeComputedColor(styles.getPropertyValue(variable), fallback); +} + /** The surface treats an omitted family or size as "use the built-in default". */ function terminalFontOptions(family: string, size: number): { family?: string; size: number } { const trimmed = family.trim(); @@ -151,6 +155,7 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty document.body; const drawerStyles = getComputedStyle(drawerSurface); const bodyStyles = getComputedStyle(document.body); + const themeStyles = getComputedStyle(document.documentElement); const background = normalizeComputedColor( drawerStyles.backgroundColor, normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), @@ -159,20 +164,32 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty drawerStyles.color, normalizeComputedColor(bodyStyles.color, fallbackForeground), ); - + const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background); + const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground); + const terminalCursor = readThemeColor( + themeStyles, + "--terminal-cursor", + isDark ? "rgb(180, 203, 255)" : "rgb(38, 56, 78)", + ); + const terminalSelection = readThemeColor( + themeStyles, + "--terminal-selection-background", + isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", + ); return { background: parseTerminalColor( - background, + terminalBackground, isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, ), foreground: parseTerminalColor( - foreground, + terminalForeground, isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 }, ), - cursor: isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, - // Matches the xterm selection overlays this renderer replaced; the text - // color underneath is left unchanged for contrast in both themes. - selectionBackground: isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", + cursor: parseTerminalColor( + terminalCursor, + isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, + ), + selectionBackground: terminalSelection, }; } diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 3a705eef36d..0955bd3abcb 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -62,6 +62,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { } >
) : null} {props.isPreparingWorktree ? ( - Preparing worktree... + Preparing worktree... ) : null} event.preventDefault()} @@ -2814,7 +2812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none", (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim()) ? "text-foreground" - : "text-muted-foreground/35", + : "text-placeholder", )} onPointerDown={(event) => event.preventDefault()} onClick={expandMobileComposer} @@ -2828,7 +2826,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : ( -
+
{image.name}
)} @@ -3116,7 +3114,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) variant="ghost" disabled data-chat-provider-unavailable="true" - className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3" + className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3" > No provider available diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 0adeed6ffa6..b11e2136770 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -114,7 +114,7 @@ export const ChatHeader = memo(function ChatHeader({ New thread in {activeProjectName} - + / diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 73fc6348905..3ed2a9432e4 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -150,7 +150,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { {groupIndex > 0 ? : null} {group.label ? ( - + {group.label} ) : null} @@ -172,10 +172,10 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
{props.triggerKind === "skill" ? ( - + Skills -

+

{props.isLoading ? "Searching workspace skills..." : (props.emptyStateText ?? @@ -183,7 +183,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {

) : ( -

+

{props.isLoading ? "Searching workspace files..." : (props.emptyStateText ?? @@ -235,26 +235,26 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { /> ) : null} {props.item.type === "slash-command" ? ( - + ) : null} {props.item.type === "provider-slash-command" ? ( - + ) : null} {props.item.type === "skill" ? ( - + ) : null} {props.item.label} - + {props.item.description} {skillSourceLabel ? ( - {skillSourceLabel} + {skillSourceLabel} ) : null} ); diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx index 8eab75171c8..a7ba4058145 100644 --- a/apps/web/src/components/chat/ComposerControl.tsx +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -6,7 +6,7 @@ import { Button } from "../ui/button"; import { SelectTrigger } from "../ui/select"; const composerControlClassName = - "h-7 min-h-7 gap-1.5 px-2.5 text-muted-foreground/70 transition-none hover:text-foreground/80 [&_svg[data-composer-control-icon]]:mx-0 [&_svg[data-composer-control-chevron]]:-mx-0.5"; + "h-7 min-h-7 gap-1.5 px-2.5 text-secondary-label transition-none hover:text-foreground [&_svg[data-composer-control-icon]]:mx-0 [&_svg[data-composer-control-chevron]]:-mx-0.5"; export function ComposerControl({ className, @@ -46,7 +46,7 @@ export function ComposerControlChevron() { return (

- + {activeQuestion.header} {prompt.questions.length > 1 ? ( - + {questionIndex + 1}/{prompt.questions.length} ) : null}

{activeQuestion.question}

{activeQuestion.multiSelect ? ( -

Select one or more options.

+

Select one or more options.

) : null}
{activeQuestion.options.map((option, index) => { @@ -190,7 +190,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
{option.label} {option.description && option.description !== option.label ? ( - {option.description} + {option.description} ) : null}
{isSelected ? ( @@ -199,7 +199,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( {shortcutKey} diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx index 602ad114464..5e9e43dcf21 100644 --- a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx @@ -63,13 +63,13 @@ export function ComposerPreviewAnnotationCards({ /> ) : ( - + )}
{annotation.comment.trim() ? ( -

+

{annotation.comment.trim()}

) : null} @@ -84,13 +84,13 @@ export function ComposerPreviewAnnotationCards({ {elementLabels.slice(0, 2).map(({ id, label }) => ( {label} ))} {elementLabels.length > 2 ? ( - + +{elementLabels.length - 2} ) : null} @@ -131,7 +131,7 @@ export function ComposerPreviewAnnotationCards({ ) : ( -
+
{image.name}
)} @@ -1270,7 +1268,7 @@ function WorkingTimelineRow({ row }: { row: Extract -
+
@@ -1349,9 +1347,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ return (
{!onlyToolEntries && ( -

- {groupLabel} -

+

{groupLabel}

)}
{nonEmptyEntries.map((workEntry) => ( @@ -1391,7 +1387,7 @@ function WorkGroupToggleTimelineRow({ ctx.onToggleWorkGroup(row.groupId, anchorElement); }} > - + {row.expanded ? ( - + Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"} ) : ( - + +{row.hiddenCount} previous {labelNoun} )} @@ -1509,7 +1505,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi + {props.context.header} @@ -1549,13 +1545,13 @@ function UserMessagePreviewAnnotationCard(props: { ) : null}
{props.annotation.comment ? ( -
+
{props.annotation.comment}
) : null}
@@ -1644,7 +1640,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop aria-expanded={expanded} data-scroll-anchor-ignore onClick={() => setExpanded((value) => !value)} - className="-ml-1 h-6 rounded-md px-1.5 text-xs text-muted-foreground/72 hover:bg-muted/55 hover:text-foreground/85" + className="-ml-1 h-6 rounded-md px-1.5 text-secondary-label text-xs hover:bg-muted/55 hover:text-message-foreground" > {expanded ? "Show less" : "Show full message"} @@ -1683,7 +1679,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { cwd={props.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={props.skills} - className="text-foreground" + className="text-message-foreground" lineBreaks /> ) : null} @@ -1695,7 +1691,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { const reviewCommentSegments = parseReviewCommentMessageSegments(props.text); if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) { return ( -
+
{reviewCommentSegments.map((segment) => segment.kind === "text" ? ( segment.text.trim().length > 0 ? ( @@ -1705,7 +1701,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { cwd={props.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={props.skills} - className="text-foreground" + className="text-message-foreground" lineBreaks />
@@ -1764,7 +1760,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1793,7 +1789,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { cwd={props.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={props.skills} - className="text-foreground" + className="text-message-foreground" lineBreaks />, ); @@ -1802,7 +1798,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1818,7 +1814,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { cwd={props.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={props.skills} - className="text-foreground" + className="text-message-foreground" lineBreaks /> ); @@ -1835,10 +1831,10 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte return (
-
+
{formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
-
+
{comment.sectionTitle} · {comment.rangeLabel}
@@ -1853,7 +1849,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte cwd={ctx.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={ctx.skills} - className="text-foreground" + className="text-message-foreground" /> )} {renderablePatch?.kind === "files" && @@ -1978,24 +1974,24 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { if (tone === "error") { return { iconName: "circle-alert", - className: "text-foreground/92", + className: "text-foreground", }; } if (tone === "thinking") { return { iconName: "bot", - className: "text-foreground/92", + className: "text-foreground", }; } if (tone === "info") { return { iconName: "check", - className: "text-muted-foreground", + className: "text-icon-muted", }; } return { iconName: "zap", - className: "text-foreground/92", + className: "text-foreground", }; } @@ -2244,14 +2240,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { : showDestructiveRowStyle ? "text-destructive" : workEntry.tone === "tool" || showFailedIndicator - ? "text-muted-foreground/65" + ? "text-icon-muted" : iconConfig.className, ); const headingClass = showWarningIndicator ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" - : "font-medium text-foreground/82"; + : "font-medium text-foreground"; const turnSettled = !activity.activeTurnInProgress; const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); const showSuccessIndicator = @@ -2293,11 +2289,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {

{heading} {preview && ( - {preview} + {preview} )}

-
+
-
+          
             {expandedBody}
           
diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index a74a4ebf8c2..70475ffd038 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -69,7 +69,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
{props.showNewBadge ? ( New diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 24ec66cd614..82ee33615b0 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -29,7 +29,7 @@ const SELECTED_INDICATOR_CLASS = "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.75 -translate-y-1/2 rounded-l-full bg-primary"; const BADGE_BASE_CLASS = "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent shadow-sm "; -const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; +const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-update `; /** Opens toward the rail so the list stays readable (not over the model names). */ const PICKER_TOOLTIP_SIDE = "left" as const; diff --git a/apps/web/src/components/chat/PierreEntryIcon.tsx b/apps/web/src/components/chat/PierreEntryIcon.tsx index 17dfa8362af..df41adb7dd5 100644 --- a/apps/web/src/components/chat/PierreEntryIcon.tsx +++ b/apps/web/src/components/chat/PierreEntryIcon.tsx @@ -73,9 +73,9 @@ export const PierreEntryIcon = memo(function PierreEntryIcon(props: { if (!icon) { return props.kind === "directory" ? ( - + ) : ( - + ); } diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a736cf96cd3..090acdb9c02 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -23,7 +23,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; -import { resolveDiffThemeName } from "~/lib/diffRendering"; +import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; import { resolvePathLinkTarget } from "~/terminal-links"; @@ -84,6 +84,16 @@ const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; const FILE_SAVE_DEBOUNCE_MS = 500; const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; const FILE_LINK_REVEAL_UNSAFE_CSS = ` + ${DIFF_SURFACE_THEME_UNSAFE_CSS} + + diffs-container { + --diffs-bg: var(--code-background, var(--background)) !important; + --diffs-light-bg: var(--code-background, var(--background)) !important; + --diffs-dark-bg: var(--code-background, var(--background)) !important; + background-color: var(--code-background, var(--background)) !important; + color: var(--code-foreground, var(--foreground)) !important; + } + [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] { background-color: light-dark( color-mix( @@ -959,7 +969,7 @@ export default function FilePreviewPanel({
) : null} {relativePath && file.data?.truncated ? ( -
+
Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
) : null} diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 75235a05307..22a91b7c150 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -122,6 +122,7 @@ describe("KeybindingsSettings.logic", () => { it("formats static and project script command labels", () => { expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle"); + expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle"); expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db"); }); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 2a691943df4..17b1ebdf33d 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -619,7 +619,7 @@ export function ProviderInstanceCard({ "size-5 rounded-sm p-0", versionAdvisory.emphasis === "strong" ? "text-warning hover:text-warning" - : "text-primary hover:text-primary", + : "text-update hover:text-update", )} aria-label="Update available — view details" > diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 9541a0f07e0..05ea2c9f04e 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -85,6 +85,26 @@ function loadDiffPreviewHtml(theme: DiffThemeName): Promise { return promise; } +// Pierre's prerendered stylesheet bakes its own light/dark surface colors +// into the shadow root's @layer rules. These unlayered rules win the cascade +// without !important and re-point the surfaces at the app's code tokens +// (custom properties inherit across the shadow boundary), so the preview +// follows the active theme exactly like the real diff panel does. +const DIFF_PREVIEW_THEME_BRIDGE = ` + :host { + color: var(--code-foreground); + background-color: var(--code-background); + --diffs-fg: var(--code-foreground); + --diffs-bg: var(--code-background); + --diffs-light-bg: var(--code-background); + --diffs-dark-bg: var(--code-background); + } + [data-diffs-header] { + background-color: var(--code-background); + color: var(--code-foreground); + } +`; + function StaticDiffHtml({ html }: { html: string }) { const hostRef = useRef(null); useEffect(() => { @@ -92,6 +112,9 @@ function StaticDiffHtml({ html }: { html: string }) { if (host === null) return; const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); shadow.innerHTML = html; + const bridge = document.createElement("style"); + bridge.textContent = DIFF_PREVIEW_THEME_BRIDGE; + shadow.append(bridge); }, [html]); return
; } @@ -158,7 +181,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu const mountRef = useRef(null); const surfaceRef = useRef(null); const fontRef = useRef({ family, size }); - const { resolvedTheme } = useTheme(); + const { theme, resolvedTheme } = useTheme(); useEffect(() => { const current = fontRef.current; @@ -167,12 +190,14 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu void surfaceRef.current?.setFont(previewTerminalFont(family, size)); }, [family, size]); + // Re-read the terminal tokens on any theme change — switching between two + // palettes can leave resolvedTheme (light/dark) untouched. useEffect(() => { const mount = mountRef.current; const surface = surfaceRef.current; if (!mount || !surface) return; surface.setTheme(terminalThemeFromApp(mount)); - }, [resolvedTheme]); + }, [theme, resolvedTheme]); useEffect(() => { const mount = mountRef.current; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 00d9573a1ac..5b9347f0307 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -52,7 +52,13 @@ import { } from "../SidebarStageBackdrop"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; -import { useTheme } from "../../hooks/useTheme"; +import { useCustomThemes } from "../../hooks/useCustomThemes"; +import { + readAppearanceModePreference, + readThemeHalves, + readThemePreference, + useTheme, +} from "../../hooks/useTheme"; import { useLocalStorage } from "../../hooks/useLocalStorage"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; @@ -106,6 +112,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ThemeLibrary } from "./ThemeSettings"; import { backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, @@ -131,21 +138,6 @@ import { import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; -const THEME_OPTIONS = [ - { - value: "system", - label: "System", - }, - { - value: "light", - label: "Light", - }, - { - value: "dark", - label: "Dark", - }, -] as const; - const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", pill: "Version pill", @@ -432,7 +424,15 @@ function AboutVersionSection() { } export function useSettingsRestore(onRestored?: () => void) { - const { theme, setTheme } = useTheme(); + const { + theme, + setTheme, + followSystem, + setFollowSystem, + setThemeHalf, + clearThemeHalves, + themeHalves, + } = useTheme(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -445,6 +445,8 @@ export function useSettingsRestore(onRestored?: () => void) { const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), + ...(!followSystem ? ["Follow system"] : []), + ...(themeHalves !== null ? ["Theme mix"] : []), ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), ...(settings.environmentIdentificationMode !== DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode @@ -525,6 +527,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, + followSystem, theme, ], ); @@ -539,7 +542,57 @@ export function useSettingsRestore(onRestored?: () => void) { ); if (!confirmed) return; - setTheme("system"); + // Only touch the theme keys that are actually dirty, so a theme-storage + // failure cannot block restoring unrelated settings. Preferences are + // re-read after the confirmation dialog: they may have changed (another + // tab, an OS flip) while it was open, and rollback must restore the live + // values rather than the ones captured at render time. + let previousTheme = theme; + try { + previousTheme = readThemePreference(); + } catch { + // Storage is unreadable; the render-time value is the best rollback. + } + // The mix may have changed while the confirmation dialog was open; both + // the dirty check and the rollback must see the live value. + const liveHalves = readThemeHalves(); + const needsThemeReset = previousTheme !== "system"; + const needsMixReset = liveHalves !== null; + // Same for the appearance mode: trusting the render-time value would skip + // the reset and report success while a non-system mode stayed in storage. + const needsFollowSystemReset = readAppearanceModePreference(previousTheme) !== "system"; + const notifyThemeRestoreFailure = () => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn’t restore theme settings", + description: "Try again.", + }), + ); + }; + // Rollback restores the base preference first (which clears any mix) and + // then re-applies the captured mix on top, so no failure path can leave + // the pair of keys half-restored. + const previousHalves = liveHalves; + const rollbackThemeState = () => { + if (needsThemeReset) setTheme(previousTheme); + if (previousHalves?.light) setThemeHalf("light", previousHalves.light); + if (previousHalves?.dark) setThemeHalf("dark", previousHalves.dark); + }; + if (needsThemeReset && !setTheme("system")) { + notifyThemeRestoreFailure(); + return; + } + if (needsMixReset && !clearThemeHalves()) { + rollbackThemeState(); + notifyThemeRestoreFailure(); + return; + } + if (needsFollowSystemReset && !setFollowSystem(true)) { + rollbackThemeState(); + notifyThemeRestoreFailure(); + return; + } updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, @@ -566,7 +619,17 @@ export function useSettingsRestore(onRestored?: () => void) { fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, }); onRestored?.(); - }, [changedSettingLabels, onRestored, setTheme, updateSettings]); + }, [ + changedSettingLabels, + clearThemeHalves, + onRestored, + setFollowSystem, + setTheme, + setThemeHalf, + theme, + themeHalves, + updateSettings, + ]); return { changedSettingLabels, @@ -841,7 +904,18 @@ function BackgroundActivityAdvancedDialog({ } export function AppearanceSettingsPanel() { - const { theme, setTheme } = useTheme(); + const { + appearanceMode, + refreshTheme, + resolvedTheme, + setAppearanceMode, + setTheme, + setThemeHalf, + theme, + themeHalves, + } = useTheme(); + const customThemes = useCustomThemes(); + const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const environmentStageLabel = useEnvironmentStageLabel(); @@ -857,38 +931,21 @@ export function AppearanceSettingsPanel() { return ( - setTheme("system")} /> - ) : null - } - control={ - - } - /> +
+ +
> = { + canvas: "Background", + toolbar: "Toolbar background", + toolbarForeground: "Toolbar text", + toolbarBorder: "Toolbar border", + toolbarControl: "Toolbar control", + toolbarControlForeground: "Toolbar control text", + toolbarControlHover: "Toolbar control hover", + accent: "Accent color", + errorForeground: "Error text", + errorSurface: "Error background", + warningForeground: "Warning text", + warningSurface: "Warning background", + updateForeground: "Update text", + updateSurface: "Update background", + }; + const label = labels[role]; + if (label) return label; + return role.replace(/([A-Z])/g, " $1").replace(/^./, (character) => character.toUpperCase()); +} + +type ThemeColorHsv = { + h: number; + s: number; + v: number; +}; + +function clampThemeColor(value: number, min = 0, max = 1) { + return Math.min(max, Math.max(min, value)); +} + +/** + * The picker's plane and sliders operate on opaque six-digit hex, but theme + * colors may carry alpha. The suffix is preserved separately and re-attached + * on commit so adjusting hue or brightness cannot change transparency. + */ +function themePickerAlphaSuffix(value: string): string { + const trimmed = value.trim().toLowerCase(); + const alpha = /^#[0-9a-f]{4}$/.test(trimmed) + ? trimmed.slice(4).repeat(2) + : /^#[0-9a-f]{8}$/.test(trimmed) + ? trimmed.slice(7) + : ""; + return alpha === "ff" ? "" : alpha; +} + +function normalizeThemePickerColor(value: string): string { + const trimmed = value.trim(); + if (/^#[0-9a-f]{3}$/i.test(trimmed)) { + return `#${trimmed + .slice(1) + .split("") + .map((character) => `${character}${character}`) + .join("")}`; + } + if (/^#[0-9a-f]{4}$/i.test(trimmed)) { + return `#${trimmed + .slice(1, 4) + .split("") + .map((character) => `${character}${character}`) + .join("")}`; + } + if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed; + if (/^#[0-9a-f]{8}$/i.test(trimmed)) return trimmed.slice(0, 7); + return "#000000"; +} + +function themeHexToHsv(hex: string): ThemeColorHsv { + const normalized = normalizeThemePickerColor(hex); + const numeric = Number.parseInt(normalized.slice(1), 16); + const red = ((numeric >> 16) & 255) / 255; + const green = ((numeric >> 8) & 255) / 255; + const blue = (numeric & 255) / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + + let hue = 0; + if (delta !== 0) { + if (max === red) { + hue = ((green - blue) / delta) % 6; + } else if (max === green) { + hue = (blue - red) / delta + 2; + } else { + hue = (red - green) / delta + 4; + } + hue *= 60; + if (hue < 0) hue += 360; + } + + return { + h: hue, + s: max === 0 ? 0 : delta / max, + v: max, + }; +} + +function themeHsvToHex(hue: number, saturation: number, value: number) { + const normalizedHue = ((hue % 360) + 360) % 360; + const chroma = value * saturation; + const x = chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1)); + const match = value - chroma; + const [red, green, blue] = + normalizedHue < 60 + ? [chroma, x, 0] + : normalizedHue < 120 + ? [x, chroma, 0] + : normalizedHue < 180 + ? [0, chroma, x] + : normalizedHue < 240 + ? [0, x, chroma] + : normalizedHue < 300 + ? [x, 0, chroma] + : [chroma, 0, x]; + + return `#${[red, green, blue] + .map((channel) => + Math.round((channel + match) * 255) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; +} + +function themeHexToRgb(hex: string) { + const numeric = Number.parseInt(normalizeThemePickerColor(hex).slice(1), 16); + return [numeric >> 16, (numeric >> 8) & 255, numeric & 255] as const; +} + +function themeRgbToHex(value: string): string | null { + const normalized = value + .trim() + .replace(/^rgb\(\s*/i, "") + .replace(/\s*\)$/, ""); + const channels = normalized + .split(/[,\s]+/) + .filter(Boolean) + .map(Number); + if ( + channels.length !== 3 || + channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255) + ) { + return null; + } + + return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +} + +function themeRgbValue(hex: string) { + return themeHexToRgb(hex).join(", "); +} + +function ThemeColorPickerPanel({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (value: string) => void; +}) { + const normalizedValue = normalizeThemePickerColor(value); + const alphaSuffix = themePickerAlphaSuffix(value); + const [hsv, setHsv] = useState(() => themeHexToHsv(normalizedValue)); + const [hexDraft, setHexDraft] = useState(normalizedValue); + const [rgbDraft, setRgbDraft] = useState(() => themeRgbValue(normalizedValue)); + const [isDragging, setIsDragging] = useState(false); + const isEditingTextRef = useRef(false); + const currentColor = themeHsvToHex(hsv.h, hsv.s, hsv.v); + const currentRgb = themeRgbValue(currentColor); + + useEffect(() => { + // While a text field is focused, the incoming value may be the guided + // editor's readability-adjusted echo of what is being typed; rewriting the + // draft would fight the keystrokes. The swatch still tracks via hsv. + if (!isEditingTextRef.current) { + setHexDraft(normalizedValue); + setRgbDraft(themeRgbValue(normalizedValue)); + } + // Keep the current hue/saturation when the incoming value is just our own + // change echoed back; hex → HSV is lossy for greys, white, and black. + setHsv((current) => + themeHsvToHex(current.h, current.s, current.v) === normalizedValue + ? current + : themeHexToHsv(normalizedValue), + ); + }, [normalizedValue]); + + // Local state updates immediately for a smooth thumb; the parent commit + // (which can regenerate a whole guided palette) is batched to one call per + // animation frame. + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const pendingCommitRef = useRef(null); + const commitFrameRef = useRef(null); + // The final drag frame must not be lost when the popover closes or the + // pointer lifts before the animation frame fires. + const flushPendingCommit = useCallback(() => { + if (commitFrameRef.current !== null) { + cancelAnimationFrame(commitFrameRef.current); + commitFrameRef.current = null; + } + const pending = pendingCommitRef.current; + pendingCommitRef.current = null; + if (pending !== null) onChangeRef.current(pending); + }, []); + useEffect(() => () => flushPendingCommit(), [flushPendingCommit]); + const scheduleCommit = useCallback((color: string) => { + pendingCommitRef.current = color; + commitFrameRef.current ??= requestAnimationFrame(() => { + commitFrameRef.current = null; + const pending = pendingCommitRef.current; + pendingCommitRef.current = null; + if (pending !== null) onChangeRef.current(pending); + }); + }, []); + + const commitHsv = useCallback( + (nextHsv: ThemeColorHsv) => { + setHsv(nextHsv); + const nextColor = themeHsvToHex(nextHsv.h, nextHsv.s, nextHsv.v); + setHexDraft(nextColor); + setRgbDraft(themeRgbValue(nextColor)); + scheduleCommit(nextColor + alphaSuffix); + }, + [alphaSuffix, scheduleCommit], + ); + + const updateFromPlane = useCallback( + (event: PointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const saturation = clampThemeColor((event.clientX - bounds.left) / bounds.width); + const value = 1 - clampThemeColor((event.clientY - bounds.top) / bounds.height); + commitHsv({ ...hsv, s: saturation, v: value }); + }, + [commitHsv, hsv], + ); + + const updateFromHue = useCallback( + (event: PointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const hue = clampThemeColor((event.clientX - bounds.left) / bounds.width) * 360; + commitHsv({ ...hsv, h: hue }); + }, + [commitHsv, hsv], + ); + + const handleHueKeyDown = (event: KeyboardEvent) => { + const step = event.shiftKey ? 10 : 1; + const direction = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : -1; + if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return; + event.preventDefault(); + commitHsv({ ...hsv, h: (hsv.h + direction * step + 360) % 360 }); + }; + + const handlePlaneKeyDown = (event: KeyboardEvent) => { + if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return; + event.preventDefault(); + const step = event.shiftKey ? 0.1 : 0.02; + const nextHsv = { ...hsv }; + if (event.key === "ArrowLeft") nextHsv.s = clampThemeColor(hsv.s - step); + if (event.key === "ArrowRight") nextHsv.s = clampThemeColor(hsv.s + step); + if (event.key === "ArrowUp") nextHsv.v = clampThemeColor(hsv.v + step); + if (event.key === "ArrowDown") nextHsv.v = clampThemeColor(hsv.v - step); + commitHsv(nextHsv); + }; + + const handlePointerDown = (handler: (event: PointerEvent) => void) => { + return (event: PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId); + setIsDragging(true); + handler(event); + }; + }; + + const stopDragging = () => { + setIsDragging(false); + flushPendingCommit(); + }; + + // Thumbs travel inside the control by half their own size so they never + // clip at the extremes; movement only animates for keyboard steps and + // click-to-jump, never while dragging. + const thumbTransition = isDragging + ? undefined + : "left 80ms linear, top 80ms linear, background-color 80ms linear"; + + const handleHexChange = (nextValue: string) => { + setHexDraft(nextValue); + if (!/^#[0-9a-f]{6}$/i.test(nextValue)) return; + const nextHsv = themeHexToHsv(nextValue); + setHsv(nextHsv); + setRgbDraft(themeRgbValue(nextValue)); + onChange(nextValue.toLowerCase()); + }; + + const handleRgbChange = (nextValue: string) => { + setRgbDraft(nextValue); + const nextColor = themeRgbToHex(nextValue); + if (!nextColor) return; + setHsv(themeHexToHsv(nextColor)); + setHexDraft(nextColor); + // RGB cannot express alpha, so a commit keeps the incoming suffix just + // like the plane and hue controls do. + onChange(nextColor + alphaSuffix); + }; + + return ( +
+
+
+

{label}

+

Choose a color

+
+ +
+
+
{ + if (event.currentTarget.hasPointerCapture(event.pointerId)) updateFromPlane(event); + }} + onPointerUp={stopDragging} + > + +
+
{ + if (event.currentTarget.hasPointerCapture(event.pointerId)) updateFromHue(event); + }} + onPointerUp={stopDragging} + > + + +
+
+ + +
+
+
+ ); +} + +function ThemeColorPicker({ + label, + value, + onChange, + onInteract, +}: { + label: string; + value: string; + onChange: (value: string) => void; + onInteract?: () => void; +}) { + return ( + + + + + } + /> + + + + + ); +} + +export const ThemeColorField = memo(function ThemeColorField({ + role, + value, + onChange, + onSelect, + onToggleSelected, + selected = false, + label: customLabel, +}: { + role: ThemeColorRole; + value: string; + onChange: (role: ThemeColorRole, value: string) => void; + onSelect?: (role: ThemeColorRole) => void; + onToggleSelected?: (role: ThemeColorRole) => void; + selected?: boolean; + label?: string; +}) { + const label = customLabel ?? getThemeRoleLabel(role); + const isColorValue = isThemeColor(value); + const swatchValue = isColorValue ? value : "#000000"; + + return ( +
+ +
+ onChange(role, nextValue)} + onInteract={() => onSelect?.(role)} + value={swatchValue} + /> + onChange(role, event.currentTarget.value)} + onFocus={() => onSelect?.(role)} + onPointerDown={() => onSelect?.(role)} + size="sm" + unstyled + value={value} + /> +
+
+ ); +}); diff --git a/apps/web/src/components/settings/ThemeEditorHost.tsx b/apps/web/src/components/settings/ThemeEditorHost.tsx new file mode 100644 index 00000000000..faf2d770e90 --- /dev/null +++ b/apps/web/src/components/settings/ThemeEditorHost.tsx @@ -0,0 +1,114 @@ +import { useCallback } from "react"; + +import { useTheme } from "../../hooks/useTheme"; +import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { ThemeEditorPanel } from "./ThemeEditorPanel"; +import { useThemeEditorStore } from "./themeEditorStore"; + +/** + * Renders the theme editor above the router. The editor paints its draft on + * the live app, so it has to outlive the settings route: the point is to walk + * through threads, panels, and pages while the colors are being tuned. + */ +export function ThemeEditorHost() { + const session = useThemeEditorStore((store) => store.session); + const closeThemeEditor = useThemeEditorStore((store) => store.closeThemeEditor); + const { theme, setTheme, themeHalves, refreshTheme } = useTheme(); + + // The panel reports which path it actually took: a theme removed while its + // editor is open resolves to null there, so the save becomes a create even + // though the session still names it. + const handleSaved = useCallback( + ( + savedTheme: ThemeDefinition, + { created, mergedAppearance }: { created: boolean; mergedAppearance?: ThemeAppearance }, + ) => { + // A merge completed an existing theme's light/dark pair; activating the + // whole theme shows the new palette right away. + if (mergedAppearance) { + if (!setTheme(savedTheme.id)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not save your theme", + description: "Browser storage is unavailable, so the change was not kept.", + }), + ); + return false; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: `${savedTheme.label} updated`, + description: `Its ${mergedAppearance} palette was added.`, + }), + ); + return true; + } + if (!created) { + // The edited theme may be showing through the base preference or either + // half of the mix; the preference itself is untouched (a setTheme here + // would clear the mix), the palette just needs re-applying. + const wasActive = + getThemeDefinition(theme)?.id === savedTheme.id || + themeHalves?.light === savedTheme.id || + themeHalves?.dark === savedTheme.id; + if (wasActive) refreshTheme(); + toastManager.add( + stackedThreadToast({ + type: "success", + title: `${savedTheme.label} saved`, + description: wasActive ? "Your changes are now active." : "Your changes are saved.", + }), + ); + return true; + } + + if (!setTheme(savedTheme.id)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not save your theme", + description: "Browser storage is unavailable, so the change was not kept.", + }), + ); + return false; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: `${savedTheme.label} created`, + description: "It’s now active.", + }), + ); + return true; + }, + [refreshTheme, setTheme, theme, themeHalves], + ); + + if (!session) return null; + + // Resolve on every render: an edit or import can change the stored + // definitions while a session is open. + const editingTheme = session.editingThemeId + ? (getThemeDefinition(session.editingThemeId) ?? null) + : null; + const seedTheme = session.seedThemeId ? (getThemeDefinition(session.seedThemeId) ?? null) : null; + + return ( + { + if (!open) closeThemeEditor(); + }} + onSaved={handleSaved} + open + restoreTheme={refreshTheme} + seedName={session.seedName ?? undefined} + seedTheme={seedTheme} + /> + ); +} diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx new file mode 100644 index 00000000000..0074ac89304 --- /dev/null +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -0,0 +1,1143 @@ +import { ChevronDownIcon, ChevronUpIcon, MousePointer2Icon, PlusIcon, XIcon } from "lucide-react"; +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { + applyThemeColorPreview, + THEME_COLOR_ROLES, + THEME_FILE_VERSION, + createVividThemeColors, + getCustomThemes, + getStandardThemeColors, + getThemeColorsForMode, + getThemeModes, + installCustomTheme, + isThemeColor, + parseThemeFile, + removeCustomTheme, + themeIdFromName, + updateCustomTheme, + type ThemeAppearance, + type ThemeColorRole, + type ThemeDefinition, +} from "../../themePalette"; +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Switch } from "../ui/switch"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { getThemeRoleLabel, ThemeColorField } from "./ThemeColorPicker"; +import { + clearThemeInspectorHover, + clearThemeInspectorHighlights, + highlightThemeRoleUsage, + inspectThemeRoleAtElement, + inspectThemeRoleFromUtilitiesAtElement, + refreshThemeInspectorSpotlight, + showThemeInspectorHover, + type ThemeElementInspection, +} from "./themeInspector"; + +const THEME_EDITOR_PRIMARY_ROLES: ReadonlyArray = [ + "canvas", + "chrome", + "sidebar", + "surface", + "text", + "textMuted", + "placeholder", + "secondaryLabel", + "iconMuted", + "accent", + "messageSurface", + "messageAction", +]; + +const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray = ["canvas", "accent"]; + +const THEME_EDITOR_STATUS_ROLES: ReadonlyArray = [ + "error", + "errorForeground", + "errorSurface", + "warning", + "warningForeground", + "warningSurface", + "update", + "updateForeground", + "updateSurface", +]; + +const THEME_EDITOR_ADVANCED_ROLES = THEME_COLOR_ROLES.filter( + (role) => !THEME_EDITOR_PRIMARY_ROLES.includes(role) && !THEME_EDITOR_STATUS_ROLES.includes(role), +); + +const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{ + id: string; + title: string; + roles: ReadonlyArray; +}> = [ + { + id: "main", + title: "Main colors", + roles: THEME_EDITOR_PRIMARY_ROLES, + }, + { + id: "status", + title: "Status colors", + roles: THEME_EDITOR_STATUS_ROLES, + }, + { + id: "additional", + title: "Other colors", + roles: THEME_EDITOR_ADVANCED_ROLES, + }, +]; + +type ThemeEditorColors = Record; +type ThemeEditorColorsByAppearance = Record; + +// A draft with no source theme starts as the standard T3 Code look — the +// palette on screen when no theme is installed — so creating from the default +// theme changes nothing until the user edits a color. +function getThemeEditorDefaults(appearance: ThemeAppearance): ThemeEditorColors { + return { ...getStandardThemeColors(appearance) }; +} + +function getThemeEditorColorsByAppearance(): ThemeEditorColorsByAppearance { + return { + light: getThemeEditorDefaults("light"), + dark: getThemeEditorDefaults("dark"), + }; +} + +function isThemeEditorColor(value: string): boolean { + return isThemeColor(value.trim()); +} + +function getManagedEditorColors( + appearance: ThemeAppearance, + colors: ThemeEditorColors, +): ThemeEditorColors { + const defaults = getStandardThemeColors(appearance); + // The editor keeps the user's exact picks and derives the rest through the + // perceptual vivid engine, so a two-color theme carries its own identity. + return createVividThemeColors( + appearance, + isThemeEditorColor(colors.canvas) ? colors.canvas : defaults.canvas, + isThemeEditorColor(colors.accent) ? colors.accent : defaults.accent, + ); +} + +export function ThemeEditorPanel({ + open, + onOpenChange, + onSaved, + editingTheme, + initialAppearance, + seedTheme, + seedName, + restoreTheme, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: ( + theme: ThemeDefinition, + context: { + created: boolean; + /** Set when a create merged its palette into an existing theme. */ + mergedAppearance?: ThemeAppearance; + }, + ) => boolean; + editingTheme: ThemeDefinition | null; + initialAppearance: ThemeAppearance; + /** The theme a new theme starts from, so tuning what you already use is a + * matter of editing rather than rebuilding. Null starts from the defaults. */ + seedTheme?: ThemeDefinition | null; + /** Prefilled name for an explicit duplicate; a plain create stays unnamed. */ + seedName?: string | undefined; + /** Reapplies the stored theme once the draft stops being previewed. */ + restoreTheme: () => void; +}) { + const isEditing = editingTheme !== null; + const [name, setName] = useState(""); + const [activeAppearance, setActiveAppearance] = useState(initialAppearance); + const [isAdvanced, setIsAdvanced] = useState(false); + const [colorsByAppearance, setColorsByAppearance] = useState(() => + getThemeEditorColorsByAppearance(), + ); + const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState< + Record + >({ light: false, dark: false }); + const [error, setError] = useState(null); + const [isMinimized, setIsMinimized] = useState(false); + const [roleQuery, setRoleQuery] = useState(""); + const [isInspecting, setIsInspecting] = useState(false); + const [selectedRole, setSelectedRole] = useState(null); + const [usageCount, setUsageCount] = useState(null); + // Null parks the panel at its default corner; a value is a dragged spot, + // kept clamped so the header can always be grabbed again. + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + // Null keeps the responsive default size; a value is a corner-grip resize. + const [size, setSize] = useState<{ width: number; height: number } | null>(null); + const panelRef = useRef(null); + const dragOffsetRef = useRef<{ dx: number; dy: number } | null>(null); + const resizeStartRef = useRef<{ + pointerX: number; + pointerY: number; + // Where the panel's top-left sits: the grip only moves the opposite + // corner, so the room to grow is measured from here. + left: number; + top: number; + width: number; + height: number; + } | null>(null); + useEffect(() => { + if (!open) return; + // A panel sized wider than the window can no longer be clamped back into + // view by position alone -- its right edge (close, minimize, the grip) + // stays off screen. So the size shrinks to fit first, then the position + // is re-clamped against the new size. + const clamp = () => { + const margin = 8; + let clampedWidth: number | undefined; + let clampedHeight: number | undefined; + setSize((current) => { + if (!current) return current; + clampedWidth = Math.max(280, Math.min(current.width, window.innerWidth - margin * 2)); + clampedHeight = Math.max(220, Math.min(current.height, window.innerHeight - margin * 2)); + return { width: clampedWidth, height: clampedHeight }; + }); + setPosition((current) => { + if (!current) return current; + const clamped = clampPosition(current.x, current.y, clampedWidth); + // Dragging may park the panel with only its header showing, but a + // window resize should pull the whole thing back into view when it + // fits -- otherwise the grip ends up below the fold. Minimized, the + // stored height is not applied (the panel hugs its header), so the + // rendered height is what has to fit. + const height = isMinimized + ? (panelRef.current?.offsetHeight ?? 0) + : (clampedHeight ?? panelRef.current?.offsetHeight ?? 0); + const maxY = Math.max(margin, window.innerHeight - height - margin); + return { x: clamped.x, y: Math.min(clamped.y, maxY) }; + }); + }; + window.addEventListener("resize", clamp); + return () => window.removeEventListener("resize", clamp); + // oxlint-disable-next-line exhaustive-deps -- clampPosition reads live layout only. + }, [isMinimized, open]); + + // The draft only reaches the live app once this open has been seeded; + // previewing in the seeding commit would paint the previous session's + // colors for a frame. + const [isDraftSeeded, setIsDraftSeeded] = useState(false); + const previousOpenRef = useRef(false); + + useEffect(() => { + if (open && !previousOpenRef.current) { + // Editing works on the theme itself; creating starts from the theme + // that is currently in use, so tuning what you already run is an edit + // away instead of a rebuild from the defaults. + const sourceTheme = editingTheme ?? seedTheme ?? null; + const nextColors = getThemeEditorColorsByAppearance(); + const nextAppearance = sourceTheme + ? getThemeColorsForMode(sourceTheme, initialAppearance) + ? initialAppearance + : sourceTheme.appearance + : initialAppearance; + if (sourceTheme) { + nextColors[sourceTheme.appearance] = { ...sourceTheme.colors }; + for (const appearance of ["light", "dark"] as const) { + const variantColors = sourceTheme.variants?.[appearance]; + if (variantColors) nextColors[appearance] = { ...variantColors }; + } + } + + setName(editingTheme?.label ?? seedName ?? ""); + setActiveAppearance(nextAppearance); + // Themes saved by the guided editor carry the managed flag; anything + // else (imports, hand-edited files, older saves) opens in advanced mode + // so guided regeneration cannot silently discard hand-tuned colors. A + // seeded new theme follows the same rule: its palette is only safe to + // regenerate when the guided editor produced it. + setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true); + setSimpleColorsDirtyByAppearance({ light: false, dark: false }); + setColorsByAppearance(nextColors); + setSelectedRole(null); + setUsageCount(null); + setIsInspecting(false); + setError(null); + setIsDraftSeeded(true); + } + if (!open && isDraftSeeded) setIsDraftSeeded(false); + previousOpenRef.current = open; + }, [editingTheme, initialAppearance, isDraftSeeded, open, seedName, seedTheme]); + + // A name an installed theme already uses combines instead of failing: + // creating adds the new palette to that theme, and renaming an existing + // theme onto it folds the edited palette in and retires the old entry — + // light "My Theme" plus a dark "My Theme" become one theme with both modes. + // Labels are matched as well as derived ids: a rename keeps a theme's + // original id, so its label is the only name a user can see and retype. + const nameTargetId = themeIdFromName(name); + const normalizedName = name.trim().toLowerCase(); + const mergeTarget = + normalizedName === "" + ? null + : (getCustomThemes().find( + (theme) => + theme.id !== editingTheme?.id && + (theme.id === nameTargetId || theme.label.trim().toLowerCase() === normalizedName), + ) ?? null); + const takenAppearances = mergeTarget ? getThemeModes(mergeTarget) : []; + const editableAppearances = editingTheme ? getThemeModes(editingTheme) : null; + + // The appearance a mode button would produce can be blocked two ways: the + // merge target already has that palette, or the theme being edited never + // had it (adding one is a create-with-same-name away). + const appearanceLockReason = (appearance: ThemeAppearance): string | null => { + if (editableAppearances && !editableAppearances.includes(appearance)) { + return `“${editingTheme?.label}” has no ${appearance} palette. Create a theme with the same name to add one.`; + } + if (!isEditing && takenAppearances.includes(appearance)) { + return `“${mergeTarget?.label}” already has a ${appearance} palette.`; + } + return null; + }; + + // Typing a name whose theme already owns the selected appearance flips the + // draft to the free side, so the merge affordance works without a manual + // toggle. Both sides taken leaves the selection alone; save is blocked with + // an explanation instead. + const mergeTargetId = mergeTarget?.id ?? null; + const takenAppearancesKey = takenAppearances.join(","); + useEffect(() => { + if (isEditing || mergeTargetId === null) return; + const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[]; + if (taken.length !== 1) return; + setActiveAppearance((current) => { + if (!taken.includes(current)) return current; + return taken[0] === "light" ? "dark" : "light"; + }); + }, [isEditing, mergeTargetId, takenAppearancesKey]); + + // The whole app wears the draft while the editor is open, so a role change + // is judged on the real interface rather than a miniature. The stored theme + // comes back when the editor closes, including on cancel. + useEffect(() => { + if (!open || !isDraftSeeded) return; + applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance); + }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]); + + useEffect(() => { + if (!open) return; + return () => { + restoreTheme(); + }; + }, [open, restoreTheme]); + + const updateColor = useCallback( + (role: ThemeColorRole, value: string) => { + setColorsByAppearance((current) => { + const nextColors = { ...current[activeAppearance], [role]: value }; + const shouldManageColors = + !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value); + + return { + ...current, + [activeAppearance]: shouldManageColors + ? getManagedEditorColors(activeAppearance, nextColors) + : nextColors, + }; + }); + if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) { + setSimpleColorsDirtyByAppearance((current) => ({ + ...current, + [activeAppearance]: true, + })); + } + }, + [activeAppearance, isAdvanced], + ); + + const selectThemeRole = useCallback((role: ThemeColorRole, reveal = false) => { + setSelectedRole(role); + if (!THEME_EDITOR_SIMPLE_ROLES.includes(role)) { + setIsAdvanced(true); + setRoleQuery(""); + } + if (!reveal) return; + + requestAnimationFrame(() => { + panelRef.current + ?.querySelector(`[data-theme-color-role="${role}"]`) + ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); + }, []); + + const toggleThemeRole = useCallback((role: ThemeColorRole) => { + setSelectedRole((current) => (current === role ? null : role)); + }, []); + + const clearInspectorSelection = useCallback(() => { + setSelectedRole(null); + setUsageCount(null); + setIsInspecting(false); + }, []); + + const selectedHighlightRoles = selectedRole + ? !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole) + ? THEME_COLOR_ROLES.filter( + (role) => + colorsByAppearance[activeAppearance][role].trim().toLowerCase() === + colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(), + ) + : [selectedRole] + : []; + const selectedHighlightRolesKey = selectedHighlightRoles.join(","); + + useEffect(() => { + clearThemeInspectorHighlights(); + if (!open || selectedRole === null) { + setUsageCount(null); + return; + } + // Picking a new element needs the unobscured app, so suspend the existing + // spotlight while the picker is armed. + if (isInspecting) return; + + const highlightedRoles = selectedHighlightRolesKey.split(",") as Array; + const refreshHighlights = () => setUsageCount(highlightThemeRoleUsage(highlightedRoles)); + refreshHighlights(); + // A refresh snapshots computed styles for the whole tree twice, so it is + // throttled rather than run per frame: a streaming reply or a virtualized + // list mutates the DOM continuously and would otherwise stall the main + // thread for as long as the inspector is open. + const MIN_REFRESH_INTERVAL_MS = 500; + let refreshFrame: number | null = null; + let refreshTimer: ReturnType | null = null; + let lastRefreshAt = performance.now(); + const scheduleRefresh = () => { + if (refreshFrame !== null || refreshTimer !== null) return; + const wait = Math.max(0, MIN_REFRESH_INTERVAL_MS - (performance.now() - lastRefreshAt)); + const run = () => { + refreshFrame = null; + refreshTimer = null; + lastRefreshAt = performance.now(); + refreshHighlights(); + }; + if (wait === 0) refreshFrame = requestAnimationFrame(run); + else refreshTimer = setTimeout(run, wait); + }; + const observer = new MutationObserver((mutations) => { + if ( + mutations.every( + (mutation) => + mutation.target instanceof Element && + (mutation.target.closest("#theme-inspector-spotlight") || + mutation.target.closest("[data-theme-editor-panel]")), + ) + ) { + return; + } + scheduleRefresh(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + let spotlightFrame: number | null = null; + const scheduleSpotlightRefresh = () => { + spotlightFrame ??= requestAnimationFrame(() => { + spotlightFrame = null; + refreshThemeInspectorSpotlight(); + }); + }; + window.addEventListener("resize", scheduleSpotlightRefresh); + window.addEventListener("scroll", scheduleSpotlightRefresh, true); + return () => { + observer.disconnect(); + if (refreshFrame !== null) cancelAnimationFrame(refreshFrame); + if (refreshTimer !== null) clearTimeout(refreshTimer); + if (spotlightFrame !== null) cancelAnimationFrame(spotlightFrame); + window.removeEventListener("resize", scheduleSpotlightRefresh); + window.removeEventListener("scroll", scheduleSpotlightRefresh, true); + clearThemeInspectorHighlights(); + }; + }, [isInspecting, open, selectedHighlightRolesKey, selectedRole]); + + useEffect(() => { + if (!open || !isInspecting) { + clearThemeInspectorHover(); + return; + } + + let shouldDisarmAfterClick = false; + let hoverTarget: Element | null = null; + let hoverInspection: ThemeElementInspection | null = null; + let hoverTimer: number | null = null; + let hoverFrame: number | null = null; + const clearHoverTimer = () => { + if (hoverTimer === null) return; + window.clearTimeout(hoverTimer); + hoverTimer = null; + }; + const clearHover = () => { + clearHoverTimer(); + hoverTarget = null; + hoverInspection = null; + clearThemeInspectorHover(); + }; + const showInspection = (inspection: ThemeElementInspection) => { + hoverInspection = inspection; + showThemeInspectorHover(inspection, getThemeRoleLabel(inspection.role)); + }; + const handlePointerOver = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) { + clearHover(); + return; + } + + clearHoverTimer(); + hoverTarget = target; + hoverInspection = null; + const utilityInspection = inspectThemeRoleFromUtilitiesAtElement(target); + if (utilityInspection) { + showInspection(utilityInspection); + return; + } + + clearThemeInspectorHover(); + hoverTimer = window.setTimeout(() => { + hoverTimer = null; + if (hoverTarget !== target || !target.isConnected) return; + const inspection = inspectThemeRoleAtElement(target); + if (inspection) showInspection(inspection); + }, 140); + }; + const handlePointerOut = (event: PointerEvent) => { + if (event.relatedTarget === null) clearHover(); + }; + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) return; + event.preventDefault(); + event.stopPropagation(); + clearHoverTimer(); + const inspection = + hoverTarget === target && hoverInspection + ? hoverInspection + : inspectThemeRoleAtElement(target); + if (!inspection) return; + clearHover(); + selectThemeRole(inspection.role, true); + shouldDisarmAfterClick = true; + }; + const blockInspectedClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) return; + event.preventDefault(); + event.stopPropagation(); + if (shouldDisarmAfterClick) setIsInspecting(false); + shouldDisarmAfterClick = false; + }; + const cancelInspection = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + clearHover(); + clearInspectorSelection(); + }; + const refreshHover = () => { + if (!hoverInspection) return; + hoverFrame ??= requestAnimationFrame(() => { + hoverFrame = null; + if (hoverInspection) { + showThemeInspectorHover(hoverInspection, getThemeRoleLabel(hoverInspection.role)); + } + }); + }; + const clearHoverOnScroll = () => clearHover(); + + document.addEventListener("pointerover", handlePointerOver, true); + document.addEventListener("pointerout", handlePointerOut, true); + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("click", blockInspectedClick, true); + document.addEventListener("keydown", cancelInspection, true); + window.addEventListener("resize", refreshHover); + window.addEventListener("scroll", clearHoverOnScroll, true); + return () => { + document.removeEventListener("pointerover", handlePointerOver, true); + document.removeEventListener("pointerout", handlePointerOut, true); + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("click", blockInspectedClick, true); + document.removeEventListener("keydown", cancelInspection, true); + window.removeEventListener("resize", refreshHover); + window.removeEventListener("scroll", clearHoverOnScroll, true); + clearHoverTimer(); + if (hoverFrame !== null) cancelAnimationFrame(hoverFrame); + clearThemeInspectorHover(); + }; + }, [clearInspectorSelection, isInspecting, open, selectThemeRole]); + + const handleAdvancedChange = useCallback( + (checked: boolean) => { + setIsAdvanced(checked); + if (checked) return; + if (selectedRole && !THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)) { + setSelectedRole(null); + } + + // Regenerate every appearance the theme will save, not just the visible + // one, so the palettes shown after toggling match what gets saved. + const managedAppearances: ReadonlyArray = + editingTheme && getThemeModes(editingTheme).length > 1 + ? ["light", "dark"] + : [activeAppearance]; + setSimpleColorsDirtyByAppearance((current) => { + const next = { ...current }; + for (const appearance of managedAppearances) next[appearance] = true; + return next; + }); + setColorsByAppearance((current) => { + const next = { ...current }; + for (const appearance of managedAppearances) { + next[appearance] = getManagedEditorColors(appearance, current[appearance]); + } + return next; + }); + }, + [activeAppearance, editingTheme, selectedRole], + ); + + const handleSubmit = useCallback(() => { + if (!name.trim()) { + setError("Name your theme first."); + return; + } + + try { + // Only regenerate palettes the user actually touched in guided mode, so + // untouched appearances save exactly what the editor displayed. + const colorsForSave = !isAdvanced + ? { + light: simpleColorsDirtyByAppearance.light + ? getManagedEditorColors("light", colorsByAppearance.light) + : colorsByAppearance.light, + dark: simpleColorsDirtyByAppearance.dark + ? getManagedEditorColors("dark", colorsByAppearance.dark) + : colorsByAppearance.dark, + } + : colorsByAppearance; + + let savedTheme: ThemeDefinition; + let mergedAppearance: ThemeAppearance | null = null; + let retiredTheme: ThemeDefinition | null = null; + if (editingTheme && mergeTarget) { + // Renamed onto another installed theme: this theme's palettes fold + // into it and the edited entry retires, so both cards become one. + // Colliding palettes cannot merge — neither side should be silently + // overwritten. + const editedModes = getThemeModes(editingTheme); + const collision = editedModes.find((mode) => takenAppearances.includes(mode)); + if (collision) { + setError(`“${mergeTarget.label}” already has a ${collision} palette. Pick another name.`); + return; + } + mergedAppearance = editedModes[0] ?? null; + savedTheme = updateCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: mergeTarget.id, + name: mergeTarget.label, + appearance: mergeTarget.appearance, + colors: mergeTarget.colors, + variants: { + ...mergeTarget.variants, + ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])), + }, + ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), + }), + ); + retiredTheme = editingTheme; + try { + removeCustomTheme(editingTheme.id); + } catch (cause) { + // The merge already persisted. Leaving it while the edited theme + // survives would collide on every retry, so the target goes back to + // its pre-merge palettes before the failure surfaces. + try { + updateCustomTheme(mergeTarget); + } catch { + // Storage is failing wholesale; the rethrow below reports it. + } + throw cause; + } + } else if (editingTheme) { + const baseAppearance = editingTheme.appearance; + const variantAppearance = baseAppearance === "light" ? "dark" : "light"; + savedTheme = updateCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: editingTheme.id, + name, + appearance: baseAppearance, + colors: colorsForSave[baseAppearance], + ...(getThemeModes(editingTheme).length > 1 + ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } } + : {}), + ...(isAdvanced ? {} : { managed: true }), + }), + ); + } else if (mergeTarget) { + if (takenAppearances.includes(activeAppearance)) { + setError( + `“${mergeTarget.label}” already has light and dark palettes. Pick another name.`, + ); + return; + } + // The new palette joins the existing theme as its other mode; its + // stored palettes are untouched. The guided (managed) flag only + // survives when every palette in the theme came from the guided + // editor. + mergedAppearance = activeAppearance; + savedTheme = updateCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: mergeTarget.id, + name: mergeTarget.label, + appearance: mergeTarget.appearance, + colors: mergeTarget.colors, + variants: { + ...mergeTarget.variants, + [activeAppearance]: colorsForSave[activeAppearance], + }, + ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), + }), + ); + } else { + savedTheme = installCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + name, + appearance: activeAppearance, + colors: colorsForSave[activeAppearance], + ...(isAdvanced ? {} : { managed: true }), + }), + ); + } + if ( + !onSaved(savedTheme, { + created: editingTheme === null && mergedAppearance === null, + ...(mergedAppearance ? { mergedAppearance } : {}), + }) + ) { + if (!editingTheme && mergedAppearance === null) { + // Roll the install back so a retry can run it again instead of + // failing on the already-taken theme id. + try { + removeCustomTheme(savedTheme.id); + } catch { + // Storage is failing wholesale; the error below covers it. + } + } else if (mergeTarget && mergedAppearance !== null) { + // Put the pre-merge definitions back for the same reason. + try { + updateCustomTheme(mergeTarget); + if (retiredTheme) installCustomTheme(retiredTheme); + } catch { + // Storage is failing wholesale; the error below covers it. + } + } + setError("Theme saved, but it could not be made active. Try again."); + return; + } + onOpenChange(false); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : isEditing + ? "Could not save the theme." + : "Could not create the theme.", + ); + } + }, [ + activeAppearance, + colorsByAppearance, + editingTheme, + isAdvanced, + isEditing, + mergeTarget, + name, + onOpenChange, + onSaved, + simpleColorsDirtyByAppearance, + takenAppearances, + ]); + + const renderNameField = () => ( + + ); + + const renderAppearanceButton = (appearance: ThemeAppearance) => { + const isActive = activeAppearance === appearance; + const lockReason = appearanceLockReason(appearance); + // A locked mode stays hoverable so the tooltip can say why it is off; + // a real disabled attribute would swallow the pointer events. + const button = ( + + ); + if (lockReason === null) return button; + return ( + + + {lockReason} + + ); + }; + + const renderAppearanceButtons = () => ( +
+ Appearance +
+ {renderAppearanceButton("light")} + {renderAppearanceButton("dark")} +
+
+ ); + + const renderColorsHeader = () => ( +
+
+

Colors

+ {isAdvanced ? null : ( +

Two colors, rest derived

+ )} +
+
+ {isAdvanced ? ( + setRoleQuery(event.currentTarget.value)} + placeholder="Filter colors" + size="sm" + value={roleQuery} + /> + ) : null} + +
+
+ ); + + const renderRoleFields = ( + roles: ReadonlyArray, + gridClassName = "grid gap-2 sm:grid-cols-2", + ) => ( +
+ {roles.map((role) => ( + + ))} +
+ ); + + const renderColorFields = () => { + const query = roleQuery.trim().toLowerCase(); + const groups = THEME_EDITOR_ROLE_GROUPS.map((group) => ({ + ...group, + roles: group.roles.filter( + (role) => !query || getThemeRoleLabel(role).toLowerCase().includes(query), + ), + })).filter((group) => group.roles.length > 0); + return isAdvanced ? ( +
+ {groups.map((group) => ( +
+

{group.title}

+ {renderRoleFields(group.roles, "grid gap-1")} +
+ ))} + {groups.length === 0 ?

No matches.

: null} +
+ ) : ( +
+ {THEME_EDITOR_SIMPLE_ROLES.map((role) => ( + + ))} +
+ ); + }; + + const clampPosition = (x: number, y: number, widthOverride?: number) => { + const panel = panelRef.current; + const margin = 8; + // The caller passes a width when it has just shrunk the panel: the DOM + // still reports the old one until React commits. + const width = widthOverride ?? panel?.offsetWidth ?? 0; + return { + x: Math.min(Math.max(x, margin), Math.max(margin, window.innerWidth - width - margin)), + // Keep at least the header on screen even when dragged far down. + y: Math.min(Math.max(y, margin), Math.max(margin, window.innerHeight - 48)), + }; + }; + + const handleDragPointerDown = (event: ReactPointerEvent) => { + // Buttons in the header keep their own behavior. + if ((event.target as HTMLElement).closest("button, input, a")) return; + const rect = panelRef.current?.getBoundingClientRect(); + if (!rect) return; + dragOffsetRef.current = { dx: event.clientX - rect.x, dy: event.clientY - rect.y }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleDragPointerMove = (event: ReactPointerEvent) => { + const offset = dragOffsetRef.current; + if (!offset) return; + setPosition(clampPosition(event.clientX - offset.dx, event.clientY - offset.dy)); + }; + + const endDrag = () => { + dragOffsetRef.current = null; + }; + + const handleResizePointerDown = (event: ReactPointerEvent) => { + const rect = panelRef.current?.getBoundingClientRect(); + if (!rect) return; + event.preventDefault(); + // The grip drags the bottom-right corner, so the top-left must hold + // still; the default parking spot is anchored bottom-right and would + // slide, so it converts to an explicit position first. + if (position === null) setPosition(clampPosition(rect.x, rect.y)); + resizeStartRef.current = { + pointerX: event.clientX, + pointerY: event.clientY, + left: rect.x, + top: rect.y, + width: rect.width, + height: rect.height, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleResizePointerMove = (event: ReactPointerEvent) => { + const start = resizeStartRef.current; + if (!start) return; + const margin = 8; + const MIN_WIDTH = 280; + const MIN_HEIGHT = 220; + // Grow only into the space right of and below the panel's own corner, + // otherwise a panel parked away from the top-left pushes its far edges + // (and this grip) off screen. + const maxWidth = Math.max(MIN_WIDTH, window.innerWidth - margin - start.left); + const maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - margin - start.top); + setSize({ + width: Math.min(Math.max(start.width + event.clientX - start.pointerX, MIN_WIDTH), maxWidth), + height: Math.min( + Math.max(start.height + event.clientY - start.pointerY, MIN_HEIGHT), + maxHeight, + ), + }); + }; + + const endResize = () => { + resizeStartRef.current = null; + }; + + return ( +
+
+
+

+ {isEditing ? "Edit theme" : "Create theme"} +

+ {isMinimized ? null : ( +

+ {isInspecting + ? "Select an element · Esc to cancel" + : selectedRole + ? `${getThemeRoleLabel(selectedRole)} · ${usageCount ?? 0} ${usageCount === 1 ? "use" : "uses"}` + : "Select a color below"} +

+ )} +
+ + { + if (isInspecting) { + clearInspectorSelection(); + return; + } + setIsInspecting(true); + }} + > + + {isInspecting ? "Cancel" : "Inspect"} + + } + /> + + {isInspecting ? "Cancel and clear the selection" : "Pick a color from the app"} + + + + +
+ + {isMinimized ? null : ( + <> +
+ {renderNameField()} + {/* Inline and above the color list: the panel scrolls, and an + error parked below every role would go unseen. */} + {error ? ( +

+ {error} +

+ ) : null} + {renderAppearanceButtons()} +
+ {renderColorsHeader()} + {renderColorFields()} +
+
+
+ + +
+
+ + + +
+ + )} +
+ ); +} diff --git a/apps/web/src/components/settings/ThemeImportDialog.test.ts b/apps/web/src/components/settings/ThemeImportDialog.test.ts new file mode 100644 index 00000000000..6cd51e9b77a --- /dev/null +++ b/apps/web/src/components/settings/ThemeImportDialog.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { describeOversizedThemeFile, MAX_THEME_FILE_BYTES } from "./ThemeImportDialog"; + +describe("theme import size guard", () => { + it("accepts anything a theme file could plausibly be", () => { + for (const bytes of [0, 4_096, MAX_THEME_FILE_BYTES]) { + expect(describeOversizedThemeFile(bytes)).toBeNull(); + } + }); + + it("rejects a file too large to be a theme and names its size", () => { + const message = describeOversizedThemeFile(100 * 1024 * 1024); + expect(message).toContain("100.0 MB"); + expect(message).toContain("256 KB"); + }); + + it("reports sizes just past the limit in KB", () => { + expect(describeOversizedThemeFile(MAX_THEME_FILE_BYTES + 1)).toContain("256 KB"); + }); +}); diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx new file mode 100644 index 00000000000..a74842acac3 --- /dev/null +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -0,0 +1,537 @@ +import { PlusIcon, UploadIcon } from "lucide-react"; +import type { ChangeEvent, DragEvent, UIEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { cn } from "../../lib/utils"; +import { + getCustomThemes, + installCustomTheme, + parseThemeFile, + removeCustomTheme, + THEME_FILE_VERSION, + updateCustomTheme, + type ThemeDefinition, +} from "../../themePalette"; +import { + humanizeThemeName, + isVsCodeThemeFile, + pairVsCodeThemes, + parseVsCodeThemeFile, + resolveThemeLabelCollisions, +} from "../../vscodeThemeImport"; +import { Alert } from "../ui/alert"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; + +/** + * A full theme export is a few KB, so anything past this is not a theme file. + * The guard runs on the size before the bytes are ever read: a large file + * would otherwise be pulled into memory, highlighted, and rendered, which + * locks the UI for as long as that takes. + */ +export const MAX_THEME_FILE_BYTES = 256 * 1024; + +/** Highlighting rebuilds the whole markup on every keystroke, so oversized + * pastes fall back to plain text instead of freezing the editor. */ +const MAX_HIGHLIGHTED_JSON_LENGTH = 20_000; + +function formatByteSize(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`; + return `${bytes} bytes`; +} + +/** Returns the error to show for a file too large to be a theme, else null. */ +export function describeOversizedThemeFile(bytes: number): string | null { + if (bytes <= MAX_THEME_FILE_BYTES) return null; + return `That file is ${formatByteSize(bytes)}. Theme files are only a few KB, so this one was not read (limit ${formatByteSize(MAX_THEME_FILE_BYTES)}).`; +} + +function escapeJsonHtml(value: string): string { + return value.replace( + /[&<>"']/g, + (character) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character] ?? character, + ); +} + +function highlightJson(value: string): string { + const tokenPattern = + /"(?:\\.|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/g; + let highlighted = ""; + let cursor = 0; + + for (const match of value.matchAll(tokenPattern)) { + const token = match[0]; + const index = match.index ?? 0; + highlighted += escapeJsonHtml(value.slice(cursor, index)); + + let tokenClass = "theme-json-number"; + if (token.startsWith('"')) { + tokenClass = /^\s*:/.test(value.slice(index + token.length)) + ? "theme-json-key" + : "theme-json-string"; + } else if (token === "true" || token === "false" || token === "null") { + tokenClass = "theme-json-constant"; + } + highlighted += `${escapeJsonHtml(token)}`; + cursor = index + token.length; + } + + return highlighted + escapeJsonHtml(value.slice(cursor)); +} + +function ThemeJsonEditor({ + id, + value, + onChange, +}: { + id: string; + value: string; + onChange: (value: string) => void; +}) { + const highlightRef = useRef(null); + const isPlainText = value.length > MAX_HIGHLIGHTED_JSON_LENGTH; + const highlightedJson = useMemo( + () => (value.length > MAX_HIGHLIGHTED_JSON_LENGTH ? "" : highlightJson(value)), + [value], + ); + + const syncScroll = useCallback((event: UIEvent) => { + const highlightElement = highlightRef.current; + if (!highlightElement) return; + highlightElement.scrollTop = event.currentTarget.scrollTop; + highlightElement.scrollLeft = event.currentTarget.scrollLeft; + }, []); + + return ( +
+ {isPlainText ? null : ( +
+          
+        
+ )} +