diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 45524f6fcd33..0e11b50e1c83 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -40,7 +40,7 @@ Treat the overall testing or implementation loop—not an assistant turn or one - Do not stop the server merely because one verification pass completed or because you are yielding a response to the user. - Before starting another environment, check whether the existing process and browser tab still serve the task. Reuse them when healthy instead of discarding useful state. - On a later turn, verify that the existing process is alive and reuse its printed ports and base directory. If it exited, restart with the same base directory; create a new pairing token only when the browser session is no longer valid. -- Tell the user when a test environment remains available, including its non-secret web URL when useful. Never include a pairing token. +- Tell the user when a test environment remains available, including its non-secret web URL when useful. Include a pairing token only when the user still needs to pair (see below). ## Authenticate the browser on the first navigation @@ -50,24 +50,13 @@ Treat the overall testing or implementation loop—not an assistant turn or one 4. Wait for the pairing exchange and redirect to finish before navigating elsewhere. 5. Continue in the same browser context so its stored bearer session remains available. -Treat pairing URLs as secrets. Do not copy them into final responses, screenshots, committed files, or durable logs. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it. +Keep pairing URLs out of screenshots, committed files, and durable logs. When the user asked for a shared environment, the deliverable IS the full pairing URL — paste it in your reply, token and all; a bare origin is useless to them. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it, so never open a URL you handed to the user. ## Recover a consumed or expired pairing token -Create another token against the same database and web URL as the running dev server: +Run `node apps/server/src/bin.ts pair` from the repository root. It discovers the running dev server (worktree `.t3` first, same precedence as the dev runner) and prints a fresh `Pair URL` against the server's current web origin, including a `--share` tailnet origin. Pass `--base-dir ` only when the server was started with `--home-dir`, using the identical path. -```bash -T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ - --base-dir \ - --dev-url \ - --base-url \ - --ttl 15m \ - --label agent-ui-test -``` - -Use the `Pair URL` from this command once. Derive `` and `` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port. - -Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `/userdata`; the `/dev` fallback is only used by an implicit dev home. A worktree-local `.t3` counts as explicit, so its state lives in `/.t3/userdata`. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. +Tokens from `pair` carry standard client scopes. The startup pairing URL carries admin scopes; if the user needs Settings → Connections management (`access:write`), restart the server and hand over the new startup URL instead. ## Inspect or seed SQLite state diff --git a/AGENTS.md b/AGENTS.md index 70aa19765a68..ab1f81e95a11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,15 @@ Adapters are registered in `provider/Layers/ProviderAdapterRegistry.ts` and look Provider runtime activity is normalized into canonical `OrchestrationEvent`s by the ingestion layer, persisted in a SQLite event store with sequence-based ordering, and projected into in-memory materialized views. Clients receive ordered events via Effect RPC streams (replay + live merge). Command receipts provide idempotency for reconnects and retries. +## Local Development Notes + +- `vp i` installs. Worktrees get this from the `t3.json` setup script; if module resolution looks broken, it probably did not run. +- `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. +- Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. +- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, paste that full URL (token included) in your reply. Do not wire up `tailscale serve` by hand for this, and do not open the URL yourself. +- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. If the token got consumed, mint a fresh one with `node apps/server/src/bin.ts pair` — note it carries standard scopes, while the startup URL carries admin scopes (needed for Settings → Connections management). +- Stop what you started, by the PID you tracked. + ### Effect Architecture The server uses Effect throughout for dependency injection, typed errors, and streaming: diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index be633971bea8..f1add4c7cc7b 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -23,6 +23,21 @@ export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( + "ElectronDialogPickFilesError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + defaultPath: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; + const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath; + return `Failed to open the Electron file picker for ${owner} with ${defaultPath}.`; + } +} + export class ElectronDialogConfirmError extends Schema.TaggedErrorClass()( "ElectronDialogConfirmError", { @@ -69,6 +84,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass; } +export interface ElectronDialogPickFilesInput { + readonly owner: Option.Option; + readonly defaultPath: Option.Option; + readonly filters: readonly Electron.FileFilter[]; +} + export interface ElectronDialogConfirmInput { readonly owner: Option.Option; readonly message: string; @@ -92,6 +114,9 @@ export class ElectronDialog extends Context.Service< readonly pickFolder: ( input: ElectronDialogPickFolderInput, ) => Effect.Effect, ElectronDialogPickFolderError>; + readonly pickFiles: ( + input: ElectronDialogPickFilesInput, + ) => Effect.Effect; readonly confirm: ( input: ElectronDialogConfirmInput, ) => Effect.Effect; @@ -137,6 +162,32 @@ export const make = ElectronDialog.of({ } return Option.fromNullishOr(result.filePaths[0]); }), + pickFiles: Effect.fn("desktop.electron.dialog.pickFiles")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const defaultPath = Option.getOrNull(input.defaultPath); + const openDialogOptions: Electron.OpenDialogOptions = { + properties: ["openFile", "multiSelections"], + filters: [...input.filters], + ...(defaultPath === null ? {} : { defaultPath }), + }; + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), + }), + catch: (cause) => + new ElectronDialogPickFilesError({ + ownerWindowId, + defaultPath, + cause, + }), + }); + return result.canceled ? [] : result.filePaths; + }), confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { const normalizedMessage = input.message.trim(); if (normalizedMessage.length === 0) { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c37b4b9d604a..fc17d4d2f771 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -41,6 +41,7 @@ import { openLogDir, openExternal, pickFolder, + pickThemeFiles, readLogFile, setTheme, showContextMenu, @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setWslOnly); yield* ipc.handle(pickFolder); + yield* ipc.handle(pickThemeFiles); yield* ipc.handle(confirm); yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index a86361bf2e7a..28f88f91e2d9 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,4 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index ae973b81b590..ff3c77c9b226 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -3,12 +3,16 @@ import { DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopThemeSchema, + PickedThemeFileSchema, PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap, + type PickedThemeFile, } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; +import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -371,3 +375,49 @@ export const openLogDir = DesktopIpc.makeIpcMethod({ yield* shell.openPath(environment.logDir).pipe(Effect.ignore); }), }); + +/** Theme files are a few KB; anything larger returns empty text and lets the + * renderer reject it by size without the contents ever crossing the bridge. */ +const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; + +export const pickThemeFiles = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PICK_THEME_FILES_CHANNEL, + payload: Schema.Undefined, + result: Schema.NullOr(Schema.Array(PickedThemeFileSchema)), + handler: Effect.fn("desktop.ipc.window.pickThemeFiles")(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // The VS Code extensions directory is the same dotfolder on Windows, + // macOS, and Linux; when it is missing the picker opens wherever the + // platform would by default. + const extensionsDir = path.join(NodeOS.homedir(), ".vscode", "extensions"); + const defaultPath = yield* fileSystem + .exists(extensionsDir) + .pipe(Effect.orElseSucceed(() => false)); + const paths = yield* dialog.pickFiles({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(), + filters: [{ name: "JSON", extensions: ["json"] }], + }); + if (paths.length === 0) { + return null; + } + return yield* Effect.forEach(paths, (filePath) => { + const name = path.basename(filePath); + return Effect.gen(function* () { + const info = yield* fileSystem.stat(filePath); + const size = Number(info.size); + if (size > PICKED_THEME_FILE_MAX_BYTES) { + return { name, size, text: "" } satisfies PickedThemeFile; + } + const text = yield* fileSystem.readFileString(filePath); + return { name, size, text } satisfies PickedThemeFile; + }).pipe( + // An unreadable file degrades to an entry the renderer reports. + Effect.orElseSucceed((): PickedThemeFile => ({ name, size: 0, text: "" })), + ); + }); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b951737a3e75..8d9c720d6a01 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -97,6 +97,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), + pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 53ef74f21911..c1cb8588b5ea 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,7 +13,6 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { - autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], @@ -30,6 +29,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + planModeEnabled: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 136cf8204176..22a24b908b62 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -52,6 +52,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), + pickFiles: () => Effect.succeed([]), confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 111482208cfa..5557f5fb6b19 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 && !shouldUsePlanSidebarSheet ? ( + {rightPanelOpen && !shouldUseRightPanelSheet ? ( - ) : activeRightPanelSurface?.kind === "plan" ? ( - ) : activeRightPanelSurface?.kind === "agents" ? ( - {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} + {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
- {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( + {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( ) : null} - {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( - + {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( + 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; @@ -440,7 +454,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { // xterm, a contenteditable composer) would otherwise swallow the event. window.addEventListener("keydown", onKeyDown, true); return () => window.removeEventListener("keydown", onKeyDown, true); - }, [keybindings, previewOpen, terminalOpen, toggleMode]); + }, [keybindings, previewOpen, resolvedTheme, terminalOpen, theme, themeHalves, toggleMode]); useEffect( () => @@ -569,6 +583,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; @@ -1465,6 +1480,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 67b82388bbcb..0489e8c79cdf 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/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx index 0c22f1702e5e..2efddcfa736d 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/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index a10cdafd7835..76191e6d4d76 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/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx deleted file mode 100644 index abc0db79b6cb..000000000000 --- a/apps/web/src/components/PlanSidebar.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { memo, useState, useCallback } from "react"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; -import { type TimestampFormat } from "@t3tools/contracts/settings"; -import { Badge } from "./ui/badge"; -import { Button } from "./ui/button"; -import { ScrollArea } from "./ui/scroll-area"; -import ChatMarkdown from "./ChatMarkdown"; -import { - CheckIcon, - ChevronDownIcon, - ChevronRightIcon, - EllipsisIcon, - LoaderIcon, -} from "lucide-react"; -import { cn } from "~/lib/utils"; -import type { ActivePlanState } from "../session-logic"; -import type { LatestProposedPlanState } from "../session-logic"; -import { formatTimestamp } from "../timestampFormat"; -import { - proposedPlanTitle, - buildProposedPlanMarkdownFilename, - normalizePlanMarkdownForExport, - downloadPlanAsTextFile, - stripDisplayedPlanMarkdown, -} from "../proposedPlan"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { projectEnvironment } from "~/state/projects"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useAtomCommand } from "~/state/use-atom-command"; - -function stepStatusIcon(status: string): React.ReactNode { - if (status === "completed") { - return ( - - - - ); - } - if (status === "inProgress") { - return ( - - - - ); - } - return ( - - - - ); -} - -interface PlanSidebarProps { - activePlan: ActivePlanState | null; - activeProposedPlan: LatestProposedPlanState | null; - label?: string; - environmentId: EnvironmentId; - threadRef?: ScopedThreadRef | undefined; - markdownCwd: string | undefined; - workspaceRoot: string | undefined; - timestampFormat: TimestampFormat; - mode?: "sheet" | "sidebar" | "embedded"; -} - -const PlanSidebar = memo(function PlanSidebar({ - activePlan, - activeProposedPlan, - label = "Plan", - environmentId, - threadRef, - markdownCwd, - workspaceRoot, - timestampFormat, - mode = "sidebar", -}: PlanSidebarProps) { - const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); - const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); - const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { - reportFailure: false, - }); - const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); - - const planMarkdown = activeProposedPlan?.planMarkdown ?? null; - const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; - const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; - - const handleCopyPlan = useCallback(() => { - if (!planMarkdown) return; - copyToClipboard(planMarkdown); - }, [planMarkdown, copyToClipboard]); - - const handleDownload = useCallback(() => { - if (!planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); - }, [planMarkdown]); - - const handleSaveToWorkspace = useCallback(() => { - if (!workspaceRoot || !planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - setIsSavingToWorkspace(true); - void (async () => { - const result = await writeProjectFile({ - environmentId, - input: { - cwd: workspaceRoot, - relativePath: filename, - contents: normalizePlanMarkdownForExport(planMarkdown), - }, - }); - setIsSavingToWorkspace(false); - if (result._tag === "Success") { - toastManager.add({ - type: "success", - title: "Plan saved", - description: result.value.relativePath, - }); - return; - } - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not save plan", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]); - - return ( -
- {/* Header */} -
-
- - {label} - - {activePlan ? ( - - {formatTimestamp(activePlan.createdAt, timestampFormat)} - - ) : null} -
-
- {planMarkdown ? ( - - - } - > - - - - - {isCopied ? "Copied!" : "Copy to clipboard"} - - Download as markdown - - Save to workspace - - - - ) : null} -
-
- - {/* Content */} - -
- {/* Explanation */} - {activePlan?.explanation ? ( -

- {activePlan.explanation} -

- ) : null} - - {/* Plan Steps */} - {activePlan && activePlan.steps.length > 0 ? ( -
-

- Steps -

- {activePlan.steps.map((step) => ( -
- {stepStatusIcon(step.status)} -

- {step.step} -

-
- ))} -
- ) : null} - - {/* Proposed Plan Markdown */} - {planMarkdown ? ( -
- - {proposedPlanExpanded ? ( -
- -
- ) : null} -
- ) : null} - - {/* Empty state */} - {!activePlan && !planMarkdown ? ( -
-

No active plan yet.

-

- Plans will appear here when generated. -

-
- ) : null} -
-
-
- ); -}); - -export default PlanSidebar; -export type { PlanSidebarProps }; diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 1df19a640756..66216e10cb58 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 ce6cf9f03963..9ea6b20554b7 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -347,6 +347,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)} /> } @@ -451,6 +454,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/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b0f18f6e1266..b9345ab8c3c5 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,6 @@ import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { Bot, ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { Bot, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, @@ -213,8 +213,6 @@ function surfaceTitle( terminalLabelsById.get(surface.activeTerminalId) ?? getTerminalLabel(surface.activeTerminalId) ); - case "plan": - return "Plan"; case "agents": return "Agents"; case "preview": { @@ -276,8 +274,6 @@ function SurfaceIcon({ ); case "terminal": return ; - case "plan": - return ; case "agents": return ; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cffab8bd5778..232ea0998ef7 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 114fd5f9241e..c34eec58316d 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 9fb448e940de..ee669e94bd47 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 0725cbaaad4d..99a9c2423603 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -113,6 +113,7 @@ import { import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; +import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, @@ -475,19 +476,40 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const terminalProcessCount = runningTerminalIds.length; + const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useEnvironmentQuery( + (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + }); + const prState = pr?.state ?? null; + // Same semantics as v1 (never-visited counts as read): flipping the beta // flag must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarV2Status(thread); // A woken thread reappears at its original position (the sort is // deliberately static), so the pill has to carry the weight. Snoozing is - // an explicit act, so unlike Done, a never-visited woke thread still - // shows the pill; visiting clears it. An unparseable visit timestamp - // counts as never-visited — corrupt local data must not eat the wake - // signal. + // an explicit act, so the pill clears only when the user re-engages: + // reading a completion-triggered wake, clicking the pill, sending a + // message, settling, archiving — or finishing the work outright (merged + // or closed PR). Timer wakes survive a mere visit. An unparseable visit + // timestamp counts as never-visited — corrupt local data must not eat + // the wake signal. const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); - const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate); + const isWoke = + wokeAtDate !== null && + (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && + prState !== "merged" && + prState !== "closed"; // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, failed, and @@ -507,13 +529,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? { label: "Working", icon: "working" as const, - className: - "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400", + // No shimmer: a label that animates forever is noise in a sidebar + // full of them (and repaints every vsync on high-refresh displays). + // Working is a background state, so it rests at the dim end of what + // the old pulse cycled through; only the thread you have open gets + // the label at full strength. + className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"), } : status === "monitoring" ? { - // Steady label, no duty-cycled shimmer: monitoring is calm - // background presence, not active progress (monitoring-pill D6). + // Monitoring is calm background presence, not active progress + // (monitoring-pill D6), so it keeps the label at full strength. label: "Monitoring", icon: null, className: "text-sky-600 dark:text-sky-400", @@ -551,30 +577,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { : null; const isWokeStatus = topStatus?.icon === "woke"; - const gitCwd = thread.worktreePath ?? props.projectCwd; - const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", activeWorktreePath: thread.worktreePath, activeThreadBranch: thread.branch, currentGitBranch: gitStatus.data?.refName ?? null, }); - const pr = resolveThreadPr({ - threadBranch: thread.branch, - gitStatus: gitStatus.data, - }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; // Report the PR state up: the parent partitions rows with effectiveSettled, // and a merged/closed PR auto-settles a thread — data only rows have. - const prState = pr?.state ?? null; useEffect(() => { onChangeRequestState(threadKey, prState); }, [onChangeRequestState, prState, threadKey]); @@ -769,7 +781,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", @@ -780,7 +792,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]", )} @@ -800,8 +812,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} @@ -872,7 +884,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { @@ -984,7 +996,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {props.projectTitle ? ( @@ -1013,7 +1025,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", )} > @@ -1102,8 +1114,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null}
-
- {thread.branch ? ( +
+ {/* 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} + + + ) : thread.branch ? ( {thread.branch} ) : ( @@ -2565,68 +2588,24 @@ export default function SidebarV2() { const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); const clicked = await settlePromise(() => api.contextMenu.show( - [ - ...(thread.branch - ? [ - { - id: "new-thread-on-branch", - label: `New thread on ${thread.branch}`, - }, - ] - : []), - ...(supportsPinning - ? [ - isPinned - ? { id: "unpin", label: "Unpin thread" } - : { id: "pin", label: "Pin thread" }, - ] - : []), - // Both lifecycle actions stay available on pinned threads: - // settling clears the pin ("done" beats "keep on top"), and - // snoozing hides the card until wake with the pin intact. - ...(supportsSettlement - ? [ - isSettled - ? { id: "unsettle", label: "Un-settle thread" } - : { id: "settle", label: "Settle thread" }, - ] - : []), - ...(supportsSnooze - ? [ - isSnoozed - ? { id: "unsnooze", label: "Wake thread" } - : { - id: "snooze", - label: "Snooze", - disabled: !canSnooze(thread, { now: new Date().toISOString() }), - children: snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}`, - label: `${preset.label} (${preset.whenLabel})`, - })), - }, - ] - : []), - { id: "rename", label: "Rename thread" }, - ...(supportsTitleRegeneration - ? [ - { - id: "regenerate-title", - label: isRegeneratingTitle - ? "Regenerating…" - : lastTitleRegenerationError - ? `Retry regenerate title — ${summarizeTitleRegenerationError( - lastTitleRegenerationError, - )}` - : "Regenerate title", - disabled: isRegeneratingTitle, - }, - ] - : []), - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-path", label: "Copy path", icon: "copy" }, - ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []), - { id: "delete", label: "Delete", destructive: true, icon: "trash" }, - ], + buildThreadActionMenuItems({ + branch: thread.branch ?? null, + isPinned, + isSettled, + isSnoozed, + canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), + isRegeneratingTitle, + titleRegenerationFailureLabel: lastTitleRegenerationError + ? summarizeTitleRegenerationError(lastTitleRegenerationError) + : null, + supports: { + settlement: supportsSettlement, + snooze: supportsSnooze, + pinning: supportsPinning, + titleRegeneration: supportsTitleRegeneration, + }, + snoozePresets, + }), position, ), ); @@ -2873,7 +2852,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. +
@@ -3008,7 +2989,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 072241426e25..25b4abb3fbe0 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 3a705eef36d4..0955bd3abcb4 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: { } >
void; onRuntimeModeChange: (mode: RuntimeMode) => void; - onTogglePlanSidebar: () => void; }) { const runtimeModeConfig = getRuntimeModeConfig(props.provider); const runtimeModeOptions = getRuntimeModeOptions(props.provider); @@ -279,9 +274,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop props.interactionMode === "plan" ? "Plan mode — click to return to normal build mode" : "Default mode — click to enter plan mode"; - const planSidebarTooltip = props.planSidebarOpen - ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` - : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`; const interactionModeToggle = props.showInteractionModeToggle ? ( <> @@ -293,8 +285,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop className={cn( "shrink-0 whitespace-nowrap", props.interactionMode === "plan" - ? "bg-blue-500/10 text-blue-400 hover:bg-blue-500/15 hover:text-blue-300" - : "text-muted-foreground/70 hover:text-foreground/80", + ? "bg-accent text-accent-foreground hover:bg-accent/80" + : "text-secondary-label hover:text-foreground", )} type="button" onClick={props.onToggleInteractionMode} @@ -357,36 +349,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {interactionModeToggle} - - {props.showPlanToggle ? ( - <> - - - - } - > - - {props.planSidebarLabel} - - {planSidebarTooltip} - - - ) : null} ); }); @@ -425,7 +387,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( /> ) : null} {props.isPreparingWorktree ? ( - Preparing worktree... + Preparing worktree... ) : null} void; handleRuntimeModeChange: (mode: RuntimeMode) => void; handleInteractionModeChange: (mode: ProviderInteractionMode) => void; - togglePlanSidebar: () => void; focusComposer: () => void; scheduleComposerFocus: () => void; @@ -641,10 +598,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) respondingRequestIds, showPlanFollowUpPrompt, activeProposedPlan, - activePlan, - sidebarProposedPlan, - planSidebarLabel, - planSidebarOpen, runtimeMode, interactionMode, lockedProvider, @@ -675,7 +628,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toggleInteractionMode, handleRuntimeModeChange, handleInteractionModeChange, - togglePlanSidebar, focusComposer, scheduleComposerFocus, setThreadError, @@ -890,14 +842,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; + // Plan mode is a legacy feature behind Settings → Beta. With the flag off, + // ChatView forces the effective mode to "default", so hiding the toggle + // can't trap anyone in plan mode. + const planModeUiEnabled = settings.planModeEnabled; const composerProviderControls = useMemo( () => ({ - showInteractionModeToggle: getProviderInteractionModeToggle( - providerStatuses, - selectedProvider, - ), + showInteractionModeToggle: + planModeUiEnabled && getProviderInteractionModeToggle(providerStatuses, selectedProvider), }), - [providerStatuses, selectedProvider], + [planModeUiEnabled, providerStatuses, selectedProvider], ); const selectedModelSelection = useMemo( () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), @@ -1058,20 +1012,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) label: "/model", description: "Switch response model for this thread", }, - { - id: "slash:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch this thread into plan mode", - }, - { - id: "slash:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch this thread back to normal build mode", - }, + ...(planModeUiEnabled + ? ([ + { + id: "slash:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + ] as const) + : []), ] satisfies ReadonlyArray>; const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( (command) => ({ @@ -1106,7 +1064,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerTrigger, + planModeUiEnabled, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1146,7 +1110,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; - const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); const composerFooterActionLayoutKey = useMemo(() => { if (activePendingProgress) { return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; @@ -1873,6 +1836,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) event: KeyboardEvent, ) => { if (key === "Tab" && event.shiftKey) { + if (!planModeUiEnabled) return false; toggleInteractionMode(); return true; } @@ -2770,9 +2734,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) type="button" className={cn( "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm", - activePendingProgress?.customAnswer - ? "text-foreground" - : "text-muted-foreground/60", + activePendingProgress?.customAnswer ? "text-foreground" : "text-placeholder", !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2", )} onPointerDown={(event) => event.preventDefault()} @@ -2817,7 +2779,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} @@ -2831,7 +2793,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : ( -
+
{image.name}
)} @@ -3119,7 +3081,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 @@ -3154,15 +3116,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {isComposerFooterCompact ? ( ) : ( @@ -3178,12 +3136,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} interactionMode={interactionMode} runtimeMode={runtimeMode} - showPlanToggle={showPlanSidebarToggle} - planSidebarLabel={planSidebarLabel} - planSidebarOpen={planSidebarOpen} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} - onTogglePlanSidebar={togglePlanSidebar} /> )} diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index d716092fc3eb..94fe070ee3dc 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { shouldShowOpenInPicker } from "./ChatHeader"; +import { resolveRenameCommit, shouldShowOpenInPicker } from "./ChatHeader"; describe("shouldShowOpenInPicker", () => { const primaryEnvironmentId = EnvironmentId.make("environment-primary"); @@ -46,3 +46,24 @@ describe("shouldShowOpenInPicker", () => { ).toBe(false); }); }); + +describe("resolveRenameCommit", () => { + it("commits a trimmed changed title", () => { + expect(resolveRenameCommit({ title: " New title ", originalTitle: "Old" })).toEqual({ + action: "commit", + title: "New title", + }); + }); + + it("rejects empty and whitespace-only titles", () => { + expect(resolveRenameCommit({ title: " ", originalTitle: "Old" })).toEqual({ + action: "reject-empty", + }); + }); + + it("no-ops when the trimmed title is unchanged", () => { + expect(resolveRenameCommit({ title: " Old ", originalTitle: "Old" })).toEqual({ + action: "noop", + }); + }); +}); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 0adeed6ffa61..68e1743bccb6 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -6,10 +6,25 @@ import { type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { memo } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled"; +import { ChevronDownIcon } from "lucide-react"; +import { + memo, + useCallback, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, @@ -17,6 +32,9 @@ import ProjectScriptsControl, { import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; +import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import { ProjectFavicon } from "../ProjectFavicon"; import { cn } from "~/lib/utils"; @@ -25,6 +43,10 @@ interface ChatHeaderProps { activeThreadId: ThreadId; draftId?: DraftId; activeThreadTitle: string; + /** Drafts have no server thread yet, so the title carries no action menu. */ + isServerThread: boolean; + /** PR state feeding the settled classification, resolved by ChatView. */ + changeRequestState: ChangeRequestStateLike | null; activeProjectName: string | undefined; activeProjectCwd: string | null; openInCwd: string | null; @@ -44,6 +66,20 @@ interface ChatHeaderProps { onDeleteProjectScript: (scriptId: string) => Promise; } +/** + * Rename commit rule shared with the sidebar's inline rename: trim, reject + * empty (the caller toasts), and skip the mutation when nothing changed. + */ +export function resolveRenameCommit(input: { + readonly title: string; + readonly originalTitle: string; +}): { action: "commit"; title: string } | { action: "reject-empty" } | { action: "noop" } { + const trimmed = input.title.trim(); + if (trimmed.length === 0) return { action: "reject-empty" }; + if (trimmed === input.originalTitle) return { action: "noop" }; + return { action: "commit", title: trimmed }; +} + export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; @@ -61,6 +97,8 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadId, draftId, activeThreadTitle, + isServerThread, + changeRequestState, activeProjectName, activeProjectCwd, openInCwd, @@ -86,8 +124,91 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, primaryEnvironmentId, }); + const activeThreadRef = useMemo( + () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), + [activeThreadEnvironmentId, activeThreadId], + ); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + // Inline rename, keyed by thread: navigating away drops an in-progress + // rename instead of committing stale text. Cleared on thread change (not + // just hidden) so returning to the thread doesn't revive the old draft. + const [renaming, setRenaming] = useState<{ threadId: ThreadId; title: string } | null>(null); + if (renaming !== null && renaming.threadId !== activeThreadId) { + setRenaming(null); + } + const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null; + const renameCommittedRef = useRef(false); + const startRename = useCallback(() => { + renameCommittedRef.current = false; + setRenaming({ threadId: activeThreadId, title: activeThreadTitle }); + }, [activeThreadId, activeThreadTitle]); + const commitRename = useCallback( + (title: string) => { + setRenaming(null); + const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); + if (resolution.action === "reject-empty") { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (resolution.action === "noop") return; + void updateThreadMetadata({ + environmentId: activeThreadEnvironmentId, + input: { threadId: activeThreadId, title: resolution.title }, + }).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }); + }, + [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], + ); + const { openMenu } = useThreadActionMenu({ + threadRef: isServerThread ? activeThreadRef : null, + projectCwd: activeProjectCwd, + changeRequestState, + onStartRename: startRename, + }); + const titleButtonRef = useRef(null); + const openMenuFromTitle = useCallback(() => { + const rect = titleButtonRef.current?.getBoundingClientRect(); + if (!rect) return; + openMenu({ x: rect.left, y: rect.bottom + 4 }); + }, [openMenu]); + const handleHeaderContextMenu = useCallback( + (event: ReactMouseEvent) => { + if (!isServerThread || renamingTitle !== null) return; + // The right-side controls (git, scripts, open-in) keep their own + // behavior; only the breadcrumb area opens the thread menu. + if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) return; + event.preventDefault(); + openMenu({ x: event.clientX, y: event.clientY }); + }, + [isServerThread, openMenu, renamingTitle], + ); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "Enter") { + renameCommittedRef.current = true; + commitRename(event.currentTarget.value); + } else if (event.key === "Escape") { + renameCommittedRef.current = true; + setRenaming(null); + } + }, + [commitRename], + ); return ( -
+
{/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone @@ -114,24 +235,63 @@ export const ChatHeader = memo(function ChatHeader({ New thread in {activeProjectName} - + / ) : null} - - + {renamingTitle !== null ? ( + { + if (renameCommittedRef.current) return; + commitRename(event.currentTarget.value); + }} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + /> + ) : isServerThread ? ( + + + } + > +

{activeThreadTitle}

- } - /> - {activeThreadTitle} -
+ +
+ {activeThreadTitle} +
+ ) : ( + + + {activeThreadTitle} + + } + /> + {activeThreadTitle} + + )}
void; - onTogglePlanSidebar: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const runtimeModeConfig = getRuntimeModeConfig(props.provider); @@ -80,17 +75,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls ))} - {props.activePlan ? ( - <> - - - - {props.planSidebarOpen - ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` - : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`} - - - ) : null} ); diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index 1b592168c20d..6eed4fb05315 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -3,9 +3,12 @@ import { describe, expect, it } from "vite-plus/test"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; -const banner = (id: string): ComposerBannerStackItem => ({ +const banner = ( + id: string, + variant: ComposerBannerStackItem["variant"] = "warning", +): ComposerBannerStackItem => ({ id, - variant: "warning", + variant, icon: , title: `${id} warning`, }); @@ -29,6 +32,19 @@ describe("ComposerBannerStack", () => { expect(markup).toContain("group-focus-within/banner-stack:visible"); }); + it("colors the collapsed stack cap by the hidden banner's variant, not a fixed warning", () => { + const neutralBehind = renderToStaticMarkup( + , + ); + expect(neutralBehind).toContain("border-border"); + expect(neutralBehind).not.toContain("border-warning/24"); + + const warningBehind = renderToStaticMarkup( + , + ); + expect(warningBehind).toContain("border-warning/24"); + }); + it("does not render an expandable region for a single banner", () => { const markup = renderToStaticMarkup(); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 548bd0f4262e..41a717d07ce4 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -22,9 +22,23 @@ const exitTransitionStyle = { transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`, } satisfies CSSProperties; +// The collapsed cap peeking above the front banner is the only hint that more +// banners are stacked behind it, so its border must match the severity of the +// first hidden banner — a neutral banner must not masquerade as a warning. +const stackCapBorderClass: Record = { + default: "border-border", + error: "border-destructive/24", + info: "border-info/24", + success: "border-success/24", + warning: "border-warning/24", +}; + export interface ComposerBannerStackItem { readonly id: string; readonly variant: "default" | "error" | "info" | "success" | "warning"; + // Ordering hint for stack assemblers: front this banner even though its + // variant is calm (e.g. live update progress). The stack itself ignores it. + readonly urgent?: boolean; readonly icon: ReactNode; readonly title: ReactNode; readonly description?: ReactNode; @@ -67,6 +81,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro const stackedItems = items.slice(1); const hasStack = stackedItems.length > 0; const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; + const firstStackedItem = stackedItems[0]; const requestDismiss = (item: ComposerBannerStackItem) => { if (!item.onDismiss || exitingItemId) { @@ -90,11 +105,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null, )} > - {showCollapsedStackCap ? ( + {showCollapsedStackCap && firstStackedItem ? (
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 8eab75171c82..a7ba40581457 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 602ad114464a..5e9e43dcf21e 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}
)} @@ -1159,16 +1183,117 @@ function ProposedPlanTimelineRow({ ); } +/** + * Inline folded plan chip: one row per turn that produced plan/todo steps. + * Collapsed by default — a segment bar plus the in-progress step label — + * and expands in place to the full step list. Replaces the old plan sidebar. + */ +const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ + row, +}: { + row: Extract; +}) { + const [expanded, setExpanded] = useState(false); + const { steps } = row.turnPlan.plan; + const completedCount = steps.filter((step) => step.status === "completed").length; + const allDone = completedCount === steps.length; + // Label priority: the in-progress step, else the next pending step (plan + // just created), else the last step (plan finished, rendered muted). + const label = + steps.find((step) => step.status === "inProgress")?.step ?? + steps.find((step) => step.status === "pending")?.step ?? + steps.at(-1)?.step ?? + "Plan"; + const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon; + + return ( +
+ + {expanded ? ( +
+ {steps.map((step) => ( +
+ + {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"} + + + {step.step} + +
+ ))} +
+ ) : null} +
+ ); +}); + function WorkingTimelineRow({ row }: { row: Extract }) { + const { workingStepLabel } = use(TimelineRowActivityCtx); return (
-
+
- + {row.createdAt ? ( <> Working for @@ -1177,6 +1302,9 @@ function WorkingTimelineRow({ row }: { row: Extract + {workingStepLabel ? ( + · {workingStepLabel} + ) : null}
); @@ -1238,9 +1366,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ return (
{!onlyToolEntries && ( -

- {groupLabel} -

+

{groupLabel}

)}
{nonEmptyEntries.map((workEntry) => ( @@ -1274,13 +1400,9 @@ function WorkGroupToggleTimelineRow({ type="button" className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" aria-expanded={row.expanded} - onClick={(event) => { - const anchorElement = - event.currentTarget.closest("[data-timeline-row-id]") ?? event.currentTarget; - ctx.onToggleWorkGroup(row.groupId, anchorElement); - }} + onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} > - + {row.expanded ? ( - + Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"} ) : ( - + +{row.hiddenCount} previous {labelNoun} )} @@ -1398,7 +1520,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi + {props.context.header} @@ -1438,13 +1560,13 @@ function UserMessagePreviewAnnotationCard(props: { ) : null}
{props.annotation.comment ? ( -
+
{props.annotation.comment}
) : null}
@@ -1533,7 +1655,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"} @@ -1572,7 +1694,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} @@ -1584,7 +1706,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 ? ( @@ -1594,7 +1716,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 />
@@ -1653,7 +1775,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1682,7 +1804,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 />, ); @@ -1691,7 +1813,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1707,7 +1829,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 /> ); @@ -1724,10 +1846,10 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte return (
-
+
{formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
-
+
{comment.sectionTitle} · {comment.rangeLabel}
@@ -1742,7 +1864,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" && @@ -1867,24 +1989,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", }; } @@ -2133,14 +2255,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 = @@ -2182,11 +2304,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 a74a4ebf8c26..70475ffd0380 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 24ec66cd6142..82ee33615b06 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 17dfa8362af6..df41adb7dd53 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 a736cf96cd3e..090acdb9c02c 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/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx new file mode 100644 index 000000000000..3c502c624ddd --- /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 a6da37c15519..158908b5e942 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. settings.sidebarAutoSettleAfterDays, ); + const planModeEnabled = useClientSettings((settings) => settings.planModeEnabled); const updateSettings = useUpdateClientSettings(); return ( @@ -114,6 +115,17 @@ export function BetaSettingsPanel() { ) : null} ) : null} + updateSettings({ planModeEnabled: Boolean(checked) })} + aria-label="Restore plan mode (legacy)" + /> + } + /> ); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 75235a053076..22a91b7c1504 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 2a691943df4e..17b1ebdf33d0 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/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx new file mode 100644 index 000000000000..2b304378ae91 --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -0,0 +1,256 @@ +import type { ReactElement } from "react"; +import { + DEFAULT_UNIFIED_SETTINGS, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type UnifiedSettings, +} from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { visitElements } from "../../test/reactElementTree"; +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +const atoms = vi.hoisted(() => ({ + 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 000000000000..bf558f5a4d66 --- /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 000000000000..1c7dac391f6a --- /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 000000000000..c06826d450d4 --- /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/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 9541a0f07e00..05ea2c9f04e3 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.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 1d4baefa53a5..efb5e12ff33d 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 9a5fe3195685..5b9347f03075 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 { @@ -72,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"; @@ -88,15 +74,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 +112,14 @@ 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 { ThemeLibrary } from "./ThemeSettings"; import { + backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, - buildProviderInstanceUpdatePatch, + durationToSeconds, formatDiagnosticsDescription, + normalizeIntervalSeconds, + PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, @@ -153,31 +128,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 = [ - { - value: "system", - label: "System", - }, - { - value: "light", - label: "Light", - }, - { - value: "dark", - label: "Dark", - }, -] as const; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", @@ -198,11 +157,6 @@ const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record; const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record = { ...BACKGROUND_ACTIVITY_PROFILE_LABELS, @@ -219,7 +173,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 ( @@ -581,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(); @@ -594,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 @@ -623,9 +476,6 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), - ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar - ? ["Auto-open task panel"] - : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -655,7 +505,6 @@ export function useSettingsRestore(onRestored?: () => void) { [ isTextGenerationModelDirty, isBackgroundActivityDirty, - settings.autoOpenPlanSidebar, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -678,6 +527,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, + followSystem, theme, ], ); @@ -692,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, @@ -701,7 +601,6 @@ export function useSettingsRestore(onRestored?: () => void) { glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, @@ -720,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, @@ -995,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(); @@ -1011,38 +931,21 @@ export function AppearanceSettingsPanel() { return ( - setTheme("system")} /> - ) : null - } - control={ - - } - /> +
+ +
- - updateSettings({ - autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, - }) - } - /> - ) : null - } - control={ - - updateSettings({ autoOpenPlanSidebar: Boolean(checked) }) - } - aria-label="Open the task panel automatically" - /> - } - /> - - >(() => 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/ThemeColorPicker.tsx b/apps/web/src/components/settings/ThemeColorPicker.tsx new file mode 100644 index 000000000000..38a72ff5c870 --- /dev/null +++ b/apps/web/src/components/settings/ThemeColorPicker.tsx @@ -0,0 +1,550 @@ +import type { KeyboardEvent, PointerEvent } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { isThemeColor, type ThemeColorRole } from "../../themePalette"; +import { cn } from "../../lib/utils"; +import { Input } from "../ui/input"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +export function getThemeRoleLabel(role: ThemeColorRole): string { + const labels: Partial> = { + 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 000000000000..faf2d770e90d --- /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 000000000000..0074ac89304e --- /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 000000000000..6cd51e9b77ae --- /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 000000000000..a74842acac3e --- /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 : ( +
+          
+        
+ )} +