From a23316d21758f45f2bc92dc572320c2672b16691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 2 Sep 2026 00:10:25 +0200 Subject: [PATCH 01/15] feat(ui): complete surface and palette harmonization Harmonize application surfaces, message chrome, project tabs, and right-panel tabs around the persisted theme palette. Active tabs now use neutral palette borders, project overflow scrolls from the top, and one-pixel seams and vertical overflow are removed. Extend embedded color schemes with editable overrides, named preset creation, reset and deletion flows, and light/dark selection persistence. Keep preference-window lifecycle handling aligned across Electron and Tauri and localize the new settings actions. Cover preset normalization, embedded overrides, and theme application regressions. Validated with the UI test suite, UI typecheck and production build, runtime palette flows, visual desktop checks, and git diff validation. --- packages/electron-app/electron/main/main.ts | 7 +- .../electron/main/preferences-ipc.test.ts | 8 +- .../electron/main/preferences-ipc.ts | 7 +- .../electron-app/electron/preload/index.cjs | 2 +- .../src-tauri/src/preferences_window.rs | 7 +- packages/ui/src/components/instance-tabs.tsx | 4 +- .../components/instance/instance-shell2.tsx | 26 +- .../instance/shell/right-panel/RightPanel.tsx | 1 + .../shell/right-panel/tabs/StatusTab.tsx | 4 +- .../ui/src/components/message-section.tsx | 131 ++------ .../settings/advanced-settings-section.tsx | 17 +- .../settings/general-settings-section.tsx | 17 +- .../settings/theme-scheme-settings.tsx | 281 +++++++++++++----- .../ui/src/lib/color-scheme-presets.test.ts | 9 +- packages/ui/src/lib/color-scheme-presets.ts | 16 +- .../ui/src/lib/i18n/messages/de/settings.ts | 19 +- .../ui/src/lib/i18n/messages/en/settings.ts | 19 +- .../ui/src/lib/i18n/messages/es/settings.ts | 19 +- .../ui/src/lib/i18n/messages/fr/settings.ts | 19 +- .../ui/src/lib/i18n/messages/he/settings.ts | 19 +- .../ui/src/lib/i18n/messages/ja/settings.ts | 19 +- .../ui/src/lib/i18n/messages/ne/settings.ts | 19 +- .../ui/src/lib/i18n/messages/ru/settings.ts | 17 +- .../ui/src/lib/i18n/messages/tr/settings.ts | 19 +- .../src/lib/i18n/messages/zh-Hans/settings.ts | 19 +- .../ui/src/lib/native/preferences-window.ts | 6 +- packages/ui/src/lib/theme-scheme.test.ts | 43 ++- packages/ui/src/lib/theme-scheme.ts | 171 ++++++++++- packages/ui/src/stores/preferences.tsx | 73 ++++- packages/ui/src/stores/settings-screen.ts | 11 +- .../src/styles/components/native-titlebar.css | 4 +- .../components/theme-scheme-settings.css | 115 +++---- .../src/styles/messaging/delete-overlays.css | 8 +- .../ui/src/styles/messaging/message-base.css | 2 +- .../src/styles/messaging/message-section.css | 29 +- .../src/styles/messaging/technical-groups.css | 2 +- .../ui/src/styles/messaging/tool-call.css | 2 +- .../styles/messaging/virtual-follow-list.css | 2 +- packages/ui/src/styles/panels/panel-shell.css | 2 +- packages/ui/src/styles/panels/right-panel.css | 91 ++++-- .../ui/src/styles/panels/session-layout.css | 6 +- packages/ui/src/styles/panels/tabs.css | 73 ++++- packages/ui/src/styles/tokens.css | 31 +- packages/ui/src/types/global.d.ts | 2 +- 44 files changed, 931 insertions(+), 467 deletions(-) diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 278d199fa..3bcfc0cac 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -423,11 +423,12 @@ function runPrimary(firstIntent: LaunchIntent) { }) } - async function openPreferences(request: PreferencesRequest): Promise { - if (preferencesWindows.reuse(request)) { - await clientState.setPreferences(request) + async function openPreferences(request: PreferencesRequest, toggle = false): Promise { + if (toggle && preferencesWindows.current()) { + preferencesWindows.current()?.close() return } + if (preferencesWindows.reuse(request)) return if (!backendTargetUrl) throw new Error("Local CodeNomad server is unavailable") const window = new BrowserWindow({ width: 1100, height: 760, minWidth: 760, minHeight: 560, diff --git a/packages/electron-app/electron/main/preferences-ipc.test.ts b/packages/electron-app/electron/main/preferences-ipc.test.ts index 4bd6b9a93..77426c7d9 100644 --- a/packages/electron-app/electron/main/preferences-ipc.test.ts +++ b/packages/electron-app/electron/main/preferences-ipc.test.ts @@ -30,7 +30,7 @@ function harness() { resolveLocal: (sender) => sender === localContents ? { window: localWindow } : undefined, resolvePreferences: (sender) => sender === preferencesContents ? preferencesWindow : undefined, getAllowedOrigins: () => ["http://localhost:3000"], - openPreferences: async (request) => { calls.push(`open:${request.section}:${request.instanceId ?? ""}`) }, + openPreferences: async (request, toggle) => { calls.push(`open:${request.section}:${request.instanceId ?? ""}:${Boolean(toggle)}`) }, getRequest: () => ({ section: "speech" }), markReady: () => { calls.push("ready") }, acceptRequest: (_window, request) => { calls.push(`accept:${request.section}`) }, @@ -48,7 +48,7 @@ test("Preferences IPC separates local open authority and controls registered app "preferences:open", "preferences:getSection", "preferences:ready", "preferences:acceptRequest", "preferences:resolveTransition", "preferences:minimize", "preferences:toggleMaximize", "preferences:close", ]) - assert.deepEqual(await h.handlers.get("preferences:open")!(h.event(h.localContents), "speech", { instanceId: "workspace-1" }), { ok: true }) + assert.deepEqual(await h.handlers.get("preferences:open")!(h.event(h.localContents), "speech", { instanceId: "workspace-1" }, true), { ok: true }) await assert.rejects(h.handlers.get("preferences:open")!(h.event(h.preferencesContents), "speech"), /local window/) await assert.rejects(h.handlers.get("preferences:open")!(h.event(h.localContents), "workspace"), /Invalid preferences section/) @@ -60,8 +60,8 @@ test("Preferences IPC separates local open authority and controls registered app assert.deepEqual(h.handlers.get("preferences:minimize")!(h.event(h.localContents)), { ok: true }) assert.deepEqual(h.handlers.get("preferences:toggleMaximize")!(h.event(h.preferencesContents)), { maximized: true }) assert.deepEqual(h.handlers.get("preferences:toggleMaximize")!(h.event(h.preferencesContents)), { maximized: false }) - assert.deepEqual(await h.handlers.get("preferences:close")!(h.event(h.preferencesContents)), { ok: true }) - assert.deepEqual(h.calls, ["open:speech:workspace-1", "ready", "accept:providers", "transition:3:false", "minimize", "local:minimize", "maximize", "unmaximize", "approve", "close"]) + assert.deepEqual(h.handlers.get("preferences:close")!(h.event(h.preferencesContents)), { ok: true }) + assert.deepEqual(h.calls, ["open:speech:workspace-1:true", "ready", "accept:providers", "transition:3:false", "minimize", "local:minimize", "maximize", "unmaximize", "approve", "close"]) }) test("Preferences IPC rejects unregistered, subframe, and cross-origin senders", () => { diff --git a/packages/electron-app/electron/main/preferences-ipc.ts b/packages/electron-app/electron/main/preferences-ipc.ts index 7bbefd36e..607acb19e 100644 --- a/packages/electron-app/electron/main/preferences-ipc.ts +++ b/packages/electron-app/electron/main/preferences-ipc.ts @@ -10,7 +10,7 @@ interface PreferencesIPCDependencies { resolveLocal(sender: IpcMainInvokeEvent["sender"]): { window: BrowserWindow } | undefined resolvePreferences(sender: IpcMainInvokeEvent["sender"]): BrowserWindow | undefined getAllowedOrigins(window: BrowserWindow): string[] - openPreferences(request: PreferencesRequest): Promise + openPreferences(request: PreferencesRequest, toggle?: boolean): Promise getRequest(window: BrowserWindow): PreferencesRequest | undefined markReady(window: BrowserWindow): void acceptRequest(window: BrowserWindow, request: PreferencesRequest): void | Promise @@ -39,9 +39,10 @@ export function setupPreferencesIPC(ipcMain: IPCRegistrar, dependencies: Prefere return window } - ipcMain.handle("preferences:open", async (event, section: unknown, context: unknown) => { + ipcMain.handle("preferences:open", async (event, section: unknown, context: unknown, toggle: unknown) => { local(event) - await dependencies.openPreferences(requirePreferencesRequest(section, context)) + if (typeof toggle !== "undefined" && typeof toggle !== "boolean") throw new Error("Invalid Preferences toggle") + await dependencies.openPreferences(requirePreferencesRequest(section, context), toggle === true) return { ok: true } }) ipcMain.handle("preferences:getSection", (event) => { diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index c5c41c9ec..36c7a2ec2 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -59,7 +59,7 @@ const localElectronAPI = { setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload), - openPreferences: (section, context) => ipcRenderer.invoke("preferences:open", section, context), + openPreferences: (section, context, toggle) => ipcRenderer.invoke("preferences:open", section, context, Boolean(toggle)), minimizeWindow: () => ipcRenderer.invoke("preferences:minimize"), toggleMaximizeWindow: () => ipcRenderer.invoke("preferences:toggleMaximize"), closeWindow: () => ipcRenderer.invoke("preferences:close"), diff --git a/packages/tauri-app/src-tauri/src/preferences_window.rs b/packages/tauri-app/src-tauri/src/preferences_window.rs index 5c9e36738..7403673c2 100644 --- a/packages/tauri-app/src-tauri/src/preferences_window.rs +++ b/packages/tauri-app/src-tauri/src/preferences_window.rs @@ -209,6 +209,7 @@ pub(crate) async fn open_preferences_window( app_state: tauri::State<'_, AppState>, preferences: tauri::State<'_, PreferencesWindow>, request: PreferencesRequest, + toggle: Option, ) -> Result<(), String> { crate::require_local_app_window(&window, &app_state)?; open_preferences(&app, &app_state, &preferences, request) @@ -226,8 +227,10 @@ fn open_preferences( .lock() .unwrap_or_else(|error| error.into_inner()); if let Some(existing) = app.get_webview_window(LABEL) { - app.state::() - .set_preferences(Some(request.clone()))?; + if toggle.unwrap_or(false) { + existing.close().map_err(|error| error.to_string())?; + return Ok(()); + } let renderer_ready = preferences .state .lock() diff --git a/packages/ui/src/components/instance-tabs.tsx b/packages/ui/src/components/instance-tabs.tsx index 02b27f294..f35d33068 100644 --- a/packages/ui/src/components/instance-tabs.tsx +++ b/packages/ui/src/components/instance-tabs.tsx @@ -24,7 +24,7 @@ import { isOsNotificationSupportedSync } from "../lib/os-notifications" import { canOpenRemoteWindows } from "../lib/runtime-env" import { getUnreadToastCountSignal, showToastNotification } from "../lib/notifications" import { useConfig } from "../stores/preferences" -import { openSettings } from "../stores/settings-screen" +import { openSettings, toggleSettings } from "../stores/settings-screen" import type { AppTabRecord } from "../stores/app-tabs" interface InstanceTabsProps { @@ -305,7 +305,7 @@ const InstanceTabs: Component = (props) => { - -
+ renderControls={(state) => ( +
+ - - - - - - -
+ + + +
)} renderBeforeItems={() => ( diff --git a/packages/ui/src/components/settings/advanced-settings-section.tsx b/packages/ui/src/components/settings/advanced-settings-section.tsx index 1bb63e8f7..046a4a8e0 100644 --- a/packages/ui/src/components/settings/advanced-settings-section.tsx +++ b/packages/ui/src/components/settings/advanced-settings-section.tsx @@ -1,7 +1,6 @@ import { createMemo, type Component } from "solid-js" import { useI18n } from "../../lib/i18n" -import { getBehaviorSettings } from "../../lib/settings/behavior-registry" -import { isNativeApplicationWindow } from "../../lib/runtime-env" +import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry" import { useConfig } from "../../stores/preferences" import EnvironmentVariablesEditor from "../environment-variables-editor" import { BehaviorSettingRows } from "./behavior-setting-rows" @@ -9,15 +8,23 @@ import { BehaviorSettingRows } from "./behavior-setting-rows" export const AdvancedSettingsSection: Component = () => { const { t } = useI18n() const config = useConfig() - const advancedSettings = createMemo(() => - getBehaviorSettings(config).filter( + const advancedSettings = createMemo(() => [ + ...getBehaviorSettings(config).filter( (setting) => setting.id === "behavior.autoCleanupBlankSessions" || setting.id === "behavior.keepUnseenSubagentIdleStatus" || (setting.id === "behavior.focusExistingWindowOnSecondLaunch" && isNativeApplicationWindow()) || setting.id === "behavior.providerUsageCreditBalance", ), - ) + { + kind: "toggle", + id: "behavior.holdLongAssistantReplies", + titleKey: "settings.behavior.holdLongAssistantReplies.title", + subtitleKey: "settings.behavior.holdLongAssistantReplies.subtitle", + get: (current) => Boolean(current.holdLongAssistantReplies ?? true), + set: (next) => config.updatePreferences({ holdLongAssistantReplies: next }), + }, + ]) return (
diff --git a/packages/ui/src/components/settings/general-settings-section.tsx b/packages/ui/src/components/settings/general-settings-section.tsx index fb7f10d65..90ed1991f 100644 --- a/packages/ui/src/components/settings/general-settings-section.tsx +++ b/packages/ui/src/components/settings/general-settings-section.tsx @@ -1,6 +1,6 @@ import { createMemo, lazy, Show, type Component } from "solid-js" import { useI18n } from "../../lib/i18n" -import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry" +import { getBehaviorSettings } from "../../lib/settings/behavior-registry" import { useConfig } from "../../stores/preferences" import { LocaleSelector } from "../locale-selector" import { BehaviorSettingRows } from "./behavior-setting-rows" @@ -15,9 +15,8 @@ interface GeneralSettingsSectionProps { export const GeneralSettingsSection: Component = (props) => { const { t } = useI18n() const config = useConfig() - const { updatePreferences } = config - const generalSettings = createMemo(() => [ - ...getBehaviorSettings(config).filter( + const generalSettings = createMemo(() => + getBehaviorSettings(config).filter( (setting) => setting.id === "behavior.keyboardShortcutHints" || setting.id === "behavior.messageTimeline" || @@ -26,15 +25,7 @@ export const GeneralSettingsSection: Component = (p setting.id === "behavior.followUpBehavior" || setting.id === "behavior.promptSubmitOnEnter", ), - { - kind: "toggle", - id: "behavior.holdLongAssistantReplies", - titleKey: "settings.behavior.holdLongAssistantReplies.title", - subtitleKey: "settings.behavior.holdLongAssistantReplies.subtitle", - get: (current) => Boolean(current.holdLongAssistantReplies ?? true), - set: (next) => updatePreferences({ holdLongAssistantReplies: next }), - }, - ]) + ) return (
diff --git a/packages/ui/src/components/settings/theme-scheme-settings.tsx b/packages/ui/src/components/settings/theme-scheme-settings.tsx index 46ffe1cd4..4248424ab 100644 --- a/packages/ui/src/components/settings/theme-scheme-settings.tsx +++ b/packages/ui/src/components/settings/theme-scheme-settings.tsx @@ -1,17 +1,18 @@ -import { Check } from "lucide-solid" import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Component } from "solid-js" import { useI18n } from "../../lib/i18n" import { useTheme } from "../../lib/theme" import { nextColorSchemePresetName } from "../../lib/color-scheme-presets" -import { showConfirmDialog, showPromptDialog } from "../../stores/alerts" +import { showConfirmDialog } from "../../stores/alerts" import { useConfig } from "../../stores/preferences" import { registerSettingsDirtyGuard } from "../../stores/settings-dirty-guard" import { BUILT_IN_COLOR_SCHEMES, DEFAULT_CUSTOM_COLORS, LIGHT_COLOR_SCHEME_COLORS, + SYSTEM_DARK_COLOR_SCHEME_COLORS, + SYSTEM_LIGHT_COLOR_SCHEME_COLORS, + isColorSchemeColors, normalizeColorScheme, - validateColorSchemeColors, type ColorSchemeColors, type ColorSchemeId, } from "../../lib/theme-scheme" @@ -41,6 +42,7 @@ interface PaletteOption { description: string appearance: "light" | "dark" colors: Readonly + originalColors: Readonly } export const ThemeSchemeSettings: Component = () => { @@ -49,34 +51,47 @@ export const ThemeSchemeSettings: Component = () => { const config = useConfig() const [draftColors, setDraftColors] = createSignal({ ...DEFAULT_CUSTOM_COLORS }) const [appearance, setAppearance] = createSignal<"light" | "dark">("dark") + const [filter, setFilter] = createSignal<"light" | "dark">("dark") + const [visitedKeys, setVisitedKeys] = createSignal>>({}) const [editingKey, setEditingKey] = createSignal("") const [sourceName, setSourceName] = createSignal(t("settings.appearance.colorScheme.option.custom")) + const [draftName, setDraftName] = createSignal("") + const [creating, setCreating] = createSignal(false) const [dirty, setDirty] = createSignal(false) const [saving, setSaving] = createSignal(false) const [saveFailed, setSaveFailed] = createSignal(false) - const classicColors = BUILT_IN_COLOR_SCHEMES.find((scheme) => scheme.id === "classic")!.colors! - const systemColors = () => typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches - ? classicColors - : LIGHT_COLOR_SCHEME_COLORS + const systemMedia = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : undefined + const [systemDark, setSystemDark] = createSignal(systemMedia?.matches ?? false) + const handleSystemAppearance = (event: MediaQueryListEvent) => setSystemDark(event.matches) + systemMedia?.addEventListener("change", handleSystemAppearance) + onCleanup(() => systemMedia?.removeEventListener("change", handleSystemAppearance)) + const systemColors = () => systemDark() ? SYSTEM_DARK_COLOR_SCHEME_COLORS : SYSTEM_LIGHT_COLOR_SCHEME_COLORS const options = createMemo(() => [ - ...BUILT_IN_COLOR_SCHEMES.map((scheme): PaletteOption => ({ - key: `builtin:${scheme.id}`, - id: scheme.id, - name: t(scheme.labelKey), - description: t(scheme.descriptionKey), - appearance: scheme.id === "system" - ? (typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") - : scheme.appearance === "light" ? "light" : "dark", - colors: scheme.id === "system" + ...BUILT_IN_COLOR_SCHEMES.map((scheme): PaletteOption => { + const originalColors = scheme.id === "system" ? systemColors() : scheme.id === "light" ? LIGHT_COLOR_SCHEME_COLORS + : scheme.colors ?? DEFAULT_CUSTOM_COLORS + const savedColors = scheme.id === "custom" + ? config.customColorSchemePreference().colors + : config.colorSchemeOverrides()[scheme.id] + return { + key: `builtin:${scheme.id}`, + id: scheme.id, + name: t(scheme.labelKey), + description: t(scheme.descriptionKey), + appearance: scheme.id === "system" + ? (systemDark() ? "dark" : "light") : scheme.id === "custom" - ? config.customColorSchemePreference().colors ?? DEFAULT_CUSTOM_COLORS - : scheme.colors ?? DEFAULT_CUSTOM_COLORS, - })), + ? (config.customColorSchemePreference().appearance === "light" ? "light" : "dark") + : scheme.appearance === "light" ? "light" : "dark", + colors: savedColors ?? originalColors, + originalColors, + } + }), ...Object.entries(config.colorSchemePresets()).map(([id, preset]): PaletteOption => ({ key: `preset:${id}`, presetId: id, @@ -84,21 +99,32 @@ export const ThemeSchemeSettings: Component = () => { description: t("settings.appearance.colorScheme.description.custom"), appearance: preset.appearance, colors: preset.colors, + originalColors: preset.colors, })), ]) const activeKey = createMemo(() => config.activeColorSchemePresetId() ? `preset:${config.activeColorSchemePresetId()}` : `builtin:${colorScheme().id}`) - const validDraft = createMemo(() => validateColorSchemeColors(draftColors())) + const filteredOptions = createMemo(() => options().filter((option) => option.appearance === filter())) + const editingOption = createMemo(() => options().find((option) => option.key === editingKey())) + const validDraft = createMemo(() => isColorSchemeColors(draftColors())) + const savedBuiltinOverride = createMemo(() => { + const option = editingOption() + if (!option?.id) return false + if (option.id === "custom") return COLOR_FIELDS.some((field) => option.colors[field.key] !== option.originalColors[field.key]) + return Boolean(config.colorSchemeOverrides()[option.id]) + }) createEffect(() => { - if (dirty()) return + if (!config.isLoaded() || dirty()) return const option = options().find((candidate) => candidate.key === activeKey()) if (!option) return setEditingKey(option.key) setSourceName(option.name) setAppearance(option.appearance) + setFilter(option.appearance) + setVisitedKeys((current) => ({ ...current, [option.appearance]: option.key })) setDraftColors({ ...option.colors }) setSaveFailed(false) }) @@ -118,8 +144,10 @@ export const ThemeSchemeSettings: Component = () => { const selectOption = async (option: PaletteOption) => { if (editingKey() === option.key || !(await confirmDiscardIfDirty())) return setDirty(false) + setCreating(false) setSaveFailed(false) setEditingKey(option.key) + setVisitedKeys((current) => ({ ...current, [option.appearance]: option.key })) setSourceName(option.name) setAppearance(option.appearance) setDraftColors({ ...option.colors }) @@ -144,32 +172,87 @@ export const ThemeSchemeSettings: Component = () => { return true } - const resetDraft = () => { - const option = options().find((candidate) => candidate.key === editingKey()) - if (option) { - setDraftColors({ ...option.colors }) + const resetBuiltin = async () => { + const option = editingOption() + if (!option?.id) return + setSaving(true) + setSaveFailed(false) + try { + if (option.id === "custom") await config.setColorSchemePreference(normalizeColorScheme("custom")) + else await config.resetColorSchemeOverride(option.id) + setDraftColors({ ...option.originalColors }) setAppearance(option.appearance) + setDirty(false) + } catch { + setSaveFailed(true) + } finally { + setSaving(false) } - setDirty(false) + } + + const startNewPreset = () => { + const name = nextColorSchemePresetName(sourceName(), Object.values(config.colorSchemePresets()).map((preset) => preset.name)) + setDraftName(name) + setCreating(true) + setDirty(true) setSaveFailed(false) } const savePreset = async () => { if (!dirty() || !validDraft()) return - const defaultName = nextColorSchemePresetName(sourceName(), Object.values(config.colorSchemePresets()).map((preset) => preset.name)) - const name = await showPromptDialog(t("settings.appearance.colorScheme.description.custom"), { - title: t("settings.appearance.colorScheme.title"), - inputLabel: t("settings.appearance.colorScheme.title"), - inputDefaultValue: defaultName, - confirmLabel: t("settings.appearance.colorScheme.custom.save"), - }) - if (!name?.trim()) return + const option = editingOption() + if (!option) return + const presetId = !creating() ? option.presetId : undefined + const name = creating() + ? draftName().trim() + : presetId + ? option.name + : nextColorSchemePresetName(sourceName(), Object.values(config.colorSchemePresets()).map((preset) => preset.name)) + if (!name) return setSaving(true) setSaveFailed(false) try { - const id = await config.saveColorSchemePreset(name, appearance(), draftColors()) + if (!creating() && option.id) { + if (option.id === "custom") { + await config.setColorSchemePreference(normalizeColorScheme({ id: "custom", appearance: option.appearance, colors: draftColors() })) + } else { + await config.saveColorSchemeOverride(option.id, option.appearance, draftColors()) + } + setDirty(false) + return + } + const id = await config.saveColorSchemePreset(name, appearance(), draftColors(), presetId) setEditingKey(`preset:${id}`) - setSourceName(name.trim()) + setVisitedKeys((current) => ({ ...current, [appearance()]: `preset:${id}` })) + setSourceName(name) + setCreating(false) + setDirty(false) + } catch { + setSaveFailed(true) + } finally { + setSaving(false) + } + } + + const deletePreset = async (option: PaletteOption) => { + if (!option.presetId) return + const confirmed = await showConfirmDialog(t("settings.appearance.colorScheme.custom.deleteConfirm", { name: option.name }), { + variant: "warning", + confirmLabel: t("settings.appearance.colorScheme.custom.delete"), + dismissible: false, + }) + if (!confirmed) return + setSaving(true) + setSaveFailed(false) + try { + await config.deleteColorSchemePreset(option.presetId) + const fallback = options().find((candidate) => candidate.key === "builtin:custom")! + setEditingKey(fallback.key) + setSourceName(fallback.name) + setAppearance(fallback.appearance) + setFilter(fallback.appearance) + setDraftColors({ ...fallback.colors }) + setCreating(false) setDirty(false) } catch { setSaveFailed(true) @@ -178,6 +261,16 @@ export const ThemeSchemeSettings: Component = () => { } } + const changeFilter = async (next: "light" | "dark") => { + if (filter() === next || !(await confirmDiscardIfDirty())) return + setDirty(false) + setCreating(false) + setFilter(next) + const option = options().find((candidate) => candidate.key === visitedKeys()[next] && candidate.appearance === next) + ?? options().find((candidate) => candidate.appearance === next) + if (option) void selectOption(option) + } + const colorsFor = (option: PaletteOption) => editingKey() === option.key ? draftColors() : option.colors return ( @@ -188,67 +281,93 @@ export const ThemeSchemeSettings: Component = () => {
- {(option) => ( -
- +
+
+ {(option) => ( + + )} +
+ +
+ {(option) => ( +
+ {option.name}}> + + {(field) => { const color = () => colorsFor(option)[field.key] const label = () => t(field.labelKey) return ( - { - const input = event.currentTarget - void updateColor(option, field.key, input.value).then((updated) => { - if (!updated) input.value = color() - }) - }} - /> + ) }}
- )} + )}
-
- {(option) => ( - - )} -
- - -
- - + + + + + + + + + + + +
diff --git a/packages/ui/src/lib/color-scheme-presets.test.ts b/packages/ui/src/lib/color-scheme-presets.test.ts index c87341e4a..c76157982 100644 --- a/packages/ui/src/lib/color-scheme-presets.test.ts +++ b/packages/ui/src/lib/color-scheme-presets.test.ts @@ -1,15 +1,19 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { DEFAULT_CUSTOM_COLORS, LIGHT_COLOR_SCHEME_COLORS, validateColorSchemeColors } from "./theme-scheme.ts" -import { MAX_COLOR_SCHEME_PRESETS, nextColorSchemePresetName, normalizeColorSchemePresets } from "./color-scheme-presets.ts" +import { MAX_COLOR_SCHEME_PRESETS, nextColorSchemePresetName, normalizeColorSchemeOverrides, normalizeColorSchemePresets } from "./color-scheme-presets.ts" describe("color scheme presets", () => { it("normalizes saved presets and increments derived names", () => { + const garish = { ...DEFAULT_CUSTOM_COLORS, surfaceBase: "#FF00FF", surfaceSecondary: "#00FFFF" } + assert.equal(validateColorSchemeColors(garish), false) assert.deepEqual(normalizeColorSchemePresets({ valid: { name: " Fjord 2 ", appearance: "dark", colors: DEFAULT_CUSTOM_COLORS }, + garish: { name: "Debug colors", appearance: "dark", colors: garish }, invalid: { name: "Broken", appearance: "dark", colors: { surfaceBase: "#fff" } }, }), { valid: { name: "Fjord 2", appearance: "dark", colors: DEFAULT_CUSTOM_COLORS }, + garish: { name: "Debug colors", appearance: "dark", colors: garish }, }) assert.equal(nextColorSchemePresetName("Fjord", ["Fjord 2", "Fjord 3"]), "Fjord 4") assert.equal(nextColorSchemePresetName("Fjord 2", ["Fjord 2"]), "Fjord 3") @@ -20,5 +24,8 @@ describe("color scheme presets", () => { { name: `Preset ${index}`, appearance: "dark", colors: DEFAULT_CUSTOM_COLORS }, ]), ))).length, MAX_COLOR_SCHEME_PRESETS) + assert.deepEqual(normalizeColorSchemeOverrides({ light: LIGHT_COLOR_SCHEME_COLORS, custom: DEFAULT_CUSTOM_COLORS, nope: DEFAULT_CUSTOM_COLORS }), { + light: LIGHT_COLOR_SCHEME_COLORS, + }) }) }) diff --git a/packages/ui/src/lib/color-scheme-presets.ts b/packages/ui/src/lib/color-scheme-presets.ts index 4c5361e1d..3f2c20857 100644 --- a/packages/ui/src/lib/color-scheme-presets.ts +++ b/packages/ui/src/lib/color-scheme-presets.ts @@ -1,7 +1,8 @@ import { + COLOR_SCHEME_IDS, isColorSchemeColors, - validateColorSchemeColors, type ColorSchemeColors, + type ColorSchemeId, } from "./theme-scheme.ts" export interface UserColorSchemePreset { @@ -11,6 +12,7 @@ export interface UserColorSchemePreset { } export type UserColorSchemePresets = Record +export type BuiltInColorSchemeOverrides = Partial, ColorSchemeColors>> export const MAX_COLOR_SCHEME_PRESETS = 50 const isRecord = (value: unknown): value is Record => @@ -23,12 +25,22 @@ export function normalizeColorSchemePresets(value: unknown): UserColorSchemePres if (!id || id.length > 128 || !isRecord(candidate)) continue const name = typeof candidate.name === "string" ? candidate.name.trim().slice(0, 80) : "" const appearance = candidate.appearance === "light" ? "light" : candidate.appearance === "dark" ? "dark" : undefined - if (!name || !appearance || !isColorSchemeColors(candidate.colors) || !validateColorSchemeColors(candidate.colors)) continue + if (!name || !appearance || !isColorSchemeColors(candidate.colors)) continue presets[id] = { name, appearance, colors: { ...candidate.colors } } } return presets } +export function normalizeColorSchemeOverrides(value: unknown): BuiltInColorSchemeOverrides { + if (!isRecord(value)) return {} + const overrides: BuiltInColorSchemeOverrides = {} + for (const [id, colors] of Object.entries(value)) { + if (id === "custom" || !COLOR_SCHEME_IDS.includes(id as ColorSchemeId) || !isColorSchemeColors(colors)) continue + overrides[id as Exclude] = { ...colors } + } + return overrides +} + export function nextColorSchemePresetName(sourceName: string, existingNames: readonly string[]): string { const source = sourceName.trim() || "Custom" const match = /^(.*?)(?:\s+(\d+))?$/.exec(source) diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 54150ef4c..026275380 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -146,6 +146,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.subtitle": "Wählen Sie die Palette für Clients dieses CodeNomad-Servers.", "settings.appearance.colorScheme.option.system": "System", "settings.appearance.colorScheme.option.light": "Hell", + "settings.appearance.colorScheme.option.porcelain": "Porzellan", + "settings.appearance.colorScheme.option.dawn": "Morgengrauen", + "settings.appearance.colorScheme.option.parchment": "Pergament", "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad Klassisch", "settings.appearance.colorScheme.option.basalt": "Basalt", "settings.appearance.colorScheme.option.fjord": "Fjord", @@ -155,6 +158,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.option.custom": "Benutzerdefiniert", "settings.appearance.colorScheme.description.system": "Folgt der Darstellung des Betriebssystems.", "settings.appearance.colorScheme.description.light": "Eine helle, neutrale Palette.", + "settings.appearance.colorScheme.description.porcelain": "Sanftes neutrales Chrome mit klarer weißer Arbeitsfläche.", + "settings.appearance.colorScheme.description.dawn": "Kühle Tageslichtflächen mit klarem blauem Akzent.", + "settings.appearance.colorScheme.description.parchment": "Warme Papierflächen mit zurückhaltendem Petrolakzent.", "settings.appearance.colorScheme.description.codeNomadClassic": "Die ursprüngliche dunkle CodeNomad-Palette.", "settings.appearance.colorScheme.description.basalt": "Neutrales Anthrazit mit kühlem blauem Akzent.", "settings.appearance.colorScheme.description.fjord": "Tiefes Blaugrau mit meergrünem Akzent.", @@ -166,10 +172,10 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.appearance.light": "Hell", "settings.appearance.colorScheme.custom.appearance.dark": "Dunkel", "settings.appearance.colorScheme.custom.colors": "Farben", - "settings.appearance.colorScheme.custom.field.surfaceBase": "Grundfläche", - "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Sekundäre Fläche", - "settings.appearance.colorScheme.custom.field.surfaceMuted": "Gedämpfte Fläche", - "settings.appearance.colorScheme.custom.field.borderBase": "Rahmen", + "settings.appearance.colorScheme.custom.field.surfaceBase": "Inhaltshintergrund", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Panelhintergrund", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "Gedämpfter Hintergrund", + "settings.appearance.colorScheme.custom.field.borderBase": "Rahmen und Trennlinien", "settings.appearance.colorScheme.custom.field.textPrimary": "Primärtext", "settings.appearance.colorScheme.custom.field.textMuted": "Gedämpfter Text", "settings.appearance.colorScheme.custom.field.accentPrimary": "Akzent", @@ -183,8 +189,11 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.pickerAriaLabel": "{name} auswählen", "settings.appearance.colorScheme.custom.valueAriaLabel": "Hexadezimalwert für {name}", "settings.appearance.colorScheme.custom.warning.format": "Verwenden Sie Großbuchstaben im Format #RRGGBB.", - "settings.appearance.colorScheme.custom.warning.contrast": "Erhöhen Sie vor dem Speichern den Kontrast.", "settings.appearance.colorScheme.custom.reset": "Zurücksetzen", + "settings.appearance.colorScheme.custom.new": "Neu", + "settings.appearance.colorScheme.custom.name": "Name", + "settings.appearance.colorScheme.custom.delete": "Löschen", + "settings.appearance.colorScheme.custom.deleteConfirm": "„{name}“ löschen?", "settings.appearance.colorScheme.custom.save": "Speichern", "settings.appearance.colorScheme.custom.saveError": "Die Farbpalette konnte nicht gespeichert werden.", "settings.appearance.startup.title": "Startverhalten", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index e5c2063e7..6df292532 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -146,6 +146,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.subtitle": "Choose the palette used by clients connected to this CodeNomad server.", "settings.appearance.colorScheme.option.system": "System", "settings.appearance.colorScheme.option.light": "Light", + "settings.appearance.colorScheme.option.porcelain": "Porcelain", + "settings.appearance.colorScheme.option.dawn": "Dawn", + "settings.appearance.colorScheme.option.parchment": "Parchment", "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad Classic", "settings.appearance.colorScheme.option.basalt": "Basalt", "settings.appearance.colorScheme.option.fjord": "Fjord", @@ -155,6 +158,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.option.custom": "Custom", "settings.appearance.colorScheme.description.system": "Follow your operating system appearance.", "settings.appearance.colorScheme.description.light": "A bright, neutral palette.", + "settings.appearance.colorScheme.description.porcelain": "Soft neutral chrome with a crisp white workspace.", + "settings.appearance.colorScheme.description.dawn": "Cool daylight surfaces with a clear blue accent.", + "settings.appearance.colorScheme.description.parchment": "Warm paper surfaces with a restrained teal accent.", "settings.appearance.colorScheme.description.codeNomadClassic": "The original CodeNomad dark palette.", "settings.appearance.colorScheme.description.basalt": "Neutral charcoal with a cool blue accent.", "settings.appearance.colorScheme.description.fjord": "Deep blue-gray with a sea-green accent.", @@ -166,10 +172,10 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.appearance.light": "Light", "settings.appearance.colorScheme.custom.appearance.dark": "Dark", "settings.appearance.colorScheme.custom.colors": "Colors", - "settings.appearance.colorScheme.custom.field.surfaceBase": "Base surface", - "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Secondary surface", - "settings.appearance.colorScheme.custom.field.surfaceMuted": "Muted surface", - "settings.appearance.colorScheme.custom.field.borderBase": "Border", + "settings.appearance.colorScheme.custom.field.surfaceBase": "Content background", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Panel background", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "Muted background", + "settings.appearance.colorScheme.custom.field.borderBase": "Borders and separators", "settings.appearance.colorScheme.custom.field.textPrimary": "Primary text", "settings.appearance.colorScheme.custom.field.textMuted": "Muted text", "settings.appearance.colorScheme.custom.field.accentPrimary": "Accent", @@ -183,8 +189,11 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.pickerAriaLabel": "Choose {name}", "settings.appearance.colorScheme.custom.valueAriaLabel": "Hex value for {name}", "settings.appearance.colorScheme.custom.warning.format": "Use uppercase colors in #RRGGBB format.", - "settings.appearance.colorScheme.custom.warning.contrast": "Increase color contrast before saving.", "settings.appearance.colorScheme.custom.reset": "Reset", + "settings.appearance.colorScheme.custom.new": "New", + "settings.appearance.colorScheme.custom.name": "Name", + "settings.appearance.colorScheme.custom.delete": "Delete", + "settings.appearance.colorScheme.custom.deleteConfirm": "Delete \"{name}\"?", "settings.appearance.colorScheme.custom.save": "Save", "settings.appearance.colorScheme.custom.saveError": "Could not save the palette.", "settings.appearance.startup.title": "Startup", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 39a8fb460..83c7b8451 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -146,6 +146,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.subtitle": "Elige la paleta de los clientes conectados a este servidor CodeNomad.", "settings.appearance.colorScheme.option.system": "Sistema", "settings.appearance.colorScheme.option.light": "Claro", + "settings.appearance.colorScheme.option.porcelain": "Porcelana", + "settings.appearance.colorScheme.option.dawn": "Amanecer", + "settings.appearance.colorScheme.option.parchment": "Pergamino", "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad Clásico", "settings.appearance.colorScheme.option.basalt": "Basalto", "settings.appearance.colorScheme.option.fjord": "Fiordo", @@ -155,6 +158,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.option.custom": "Personalizado", "settings.appearance.colorScheme.description.system": "Sigue la apariencia del sistema operativo.", "settings.appearance.colorScheme.description.light": "Una paleta clara y neutra.", + "settings.appearance.colorScheme.description.porcelain": "Un marco neutro y suave con un espacio de trabajo blanco y nítido.", + "settings.appearance.colorScheme.description.dawn": "Superficies frías de luz diurna con un acento azul definido.", + "settings.appearance.colorScheme.description.parchment": "Superficies cálidas de papel con un discreto acento verde azulado.", "settings.appearance.colorScheme.description.codeNomadClassic": "La paleta oscura original de CodeNomad.", "settings.appearance.colorScheme.description.basalt": "Gris carbón neutro con un acento azul frío.", "settings.appearance.colorScheme.description.fjord": "Azul grisáceo profundo con un acento verde mar.", @@ -166,10 +172,10 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.appearance.light": "Clara", "settings.appearance.colorScheme.custom.appearance.dark": "Oscura", "settings.appearance.colorScheme.custom.colors": "Colores", - "settings.appearance.colorScheme.custom.field.surfaceBase": "Superficie base", - "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Superficie secundaria", - "settings.appearance.colorScheme.custom.field.surfaceMuted": "Superficie atenuada", - "settings.appearance.colorScheme.custom.field.borderBase": "Borde", + "settings.appearance.colorScheme.custom.field.surfaceBase": "Fondo del contenido", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Fondo de paneles", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "Fondo atenuado", + "settings.appearance.colorScheme.custom.field.borderBase": "Bordes y separadores", "settings.appearance.colorScheme.custom.field.textPrimary": "Texto principal", "settings.appearance.colorScheme.custom.field.textMuted": "Texto atenuado", "settings.appearance.colorScheme.custom.field.accentPrimary": "Acento", @@ -183,8 +189,11 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.pickerAriaLabel": "Elegir {name}", "settings.appearance.colorScheme.custom.valueAriaLabel": "Valor hexadecimal de {name}", "settings.appearance.colorScheme.custom.warning.format": "Usa colores en mayúsculas con formato #RRGGBB.", - "settings.appearance.colorScheme.custom.warning.contrast": "Aumenta el contraste antes de guardar.", "settings.appearance.colorScheme.custom.reset": "Restablecer", + "settings.appearance.colorScheme.custom.new": "Nuevo", + "settings.appearance.colorScheme.custom.name": "Nombre", + "settings.appearance.colorScheme.custom.delete": "Eliminar", + "settings.appearance.colorScheme.custom.deleteConfirm": "¿Eliminar «{name}»?", "settings.appearance.colorScheme.custom.save": "Guardar", "settings.appearance.colorScheme.custom.saveError": "No se pudo guardar la paleta.", "settings.appearance.startup.title": "Inicio", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 5b48289fe..6ad97c585 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -146,6 +146,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.subtitle": "Choisissez la palette des clients connectés à ce serveur CodeNomad.", "settings.appearance.colorScheme.option.system": "Système", "settings.appearance.colorScheme.option.light": "Clair", + "settings.appearance.colorScheme.option.porcelain": "Porcelaine", + "settings.appearance.colorScheme.option.dawn": "Aube", + "settings.appearance.colorScheme.option.parchment": "Parchemin", "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad Classique", "settings.appearance.colorScheme.option.basalt": "Basalte", "settings.appearance.colorScheme.option.fjord": "Fjord", @@ -155,6 +158,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.option.custom": "Personnalisée", "settings.appearance.colorScheme.description.system": "Suit l'apparence du système d'exploitation.", "settings.appearance.colorScheme.description.light": "Une palette claire et neutre.", + "settings.appearance.colorScheme.description.porcelain": "Un chrome neutre et doux autour d’un espace de travail blanc.", + "settings.appearance.colorScheme.description.dawn": "Des surfaces de jour froides rehaussées de bleu clair.", + "settings.appearance.colorScheme.description.parchment": "Des surfaces papier chaudes avec un accent bleu sarcelle discret.", "settings.appearance.colorScheme.description.codeNomadClassic": "La palette sombre d'origine de CodeNomad.", "settings.appearance.colorScheme.description.basalt": "Un gris charbon neutre rehaussé de bleu froid.", "settings.appearance.colorScheme.description.fjord": "Un bleu-gris profond rehaussé de vert marin.", @@ -166,10 +172,10 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.appearance.light": "Claire", "settings.appearance.colorScheme.custom.appearance.dark": "Sombre", "settings.appearance.colorScheme.custom.colors": "Couleurs", - "settings.appearance.colorScheme.custom.field.surfaceBase": "Surface de base", - "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Surface secondaire", - "settings.appearance.colorScheme.custom.field.surfaceMuted": "Surface atténuée", - "settings.appearance.colorScheme.custom.field.borderBase": "Bordure", + "settings.appearance.colorScheme.custom.field.surfaceBase": "Fond du contenu", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Fond des panneaux", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "Fond atténué", + "settings.appearance.colorScheme.custom.field.borderBase": "Bordures et séparateurs", "settings.appearance.colorScheme.custom.field.textPrimary": "Texte principal", "settings.appearance.colorScheme.custom.field.textMuted": "Texte atténué", "settings.appearance.colorScheme.custom.field.accentPrimary": "Accent", @@ -183,8 +189,11 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.pickerAriaLabel": "Choisir {name}", "settings.appearance.colorScheme.custom.valueAriaLabel": "Valeur hexadécimale pour {name}", "settings.appearance.colorScheme.custom.warning.format": "Utilisez le format #RRGGBB en majuscules.", - "settings.appearance.colorScheme.custom.warning.contrast": "Augmentez le contraste avant d'enregistrer.", "settings.appearance.colorScheme.custom.reset": "Réinitialiser", + "settings.appearance.colorScheme.custom.new": "Nouveau", + "settings.appearance.colorScheme.custom.name": "Nom", + "settings.appearance.colorScheme.custom.delete": "Supprimer", + "settings.appearance.colorScheme.custom.deleteConfirm": "Supprimer « {name} » ?", "settings.appearance.colorScheme.custom.save": "Enregistrer", "settings.appearance.colorScheme.custom.saveError": "Impossible d'enregistrer la palette.", "settings.appearance.startup.title": "Démarrage", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 8eafb913d..17895262c 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -146,6 +146,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.subtitle": "בחר את לוח הצבעים ללקוחות המחוברים לשרת CodeNomad הזה.", "settings.appearance.colorScheme.option.system": "מערכת", "settings.appearance.colorScheme.option.light": "בהיר", + "settings.appearance.colorScheme.option.porcelain": "פורצלן", + "settings.appearance.colorScheme.option.dawn": "שחר", + "settings.appearance.colorScheme.option.parchment": "קלף", "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad קלאסי", "settings.appearance.colorScheme.option.basalt": "בזלת", "settings.appearance.colorScheme.option.fjord": "פיורד", @@ -155,6 +158,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.option.custom": "מותאם אישית", "settings.appearance.colorScheme.description.system": "מתאים למראה של מערכת ההפעלה.", "settings.appearance.colorScheme.description.light": "לוח צבעים בהיר וניטרלי.", + "settings.appearance.colorScheme.description.porcelain": "מעטפת ניטרלית ורכה עם סביבת עבודה לבנה ונקייה.", + "settings.appearance.colorScheme.description.dawn": "משטחי אור יום קרירים עם הדגשה כחולה ברורה.", + "settings.appearance.colorScheme.description.parchment": "משטחי נייר חמימים עם הדגשת טורקיז מאופקת.", "settings.appearance.colorScheme.description.codeNomadClassic": "לוח הצבעים הכהה המקורי של CodeNomad.", "settings.appearance.colorScheme.description.basalt": "אפור פחם ניטרלי עם הדגשה כחולה קרירה.", "settings.appearance.colorScheme.description.fjord": "כחול-אפור עמוק עם הדגשה ירוקה-ימית.", @@ -166,10 +172,10 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.appearance.light": "בהיר", "settings.appearance.colorScheme.custom.appearance.dark": "כהה", "settings.appearance.colorScheme.custom.colors": "צבעים", - "settings.appearance.colorScheme.custom.field.surfaceBase": "משטח בסיס", - "settings.appearance.colorScheme.custom.field.surfaceSecondary": "משטח משני", - "settings.appearance.colorScheme.custom.field.surfaceMuted": "משטח מעומעם", - "settings.appearance.colorScheme.custom.field.borderBase": "גבול", + "settings.appearance.colorScheme.custom.field.surfaceBase": "רקע התוכן", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "רקע החלוניות", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "רקע מעומעם", + "settings.appearance.colorScheme.custom.field.borderBase": "גבולות ומפרידים", "settings.appearance.colorScheme.custom.field.textPrimary": "טקסט ראשי", "settings.appearance.colorScheme.custom.field.textMuted": "טקסט מעומעם", "settings.appearance.colorScheme.custom.field.accentPrimary": "הדגשה", @@ -183,8 +189,11 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.pickerAriaLabel": "בחירת {name}", "settings.appearance.colorScheme.custom.valueAriaLabel": "ערך הקסדצימלי עבור {name}", "settings.appearance.colorScheme.custom.warning.format": "יש להשתמש באותיות גדולות בתבנית #RRGGBB.", - "settings.appearance.colorScheme.custom.warning.contrast": "יש להגדיל את ניגודיות הצבעים לפני השמירה.", "settings.appearance.colorScheme.custom.reset": "איפוס", + "settings.appearance.colorScheme.custom.new": "חדש", + "settings.appearance.colorScheme.custom.name": "שם", + "settings.appearance.colorScheme.custom.delete": "מחיקה", + "settings.appearance.colorScheme.custom.deleteConfirm": "למחוק את „{name}” ?", "settings.appearance.colorScheme.custom.save": "שמירה", "settings.appearance.colorScheme.custom.saveError": "לא ניתן לשמור את לוח הצבעים.", "settings.appearance.startup.title": "הפעלה", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index ffee6a953..2c75019b3 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -146,6 +146,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.subtitle": "この CodeNomad サーバーに接続するクライアントの配色を選択します。", "settings.appearance.colorScheme.option.system": "システム", "settings.appearance.colorScheme.option.light": "ライト", + "settings.appearance.colorScheme.option.porcelain": "ポーセリン", + "settings.appearance.colorScheme.option.dawn": "夜明け", + "settings.appearance.colorScheme.option.parchment": "パーチメント", "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad クラシック", "settings.appearance.colorScheme.option.basalt": "バサルト", "settings.appearance.colorScheme.option.fjord": "フィヨルド", @@ -155,6 +158,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.option.custom": "カスタム", "settings.appearance.colorScheme.description.system": "OS の表示設定に合わせます。", "settings.appearance.colorScheme.description.light": "明るくニュートラルな配色です。", + "settings.appearance.colorScheme.description.porcelain": "柔らかなニュートラルカラーの枠と、くっきりした白い作業領域。", + "settings.appearance.colorScheme.description.dawn": "涼やかな昼光色の面に、明快な青のアクセント。", + "settings.appearance.colorScheme.description.parchment": "温かな紙色の面に、控えめな青緑のアクセント。", "settings.appearance.colorScheme.description.codeNomadClassic": "CodeNomad オリジナルのダーク配色です。", "settings.appearance.colorScheme.description.basalt": "チャコールに寒色の青を合わせた配色です。", "settings.appearance.colorScheme.description.fjord": "深いブルーグレーに青緑を合わせた配色です。", @@ -166,10 +172,10 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.appearance.light": "ライト", "settings.appearance.colorScheme.custom.appearance.dark": "ダーク", "settings.appearance.colorScheme.custom.colors": "色", - "settings.appearance.colorScheme.custom.field.surfaceBase": "基本サーフェス", - "settings.appearance.colorScheme.custom.field.surfaceSecondary": "第2サーフェス", - "settings.appearance.colorScheme.custom.field.surfaceMuted": "控えめなサーフェス", - "settings.appearance.colorScheme.custom.field.borderBase": "境界線", + "settings.appearance.colorScheme.custom.field.surfaceBase": "コンテンツ背景", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "パネル背景", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "控えめな背景", + "settings.appearance.colorScheme.custom.field.borderBase": "境界線と区切り", "settings.appearance.colorScheme.custom.field.textPrimary": "メインテキスト", "settings.appearance.colorScheme.custom.field.textMuted": "控えめなテキスト", "settings.appearance.colorScheme.custom.field.accentPrimary": "アクセント", @@ -183,8 +189,11 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.pickerAriaLabel": "{name}を選択", "settings.appearance.colorScheme.custom.valueAriaLabel": "{name}の16進値", "settings.appearance.colorScheme.custom.warning.format": "大文字の #RRGGBB 形式で入力してください。", - "settings.appearance.colorScheme.custom.warning.contrast": "保存する前に色のコントラストを上げてください。", "settings.appearance.colorScheme.custom.reset": "リセット", + "settings.appearance.colorScheme.custom.new": "新規", + "settings.appearance.colorScheme.custom.name": "名前", + "settings.appearance.colorScheme.custom.delete": "削除", + "settings.appearance.colorScheme.custom.deleteConfirm": "「{name}」を削除しますか?", "settings.appearance.colorScheme.custom.save": "保存", "settings.appearance.colorScheme.custom.saveError": "カラーパレットを保存できませんでした。", "settings.appearance.startup.title": "起動", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 3ab2a000d..fa81e2c1a 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -146,6 +146,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.subtitle": "यो CodeNomad सर्भरमा जोडिएका क्लाइन्टहरूको रङ प्यालेट छान्नुहोस्।", "settings.appearance.colorScheme.option.system": "प्रणाली", "settings.appearance.colorScheme.option.light": "उज्यालो", + "settings.appearance.colorScheme.option.porcelain": "पोर्सिलेन", + "settings.appearance.colorScheme.option.dawn": "प्रभात", + "settings.appearance.colorScheme.option.parchment": "पार्चमेन्ट", "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad क्लासिक", "settings.appearance.colorScheme.option.basalt": "बेसाल्ट", "settings.appearance.colorScheme.option.fjord": "फ्योर्ड", @@ -155,6 +158,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.option.custom": "अनुकूलित", "settings.appearance.colorScheme.description.system": "अपरेटिङ सिस्टमको रूप पछ्याउँछ।", "settings.appearance.colorScheme.description.light": "उज्यालो र तटस्थ प्यालेट।", + "settings.appearance.colorScheme.description.porcelain": "सफा सेतो कार्यक्षेत्रसहितको नरम तटस्थ क्रोम।", + "settings.appearance.colorScheme.description.dawn": "स्पष्ट नीलो जोडसहितका चिसा दिवा सतहहरू।", + "settings.appearance.colorScheme.description.parchment": "संयमित टिल जोडसहितका न्यानो कागजी सतहहरू।", "settings.appearance.colorScheme.description.codeNomadClassic": "CodeNomad को मौलिक अँध्यारो प्यालेट।", "settings.appearance.colorScheme.description.basalt": "चिसो नीलो जोडसहितको तटस्थ कोइला रङ।", "settings.appearance.colorScheme.description.fjord": "समुद्री हरियो जोडसहितको गाढा नीलो-खैरो।", @@ -166,10 +172,10 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.appearance.light": "उज्यालो", "settings.appearance.colorScheme.custom.appearance.dark": "अँध्यारो", "settings.appearance.colorScheme.custom.colors": "रङहरू", - "settings.appearance.colorScheme.custom.field.surfaceBase": "आधार सतह", - "settings.appearance.colorScheme.custom.field.surfaceSecondary": "दोस्रो सतह", - "settings.appearance.colorScheme.custom.field.surfaceMuted": "मधुरो सतह", - "settings.appearance.colorScheme.custom.field.borderBase": "किनारा", + "settings.appearance.colorScheme.custom.field.surfaceBase": "सामग्रीको पृष्ठभूमि", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "प्यानलको पृष्ठभूमि", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "मधुरो पृष्ठभूमि", + "settings.appearance.colorScheme.custom.field.borderBase": "किनारा र विभाजक", "settings.appearance.colorScheme.custom.field.textPrimary": "मुख्य पाठ", "settings.appearance.colorScheme.custom.field.textMuted": "मधुरो पाठ", "settings.appearance.colorScheme.custom.field.accentPrimary": "जोड रङ", @@ -183,8 +189,11 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.pickerAriaLabel": "{name} छान्नुहोस्", "settings.appearance.colorScheme.custom.valueAriaLabel": "{name} को हेक्स मान", "settings.appearance.colorScheme.custom.warning.format": "ठूला अक्षरसहित #RRGGBB ढाँचा प्रयोग गर्नुहोस्।", - "settings.appearance.colorScheme.custom.warning.contrast": "सुरक्षित गर्नुअघि रङको कन्ट्रास्ट बढाउनुहोस्।", "settings.appearance.colorScheme.custom.reset": "रिसेट गर्नुहोस्", + "settings.appearance.colorScheme.custom.new": "नयाँ", + "settings.appearance.colorScheme.custom.name": "नाम", + "settings.appearance.colorScheme.custom.delete": "मेटाउनुहोस्", + "settings.appearance.colorScheme.custom.deleteConfirm": "\"{name}\" मेटाउने हो?", "settings.appearance.colorScheme.custom.save": "सुरक्षित गर्नुहोस्", "settings.appearance.colorScheme.custom.saveError": "रङ प्यालेट सुरक्षित गर्न सकिएन।", "settings.appearance.startup.title": "सुरुआत", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 4b9b1e292..7307d8acb 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -146,6 +146,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.subtitle": "Выберите палитру для клиентов этого сервера CodeNomad.", "settings.appearance.colorScheme.option.system": "Системная", "settings.appearance.colorScheme.option.light": "Светлая", + "settings.appearance.colorScheme.option.porcelain": "Фарфор", + "settings.appearance.colorScheme.option.dawn": "Рассвет", + "settings.appearance.colorScheme.option.parchment": "Пергамент", "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad Классика", "settings.appearance.colorScheme.option.basalt": "Базальт", "settings.appearance.colorScheme.option.fjord": "Фьорд", @@ -155,6 +158,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.option.custom": "Своя", "settings.appearance.colorScheme.description.system": "Следует оформлению операционной системы.", "settings.appearance.colorScheme.description.light": "Светлая нейтральная палитра.", + "settings.appearance.colorScheme.description.porcelain": "Мягкое нейтральное обрамление и чистая белая рабочая область.", + "settings.appearance.colorScheme.description.dawn": "Прохладные дневные поверхности с ясным синим акцентом.", + "settings.appearance.colorScheme.description.parchment": "Тёплые бумажные поверхности со сдержанным бирюзовым акцентом.", "settings.appearance.colorScheme.description.codeNomadClassic": "Оригинальная тёмная палитра CodeNomad.", "settings.appearance.colorScheme.description.basalt": "Нейтральный угольный цвет с холодным синим акцентом.", "settings.appearance.colorScheme.description.fjord": "Глубокий серо-синий цвет с морским зелёным акцентом.", @@ -166,10 +172,10 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.appearance.light": "Светлая", "settings.appearance.colorScheme.custom.appearance.dark": "Тёмная", "settings.appearance.colorScheme.custom.colors": "Цвета", - "settings.appearance.colorScheme.custom.field.surfaceBase": "Основной фон", - "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Вторичный фон", + "settings.appearance.colorScheme.custom.field.surfaceBase": "Фон содержимого", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Фон панелей", "settings.appearance.colorScheme.custom.field.surfaceMuted": "Приглушённый фон", - "settings.appearance.colorScheme.custom.field.borderBase": "Граница", + "settings.appearance.colorScheme.custom.field.borderBase": "Границы и разделители", "settings.appearance.colorScheme.custom.field.textPrimary": "Основной текст", "settings.appearance.colorScheme.custom.field.textMuted": "Приглушённый текст", "settings.appearance.colorScheme.custom.field.accentPrimary": "Акцент", @@ -183,8 +189,11 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.pickerAriaLabel": "Выбрать цвет: {name}", "settings.appearance.colorScheme.custom.valueAriaLabel": "Шестнадцатеричное значение: {name}", "settings.appearance.colorScheme.custom.warning.format": "Используйте формат #RRGGBB с заглавными буквами.", - "settings.appearance.colorScheme.custom.warning.contrast": "Увеличьте контраст перед сохранением.", "settings.appearance.colorScheme.custom.reset": "Сбросить", + "settings.appearance.colorScheme.custom.new": "Новая", + "settings.appearance.colorScheme.custom.name": "Название", + "settings.appearance.colorScheme.custom.delete": "Удалить", + "settings.appearance.colorScheme.custom.deleteConfirm": "Удалить «{name}»?", "settings.appearance.colorScheme.custom.save": "Сохранить", "settings.appearance.colorScheme.custom.saveError": "Не удалось сохранить палитру.", "settings.appearance.startup.title": "Запуск", diff --git a/packages/ui/src/lib/i18n/messages/tr/settings.ts b/packages/ui/src/lib/i18n/messages/tr/settings.ts index a8128f8ec..7d5527a03 100644 --- a/packages/ui/src/lib/i18n/messages/tr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/settings.ts @@ -136,6 +136,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.subtitle": "Bu CodeNomad sunucusuna bağlı istemcilerin paletini seçin.", "settings.appearance.colorScheme.option.system": "Sistem", "settings.appearance.colorScheme.option.light": "Açık", + "settings.appearance.colorScheme.option.porcelain": "Porselen", + "settings.appearance.colorScheme.option.dawn": "Şafak", + "settings.appearance.colorScheme.option.parchment": "Parşömen", "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad Klasik", "settings.appearance.colorScheme.option.basalt": "Bazalt", "settings.appearance.colorScheme.option.fjord": "Fiyort", @@ -145,6 +148,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.option.custom": "Özel", "settings.appearance.colorScheme.description.system": "İşletim sisteminin görünümünü izler.", "settings.appearance.colorScheme.description.light": "Açık ve nötr bir palet.", + "settings.appearance.colorScheme.description.porcelain": "Temiz beyaz çalışma alanını çevreleyen yumuşak nötr yüzeyler.", + "settings.appearance.colorScheme.description.dawn": "Belirgin mavi vurgulu serin gün ışığı yüzeyleri.", + "settings.appearance.colorScheme.description.parchment": "Ölçülü turkuaz vurgulu sıcak kâğıt yüzeyler.", "settings.appearance.colorScheme.description.codeNomadClassic": "CodeNomad'in özgün koyu paleti.", "settings.appearance.colorScheme.description.basalt": "Soğuk mavi vurgulu nötr kömür grisi.", "settings.appearance.colorScheme.description.fjord": "Deniz yeşili vurgulu derin mavi-gri.", @@ -156,10 +162,10 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.appearance.light": "Açık", "settings.appearance.colorScheme.custom.appearance.dark": "Koyu", "settings.appearance.colorScheme.custom.colors": "Renkler", - "settings.appearance.colorScheme.custom.field.surfaceBase": "Temel yüzey", - "settings.appearance.colorScheme.custom.field.surfaceSecondary": "İkincil yüzey", - "settings.appearance.colorScheme.custom.field.surfaceMuted": "Soluk yüzey", - "settings.appearance.colorScheme.custom.field.borderBase": "Kenarlık", + "settings.appearance.colorScheme.custom.field.surfaceBase": "İçerik arka planı", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Panel arka planı", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "Soluk arka plan", + "settings.appearance.colorScheme.custom.field.borderBase": "Kenarlıklar ve ayırıcılar", "settings.appearance.colorScheme.custom.field.textPrimary": "Ana metin", "settings.appearance.colorScheme.custom.field.textMuted": "Soluk metin", "settings.appearance.colorScheme.custom.field.accentPrimary": "Vurgu", @@ -173,8 +179,11 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.pickerAriaLabel": "{name} seç", "settings.appearance.colorScheme.custom.valueAriaLabel": "{name} için onaltılık değer", "settings.appearance.colorScheme.custom.warning.format": "Büyük harflerle #RRGGBB biçimini kullanın.", - "settings.appearance.colorScheme.custom.warning.contrast": "Kaydetmeden önce renk kontrastını artırın.", "settings.appearance.colorScheme.custom.reset": "Sıfırla", + "settings.appearance.colorScheme.custom.new": "Yeni", + "settings.appearance.colorScheme.custom.name": "Ad", + "settings.appearance.colorScheme.custom.delete": "Sil", + "settings.appearance.colorScheme.custom.deleteConfirm": "\"{name}\" silinsin mi?", "settings.appearance.colorScheme.custom.save": "Kaydet", "settings.appearance.colorScheme.custom.saveError": "Renk paleti kaydedilemedi.", "settings.appearance.startup.title": "Başlangıç", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 8bb6d1f61..cd9c18833 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -146,6 +146,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.subtitle": "选择连接到此 CodeNomad 服务器的客户端所用配色。", "settings.appearance.colorScheme.option.system": "系统", "settings.appearance.colorScheme.option.light": "浅色", + "settings.appearance.colorScheme.option.porcelain": "瓷白", + "settings.appearance.colorScheme.option.dawn": "晨曦", + "settings.appearance.colorScheme.option.parchment": "羊皮纸", "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad 经典", "settings.appearance.colorScheme.option.basalt": "玄武岩", "settings.appearance.colorScheme.option.fjord": "峡湾", @@ -155,6 +158,9 @@ export const settingsMessages = { "settings.appearance.colorScheme.option.custom": "自定义", "settings.appearance.colorScheme.description.system": "跟随操作系统外观。", "settings.appearance.colorScheme.description.light": "明亮、中性的配色。", + "settings.appearance.colorScheme.description.porcelain": "柔和的中性色框架搭配清爽的白色工作区。", + "settings.appearance.colorScheme.description.dawn": "冷调日光表面搭配清晰的蓝色强调色。", + "settings.appearance.colorScheme.description.parchment": "暖纸色表面搭配克制的青绿色强调色。", "settings.appearance.colorScheme.description.codeNomadClassic": "CodeNomad 原版深色配色。", "settings.appearance.colorScheme.description.basalt": "中性炭灰搭配冷蓝色强调色。", "settings.appearance.colorScheme.description.fjord": "深蓝灰搭配海绿色强调色。", @@ -166,10 +172,10 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.appearance.light": "浅色", "settings.appearance.colorScheme.custom.appearance.dark": "深色", "settings.appearance.colorScheme.custom.colors": "颜色", - "settings.appearance.colorScheme.custom.field.surfaceBase": "基础表面", - "settings.appearance.colorScheme.custom.field.surfaceSecondary": "次级表面", - "settings.appearance.colorScheme.custom.field.surfaceMuted": "弱化表面", - "settings.appearance.colorScheme.custom.field.borderBase": "边框", + "settings.appearance.colorScheme.custom.field.surfaceBase": "内容背景", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "面板背景", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "弱化背景", + "settings.appearance.colorScheme.custom.field.borderBase": "边框和分隔线", "settings.appearance.colorScheme.custom.field.textPrimary": "主要文本", "settings.appearance.colorScheme.custom.field.textMuted": "弱化文本", "settings.appearance.colorScheme.custom.field.accentPrimary": "强调色", @@ -183,8 +189,11 @@ export const settingsMessages = { "settings.appearance.colorScheme.custom.pickerAriaLabel": "选择{name}", "settings.appearance.colorScheme.custom.valueAriaLabel": "{name}的十六进制值", "settings.appearance.colorScheme.custom.warning.format": "请使用大写的 #RRGGBB 格式。", - "settings.appearance.colorScheme.custom.warning.contrast": "请先提高颜色对比度再保存。", "settings.appearance.colorScheme.custom.reset": "重置", + "settings.appearance.colorScheme.custom.new": "新建", + "settings.appearance.colorScheme.custom.name": "名称", + "settings.appearance.colorScheme.custom.delete": "删除", + "settings.appearance.colorScheme.custom.deleteConfirm": "删除“{name}”?", "settings.appearance.colorScheme.custom.save": "保存", "settings.appearance.colorScheme.custom.saveError": "无法保存调色板。", "settings.appearance.startup.title": "启动", diff --git a/packages/ui/src/lib/native/preferences-window.ts b/packages/ui/src/lib/native/preferences-window.ts index c29cb7480..f0e1dd84c 100644 --- a/packages/ui/src/lib/native/preferences-window.ts +++ b/packages/ui/src/lib/native/preferences-window.ts @@ -44,15 +44,15 @@ export function readPreferencesRequestFromUrl(url: string): NativePreferencesReq } } -export async function openNativePreferences(request: NativePreferencesRequest): Promise { +export async function openNativePreferences(request: NativePreferencesRequest, toggle = false): Promise { if (runtimeEnv.host === "electron") { const open = window.electronAPI?.openPreferences if (!open) throw new Error("Native Preferences is unavailable") - await open(request.section, { instanceId: request.instanceId, location: request.location }) + await open(request.section, { instanceId: request.instanceId, location: request.location }, toggle) return } if (runtimeEnv.host === "tauri") { - await invoke("open_preferences_window", { request }) + await invoke("open_preferences_window", { request, toggle }) return } throw new Error("Native Preferences is unavailable") diff --git a/packages/ui/src/lib/theme-scheme.test.ts b/packages/ui/src/lib/theme-scheme.test.ts index a5f414304..4e570fe89 100644 --- a/packages/ui/src/lib/theme-scheme.test.ts +++ b/packages/ui/src/lib/theme-scheme.test.ts @@ -3,6 +3,9 @@ import { describe, it } from "node:test" import { BUILT_IN_COLOR_SCHEMES, DEFAULT_CUSTOM_COLORS, + LIGHT_COLOR_SCHEME_COLORS, + SYSTEM_DARK_COLOR_SCHEME_COLORS, + SYSTEM_LIGHT_COLOR_SCHEME_COLORS, applyColorScheme, contrastRatio, normalizeColorScheme, @@ -57,6 +60,15 @@ describe("normalizeColorScheme", () => { assert.equal(normalizeColorScheme({ id: "custom", appearance: "light", colors: DEFAULT_CUSTOM_COLORS }).appearance, "light") assert.equal(normalizeColorScheme({ id: "custom", appearance: "system", colors: DEFAULT_CUSTOM_COLORS }).appearance, "dark") }) + + it("preserves saved colors on an embedded palette", () => { + const colors = { ...LIGHT_COLOR_SCHEME_COLORS, surfaceBase: "#FF00FF" } + assert.deepEqual(normalizeColorScheme({ id: "light", appearance: "light", colors }), { + id: "light", + appearance: "light", + colors, + }) + }) }) describe("built-in color schemes", () => { @@ -75,6 +87,18 @@ describe("built-in color schemes", () => { assert.equal(accents.ember, "#D79A66") }) + it("keeps the additional Zed-inspired palettes light", () => { + for (const id of ["porcelain", "dawn", "parchment"]) { + assert.equal(BUILT_IN_COLOR_SCHEMES.find((scheme) => scheme.id === id)?.appearance, "light") + } + }) + + it("keeps System distinct from CodeNomad Classic", () => { + const classic = BUILT_IN_COLOR_SCHEMES.find((scheme) => scheme.id === "classic")?.colors + assert.notDeepEqual(SYSTEM_DARK_COLOR_SCHEME_COLORS, classic) + assert.notDeepEqual(SYSTEM_LIGHT_COLOR_SCHEME_COLORS, LIGHT_COLOR_SCHEME_COLORS) + }) + it("preserves the exact CodeNomad Classic dark palette", () => { assert.deepEqual(BUILT_IN_COLOR_SCHEMES.find((scheme) => scheme.id === "classic")?.colors, { surfaceBase: "#1A1A1A", @@ -96,20 +120,20 @@ describe("built-in color schemes", () => { }) describe("applyColorScheme", () => { - it("clears stale overrides when switching to system or light", () => { + it("replaces stale overrides when switching to system or light", () => { const root = target() applyColorScheme(normalizeColorScheme("fjord"), { target: root.value }) assert.ok(root.properties.size > 0) assert.equal(root.attributes.get("data-theme"), "dark") applyColorScheme(normalizeColorScheme("system"), { target: root.value, systemDark: true }) - assert.equal(root.properties.size, 0) + assert.equal(root.properties.get("--surface-base"), SYSTEM_DARK_COLOR_SCHEME_COLORS.surfaceBase) assert.equal(root.attributes.has("data-theme"), false) assert.equal(root.value.dataset.colorScheme, "system") applyColorScheme(normalizeColorScheme("ember"), { target: root.value }) applyColorScheme(normalizeColorScheme("light"), { target: root.value }) - assert.equal(root.properties.size, 0) + assert.equal(root.properties.get("--surface-base"), LIGHT_COLOR_SCHEME_COLORS.surfaceBase) assert.equal(root.attributes.get("data-theme"), "light") }) @@ -155,6 +179,19 @@ describe("applyColorScheme", () => { assert.equal(root.properties.get("--session-status-compacting-fg"), colors.compactionAccent) assert.equal(root.properties.get("--session-yolo-accent"), colors.yoloAccent) }) + + it("derives tabs and message surfaces from each palette", () => { + for (const id of ["fjord", "lichen", "velvet", "ember", "porcelain", "dawn", "parchment"] as const) { + const root = target() + const scheme = normalizeColorScheme(id) + applyColorScheme(scheme, { target: root.value }) + assert.equal(root.properties.get("--tab-active-bg"), scheme.colors?.surfaceBase, id) + assert.equal(root.properties.get("--tab-inactive-bg"), scheme.colors?.surfaceSecondary, id) + assert.equal(root.properties.get("--message-user-border"), scheme.colors?.userAccent, id) + assert.equal(root.properties.get("--message-assistant-border"), scheme.colors?.agentAccent, id) + assert.notEqual(root.properties.get("--message-assistant-bg"), "#212529", id) + } + }) }) describe("custom color validation", () => { diff --git a/packages/ui/src/lib/theme-scheme.ts b/packages/ui/src/lib/theme-scheme.ts index 2fa700f0d..4e85a1d61 100644 --- a/packages/ui/src/lib/theme-scheme.ts +++ b/packages/ui/src/lib/theme-scheme.ts @@ -1,4 +1,4 @@ -export const COLOR_SCHEME_IDS = ["system", "light", "classic", "basalt", "fjord", "lichen", "velvet", "ember", "custom"] as const +export const COLOR_SCHEME_IDS = ["system", "light", "porcelain", "dawn", "parchment", "classic", "basalt", "fjord", "lichen", "velvet", "ember", "custom"] as const export type ColorSchemeId = (typeof COLOR_SCHEME_IDS)[number] export type ColorSchemeAppearance = "system" | "light" | "dark" @@ -102,6 +102,40 @@ export const LIGHT_COLOR_SCHEME_COLORS: Readonly = { yoloAccent: "#005FCC", } +export const SYSTEM_LIGHT_COLOR_SCHEME_COLORS: Readonly = { + surfaceBase: "#F7F8FA", + surfaceSecondary: "#E9EBEF", + surfaceMuted: "#DEE2E8", + borderBase: "#C7CDD6", + textPrimary: "#1F2937", + textMuted: "#4B5563", + accentPrimary: "#4F6CCB", + statusSuccess: "#3F7A50", + statusWarning: "#856300", + statusError: "#B83D49", + userAccent: "#356FA8", + agentAccent: "#9A5522", + compactionAccent: "#7153A6", + yoloAccent: "#4F6CCB", +} + +export const SYSTEM_DARK_COLOR_SCHEME_COLORS: Readonly = { + surfaceBase: "#181A1F", + surfaceSecondary: "#22252B", + surfaceMuted: "#2B3038", + borderBase: "#404751", + textPrimary: "#E6E9EF", + textMuted: "#AEB5C0", + accentPrimary: "#6C8EF5", + statusSuccess: "#67B98C", + statusWarning: "#D3A853", + statusError: "#DF767D", + userAccent: "#5FA8E8", + agentAccent: "#D59755", + compactionAccent: "#B58AE4", + yoloAccent: "#6C8EF5", +} + export const BUILT_IN_COLOR_SCHEMES: readonly ColorSchemeDefinition[] = [ { id: "system", @@ -116,6 +150,76 @@ export const BUILT_IN_COLOR_SCHEMES: readonly ColorSchemeDefinition[] = [ descriptionKey: "settings.appearance.colorScheme.description.light", appearance: "light", editable: false, + colors: LIGHT_COLOR_SCHEME_COLORS, + }, + { + id: "porcelain", + labelKey: "settings.appearance.colorScheme.option.porcelain", + descriptionKey: "settings.appearance.colorScheme.description.porcelain", + appearance: "light", + editable: false, + colors: { + surfaceBase: "#FAFAFA", + surfaceSecondary: "#EBEBEC", + surfaceMuted: "#DCDCDD", + borderBase: "#C9C9CA", + textPrimary: "#242529", + textMuted: "#58585A", + accentPrimary: "#4F69C6", + statusSuccess: "#4F7D45", + statusWarning: "#806A14", + statusError: "#B7473B", + userAccent: "#3F6FAE", + agentAccent: "#94551F", + compactionAccent: "#7350A2", + yoloAccent: "#4F69C6", + }, + }, + { + id: "dawn", + labelKey: "settings.appearance.colorScheme.option.dawn", + descriptionKey: "settings.appearance.colorScheme.description.dawn", + appearance: "light", + editable: false, + colors: { + surfaceBase: "#FCFCFC", + surfaceSecondary: "#ECECED", + surfaceMuted: "#DCDFE2", + borderBase: "#CFD1D2", + textPrimary: "#3F4449", + textMuted: "#5E6368", + accentPrimary: "#287DB5", + statusSuccess: "#567D00", + statusWarning: "#855B00", + statusError: "#B94747", + userAccent: "#287DB5", + agentAccent: "#95501C", + compactionAccent: "#67529A", + yoloAccent: "#287DB5", + }, + }, + { + id: "parchment", + labelKey: "settings.appearance.colorScheme.option.parchment", + descriptionKey: "settings.appearance.colorScheme.description.parchment", + appearance: "light", + editable: false, + colors: { + surfaceBase: "#FBF1C7", + surfaceSecondary: "#ECDBB5", + surfaceMuted: "#DFCDA7", + borderBase: "#C8B899", + textPrimary: "#282828", + textMuted: "#5F5650", + accentPrimary: "#0B6678", + statusSuccess: "#5E650C", + statusWarning: "#8F500F", + statusError: "#9D0308", + userAccent: "#0B6678", + agentAccent: "#91460D", + compactionAccent: "#684683", + yoloAccent: "#0B6678", + }, }, { id: "classic", @@ -146,7 +250,12 @@ export const BUILT_IN_COLOR_SCHEMES: readonly ColorSchemeDefinition[] = [ descriptionKey: "settings.appearance.colorScheme.description.basalt", appearance: "dark", editable: false, - colors: DEFAULT_CUSTOM_COLORS, + colors: { + ...DEFAULT_CUSTOM_COLORS, + userAccent: "#75A7E8", + agentAccent: "#E3A765", + compactionAccent: "#C493EE", + }, }, { id: "fjord", @@ -165,7 +274,9 @@ export const BUILT_IN_COLOR_SCHEMES: readonly ColorSchemeDefinition[] = [ statusSuccess: "#72C497", statusWarning: "#D8B36A", statusError: "#E28181", - ...DEFAULT_SEMANTIC_COLORS, + userAccent: "#67C9BA", + agentAccent: "#D6A25F", + compactionAccent: "#A99BE8", yoloAccent: "#67C9BA", }, }, @@ -186,7 +297,9 @@ export const BUILT_IN_COLOR_SCHEMES: readonly ColorSchemeDefinition[] = [ statusSuccess: "#77C49A", statusWarning: "#D6B36D", statusError: "#DF8580", - ...DEFAULT_SEMANTIC_COLORS, + userAccent: "#A9C47F", + agentAccent: "#D39B67", + compactionAccent: "#C6A0D8", yoloAccent: "#A9C47F", }, }, @@ -207,7 +320,9 @@ export const BUILT_IN_COLOR_SCHEMES: readonly ColorSchemeDefinition[] = [ statusSuccess: "#78C59A", statusWarning: "#DDB46F", statusError: "#E28787", - ...DEFAULT_SEMANTIC_COLORS, + userAccent: "#D78BC8", + agentAccent: "#E5A77D", + compactionAccent: "#B697E8", yoloAccent: "#E5A77D", }, }, @@ -228,7 +343,9 @@ export const BUILT_IN_COLOR_SCHEMES: readonly ColorSchemeDefinition[] = [ statusSuccess: "#78C296", statusWarning: "#D8AE62", statusError: "#DE817A", - ...DEFAULT_SEMANTIC_COLORS, + userAccent: "#D79A66", + agentAccent: "#D6C17A", + compactionAccent: "#C391DB", yoloAccent: "#D79A66", }, }, @@ -274,7 +391,7 @@ function selectionFor( return { id, appearance, - ...(definition.colors ? { colors: copyColors(colors ?? definition.colors) } : {}), + ...(colors || definition.colors ? { colors: copyColors(colors ?? definition.colors!) } : {}), } } @@ -283,7 +400,10 @@ export function normalizeColorScheme(value: unknown, legacyTheme?: unknown): Nor if (typeof id === "string" && COLOR_SCHEME_IDS.includes(id as ColorSchemeId)) { const schemeId = id as ColorSchemeId - if (schemeId !== "custom") return selectionFor(schemeId) + if (schemeId !== "custom") { + const colors = isRecord(value) ? normalizeColors(value.colors) : undefined + return selectionFor(schemeId, colors) + } const colors = isRecord(value) ? normalizeColors(value.colors) ?? DEFAULT_CUSTOM_COLORS : DEFAULT_CUSTOM_COLORS const appearance = isRecord(value) && value.appearance === "light" ? "light" : "dark" return selectionFor("custom", colors, appearance) @@ -401,10 +521,24 @@ const APPLIED_PROPERTIES = [ "--status-error-fg", "--message-user-bg", "--message-user-border", + "--message-assistant-bg", "--message-assistant-border", + "--message-tool-bg", + "--message-tool-border", "--session-status-compacting-fg", "--session-status-compacting-bg", "--session-yolo-accent", + "--tab-active-bg", + "--tab-active-hover-bg", + "--tab-active-text", + "--tab-inactive-bg", + "--tab-inactive-hover-bg", + "--tab-inactive-text", + "--tab-rail-bg", + "--tab-border", + "--new-tab-bg", + "--new-tab-hover-bg", + "--new-tab-text", ] as const function derivedProperties(colors: ColorSchemeColors, dark: boolean): Record<(typeof APPLIED_PROPERTIES)[number], string> { @@ -460,10 +594,24 @@ function derivedProperties(colors: ColorSchemeColors, dark: boolean): Record<(ty "--status-error-fg": colors.statusError, "--message-user-bg": mix(colors.userAccent, colors.surfaceSecondary, dark ? 0.1 : 0.12), "--message-user-border": colors.userAccent, + "--message-assistant-bg": mix(colors.surfaceMuted, colors.surfaceBase, dark ? 0.55 : 0.45), "--message-assistant-border": colors.agentAccent, + "--message-tool-bg": mix(colors.surfaceMuted, colors.surfaceBase, dark ? 0.55 : 0.45), + "--message-tool-border": mix(colors.textMuted, colors.borderBase, 0.28), "--session-status-compacting-fg": colors.compactionAccent, "--session-status-compacting-bg": alpha(colors.compactionAccent, dark ? 0.28 : 0.18), "--session-yolo-accent": colors.yoloAccent, + "--tab-active-bg": colors.surfaceBase, + "--tab-active-hover-bg": mix(colors.textPrimary, colors.surfaceBase, dark ? 0.06 : 0.04), + "--tab-active-text": colors.textPrimary, + "--tab-inactive-bg": colors.surfaceSecondary, + "--tab-inactive-hover-bg": colors.surfaceMuted, + "--tab-inactive-text": colors.textMuted, + "--tab-rail-bg": colors.surfaceSecondary, + "--tab-border": colors.borderBase, + "--new-tab-bg": colors.surfaceSecondary, + "--new-tab-hover-bg": colors.surfaceMuted, + "--new-tab-text": colors.textMuted, } } @@ -481,8 +629,11 @@ export function applyColorScheme( if (scheme.appearance === "system") target.removeAttribute("data-theme") else target.setAttribute("data-theme", dark ? "dark" : "light") - if (scheme.colors && scheme.id !== "classic") { - for (const [property, value] of Object.entries(derivedProperties(scheme.colors, dark))) { + const colors = scheme.id === "system" + ? scheme.colors ?? (dark ? SYSTEM_DARK_COLOR_SCHEME_COLORS : SYSTEM_LIGHT_COLOR_SCHEME_COLORS) + : scheme.colors + if (colors && scheme.id !== "classic") { + for (const [property, value] of Object.entries(derivedProperties(colors, dark))) { target.style.setProperty(property, value) } } diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 7aadfdce4..b8e8cd334 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -11,14 +11,16 @@ import { getLogger } from "../lib/logger" import { loadSpeechCapabilities, resetSpeechCapabilities } from "./speech" import { buildSpeechPatch } from "../lib/speech-patch" import { + isColorSchemeColors, normalizeColorScheme, - validateColorSchemeColors, type ColorSchemeColors, + type ColorSchemeId, type NormalizedColorScheme, } from "../lib/theme-scheme" import { createColorSchemePresetId, MAX_COLOR_SCHEME_PRESETS, + normalizeColorSchemeOverrides, normalizeColorSchemePresets, type UserColorSchemePresets, } from "../lib/color-scheme-presets" @@ -168,6 +170,7 @@ interface UiStateBucket { theme?: ThemePreference colorScheme?: unknown customColorScheme?: unknown + colorSchemeOverrides?: unknown colorSchemePresets?: unknown activeColorSchemePresetId?: string recentFolders?: RecentFolder[] @@ -583,7 +586,13 @@ const [isLoaded, setIsLoaded] = createSignal(false) const uiSettings = createMemo(() => normalizeUiSettings(uiConfigBucket().settings)) const themePreference = createMemo(() => uiStateBucket().theme ?? uiConfigBucket().theme ?? "system") -const colorSchemePreference = createMemo(() => normalizeColorScheme(uiStateBucket().colorScheme ?? uiConfigBucket().colorScheme, themePreference())) +const colorSchemeOverrides = createMemo(() => normalizeColorSchemeOverrides(uiStateBucket().colorSchemeOverrides)) +const colorSchemePreference = createMemo(() => { + const scheme = normalizeColorScheme(uiStateBucket().colorScheme ?? uiConfigBucket().colorScheme, themePreference()) + if (scheme.id === "custom") return scheme + const colors = colorSchemeOverrides()[scheme.id] + return colors ? normalizeColorScheme({ ...scheme, colors }) : scheme +}) const customColorSchemePreference = createMemo(() => { const state = uiStateBucket() const config = uiConfigBucket() @@ -763,23 +772,49 @@ function selectColorSchemePreset(id: string): Promise { const write = colorSchemeWriteQueue.then(() => patchStateOwner("ui", { theme: preset.appearance, colorScheme: scheme, - customColorScheme: scheme, activeColorSchemePresetId: id, })) colorSchemeWriteQueue = write.then(() => undefined, () => undefined) return write.then(() => undefined) } -function saveColorSchemePreset(name: string, appearance: "light" | "dark", colors: Readonly): Promise { +function saveColorSchemeOverride(id: Exclude, appearance: "light" | "dark", colors: Readonly): Promise { + if (!isColorSchemeColors(colors)) return Promise.reject(new Error("Invalid color scheme override")) + const scheme = normalizeColorScheme({ id, appearance, colors }) + const legacyTheme: ThemePreference = scheme.appearance === "system" ? "system" : scheme.appearance + const write = colorSchemeWriteQueue.then(() => patchStateOwner("ui", { + theme: legacyTheme, + colorScheme: scheme, + colorSchemeOverrides: { [id]: { ...colors } }, + activeColorSchemePresetId: null, + })) + colorSchemeWriteQueue = write.then(() => undefined, () => undefined) + return write.then(() => undefined) +} + +function resetColorSchemeOverride(id: Exclude): Promise { + const scheme = normalizeColorScheme(id) + const legacyTheme: ThemePreference = scheme.appearance === "system" ? "system" : scheme.appearance + const write = colorSchemeWriteQueue.then(() => patchStateOwner("ui", { + theme: legacyTheme, + colorScheme: scheme, + colorSchemeOverrides: { [id]: null }, + activeColorSchemePresetId: null, + })) + colorSchemeWriteQueue = write.then(() => undefined, () => undefined) + return write.then(() => undefined) +} + +function saveColorSchemePreset(name: string, appearance: "light" | "dark", colors: Readonly, presetId?: string): Promise { const trimmedName = name.trim().slice(0, 80) - if (!trimmedName || !validateColorSchemeColors(colors)) return Promise.reject(new Error("Invalid color scheme preset")) - if (Object.keys(colorSchemePresets()).length >= MAX_COLOR_SCHEME_PRESETS) return Promise.reject(new Error("Color scheme preset limit reached")) - const id = createColorSchemePresetId() + if (!trimmedName || !isColorSchemeColors(colors)) return Promise.reject(new Error("Invalid color scheme preset")) + if (!presetId && Object.keys(colorSchemePresets()).length >= MAX_COLOR_SCHEME_PRESETS) return Promise.reject(new Error("Color scheme preset limit reached")) + if (presetId && !colorSchemePresets()[presetId]) return Promise.reject(new Error("Unknown color scheme preset")) + const id = presetId ?? createColorSchemePresetId() const scheme = normalizeColorScheme({ id: "custom", appearance, colors }) const write = colorSchemeWriteQueue.then(() => patchStateOwner("ui", { theme: appearance, colorScheme: scheme, - customColorScheme: scheme, colorSchemePresets: { [id]: { name: trimmedName, appearance, colors: { ...colors } } }, activeColorSchemePresetId: id, })) @@ -787,6 +822,16 @@ function saveColorSchemePreset(name: string, appearance: "light" | "dark", color return write.then(() => id) } +function deleteColorSchemePreset(id: string): Promise { + if (!colorSchemePresets()[id]) return Promise.resolve() + const write = colorSchemeWriteQueue.then(() => patchStateOwner("ui", { + colorSchemePresets: { [id]: null }, + ...(activeColorSchemePresetId() === id ? { activeColorSchemePresetId: null } : {}), + })) + colorSchemeWriteQueue = write.then(() => undefined, () => undefined) + return write.then(() => undefined) +} + async function setListeningMode(mode: ListeningMode): Promise { if (serverSettings().listeningMode === mode) return await patchConfigOwner("server", { listeningMode: mode }) @@ -1103,11 +1148,15 @@ interface ConfigContextValue { setThemePreference: typeof setThemePreference colorSchemePreference: typeof colorSchemePreference customColorSchemePreference: typeof customColorSchemePreference + colorSchemeOverrides: typeof colorSchemeOverrides colorSchemePresets: typeof colorSchemePresets activeColorSchemePresetId: typeof activeColorSchemePresetId setColorSchemePreference: typeof setColorSchemePreference selectColorSchemePreset: typeof selectColorSchemePreset + saveColorSchemeOverride: typeof saveColorSchemeOverride + resetColorSchemeOverride: typeof resetColorSchemeOverride saveColorSchemePreset: typeof saveColorSchemePreset + deleteColorSchemePreset: typeof deleteColorSchemePreset // server-owned stable config serverSettings: typeof serverSettings @@ -1174,11 +1223,15 @@ const configContextValue: ConfigContextValue = { setThemePreference, colorSchemePreference, customColorSchemePreference, + colorSchemeOverrides, colorSchemePresets, activeColorSchemePresetId, setColorSchemePreference, selectColorSchemePreset, + saveColorSchemeOverride, + resetColorSchemeOverride, saveColorSchemePreset, + deleteColorSchemePreset, serverSettings, setListeningMode, updateEnvironmentVariables, @@ -1273,11 +1326,15 @@ export { setThemePreference, colorSchemePreference, customColorSchemePreference, + colorSchemeOverrides, colorSchemePresets, activeColorSchemePresetId, setColorSchemePreference, selectColorSchemePreset, + saveColorSchemeOverride, + resetColorSchemeOverride, saveColorSchemePreset, + deleteColorSchemePreset, updatePreferences, setProviderModelVisibility, getProviderModelVisibilityPreference, diff --git a/packages/ui/src/stores/settings-screen.ts b/packages/ui/src/stores/settings-screen.ts index ed1230167..c54116480 100644 --- a/packages/ui/src/stores/settings-screen.ts +++ b/packages/ui/src/stores/settings-screen.ts @@ -2,6 +2,7 @@ import { createSignal } from "solid-js" import { runtimeEnv } from "../lib/runtime-env" import { openNativePreferences, type NativePreferencesRequest } from "../lib/native/preferences-window" import { getLogger } from "../lib/logger" +import { confirmSettingsDiscard } from "./settings-dirty-guard" export type SettingsSectionId = | "general" @@ -20,7 +21,11 @@ const [settingsOpen, setSettingsOpen] = createSignal(false) const [activeSettingsSection, setActiveSettingsSection] = createSignal("general") const log = getLogger("actions") -export async function openSettings(section: SettingsSectionId = "general") { +export async function openSettings(section: SettingsSectionId = "general", toggle = false) { + if (toggle && settingsOpen()) { + if (await confirmSettingsDiscard()) setSettingsOpen(false) + return + } setActiveSettingsSection(section) if ((runtimeEnv.host === "electron" || runtimeEnv.host === "tauri") && runtimeEnv.windowContext === "local") { try { @@ -31,7 +36,7 @@ export async function openSettings(section: SettingsSectionId = "general") { const { getActiveCatalogLocation } = await import("./sessions") request.location = getActiveCatalogLocation(instanceId) } - await openNativePreferences(request) + await openNativePreferences(request, toggle) return } catch (error) { log.warn("Native Preferences failed; opening settings in this window", error) @@ -40,6 +45,8 @@ export async function openSettings(section: SettingsSectionId = "general") { setSettingsOpen(true) } +export const toggleSettings = (section: SettingsSectionId = "general") => openSettings(section, true) + export function closeSettings() { setSettingsOpen(false) } diff --git a/packages/ui/src/styles/components/native-titlebar.css b/packages/ui/src/styles/components/native-titlebar.css index 196542c57..cfc7fe192 100644 --- a/packages/ui/src/styles/components/native-titlebar.css +++ b/packages/ui/src/styles/components/native-titlebar.css @@ -4,8 +4,8 @@ flex: 0 0 var(--chrome-height); min-width: 0; height: var(--chrome-height); - border-bottom: 1px solid var(--border-divider); - background: var(--surface-chrome); + border-bottom: 0; + background: var(--surface-shade-strong); color: var(--text-secondary); user-select: none; -webkit-app-region: drag; diff --git a/packages/ui/src/styles/components/theme-scheme-settings.css b/packages/ui/src/styles/components/theme-scheme-settings.css index c367f7a51..0a3c25dc8 100644 --- a/packages/ui/src/styles/components/theme-scheme-settings.css +++ b/packages/ui/src/styles/components/theme-scheme-settings.css @@ -3,40 +3,38 @@ } .theme-scheme-list { - display: grid; - gap: 1px; - border: 1px solid var(--border-base); - background: var(--border-base); + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.theme-scheme-toolbar { + display: flex; + align-items: stretch; + gap: var(--space-xs); } .theme-scheme-card { - display: grid; - grid-template-columns: minmax(8rem, 0.4fr) minmax(12rem, 1fr); - align-items: center; - min-height: 2.25rem; + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--space-xs); + padding: var(--space-sm); + border: 1px solid var(--border-base); background: var(--surface-base); } -.theme-scheme-card[data-selected="true"] { - box-shadow: inset 2px 0 0 var(--accent-primary); - background: color-mix(in oklab, var(--accent-primary) 8%, var(--surface-base)); +.theme-scheme-picker { + flex: 1; } -.theme-scheme-select { - display: flex; - align-items: center; - min-width: 0; - height: 100%; - gap: var(--space-xs); - padding: var(--space-xs) var(--space-sm); - border: 0; - background: transparent; - color: var(--text-primary); - text-align: start; +.theme-scheme-picker:focus { + border-color: var(--border-base); + box-shadow: none; } -.theme-scheme-select:hover { - background: var(--surface-hover); +.theme-scheme-picker:focus-visible { + background: var(--surface-secondary); } .theme-scheme-name { @@ -48,30 +46,29 @@ white-space: nowrap; } -.theme-scheme-check { - width: 0.875rem; - height: 0.875rem; - margin-inline-start: auto; - color: var(--accent-primary); - opacity: 0; -} - -.theme-scheme-card[data-selected="true"] .theme-scheme-check { - opacity: 1; +.theme-scheme-swatches { + display: flex; + align-items: flex-start; + justify-content: flex-start; + flex-wrap: wrap; + gap: var(--space-xs); } -.theme-scheme-swatches { +.theme-scheme-swatch { display: flex; + width: 3.75rem; + flex-direction: column; align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - gap: 2px; - padding: 3px var(--space-sm); + gap: 3px; + color: var(--text-muted); + font-size: 0.625rem; + line-height: 1.1; + text-align: center; } .theme-scheme-swatches input[type="color"] { - width: 1.1rem; - height: 1.1rem; + width: 2.75rem; + height: 2.75rem; flex: 0 0 auto; padding: 0; border: 1px solid color-mix(in oklab, var(--border-base) 75%, transparent); @@ -97,6 +94,12 @@ margin-top: var(--space-sm); } +.theme-scheme-swatches input:focus-visible, +.theme-scheme-actions .selector-button:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 1px; +} + .theme-scheme-appearance-options { display: flex; gap: 1px; @@ -105,6 +108,7 @@ } .theme-scheme-appearance-option { + min-width: 4.25rem; padding: 0.35rem 0.65rem; border: 0; border-radius: 0; @@ -114,18 +118,27 @@ } .theme-scheme-appearance-option[data-selected="true"] { - background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-base)); + background: color-mix(in oklab, var(--accent-primary) 12%, var(--surface-base)); color: var(--text-primary); } -.theme-scheme-select:focus-visible, -.theme-scheme-swatches input:focus-visible, -.theme-scheme-appearance-option:focus-visible, -.theme-scheme-actions .selector-button:focus-visible { +.theme-scheme-appearance-option:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 1px; } +.theme-scheme-name-editor { + display: flex; + align-items: center; + gap: var(--space-sm); + color: var(--text-muted); + font-size: var(--font-size-xs); +} + +.theme-scheme-name-editor .selector-input { + flex: 1; +} + .theme-scheme-warning { margin: 0; color: var(--status-error); @@ -144,13 +157,9 @@ } @media (max-width: 640px) { - .theme-scheme-card { - grid-template-columns: 1fr; - } - - .theme-scheme-swatches { - justify-content: flex-start; - padding-top: 0; + .theme-scheme-toolbar { + align-items: stretch; + flex-direction: column; } .theme-scheme-actions { diff --git a/packages/ui/src/styles/messaging/delete-overlays.css b/packages/ui/src/styles/messaging/delete-overlays.css index fd04b4c06..c15fb2b19 100644 --- a/packages/ui/src/styles/messaging/delete-overlays.css +++ b/packages/ui/src/styles/messaging/delete-overlays.css @@ -16,7 +16,7 @@ content: ""; position: absolute; inset: -4px; - background: var(--status-error-bg); + background: color-mix(in srgb, var(--status-error-bg) 50%, transparent); border-radius: 0; pointer-events: none; /* Overlay must sit above the part card background. */ @@ -24,8 +24,12 @@ } .message-technical-group[data-delete-technical-selected="true"] > .message-technical-group-toggle, .message-technical-group[data-delete-technical-selected="true"] > .message-technical-group-header { - background: var(--status-error-bg); + background: color-mix(in srgb, var(--status-error-bg) 50%, transparent); } .message-reasoning-card[data-delete-part-hover="true"]::before { inset: 0; } + +.delete-hover-scope[data-delete-part-hover="true"] :is(.tool-call-header, .message-reasoning-header, .message-technical-group-toggle) { + background-color: transparent; +} diff --git a/packages/ui/src/styles/messaging/message-base.css b/packages/ui/src/styles/messaging/message-base.css index 6b676ed7b..706df9f92 100644 --- a/packages/ui/src/styles/messaging/message-base.css +++ b/packages/ui/src/styles/messaging/message-base.css @@ -549,7 +549,7 @@ } .message-reasoning-header:hover { - background-color: var(--surface-hover); + background-color: var(--message-part-hover); } .message-reasoning-toggle { diff --git a/packages/ui/src/styles/messaging/message-section.css b/packages/ui/src/styles/messaging/message-section.css index 83d9f7078..8cf298b65 100644 --- a/packages/ui/src/styles/messaging/message-section.css +++ b/packages/ui/src/styles/messaging/message-section.css @@ -216,32 +216,9 @@ } .message-scroll-controls { - position: relative; display: flex; - flex-direction: column; - align-items: flex-end; - width: 1.75rem; - min-height: 1.75rem; -} - -.message-scroll-controls-expanded { - position: absolute; - inset-inline-end: 0; - bottom: 0; - display: none; - flex-direction: column; + flex-direction: row; gap: 0.25rem; - align-items: flex-end; -} - -.message-scroll-controls:not([data-hover-suppressed="true"]):hover .message-scroll-controls-trigger, -.message-scroll-controls[data-open="true"] .message-scroll-controls-trigger { - display: none; -} - -.message-scroll-controls:not([data-hover-suppressed="true"]):hover .message-scroll-controls-expanded, -.message-scroll-controls[data-open="true"] .message-scroll-controls-expanded { - display: flex; } .message-scroll-button { @@ -271,10 +248,6 @@ color: var(--accent-primary); } -.message-scroll-button[data-active="false"] .message-scroll-icon--toggle { - color: var(--text-secondary); -} - .message-quote-popover { position: absolute; z-index: 5; diff --git a/packages/ui/src/styles/messaging/technical-groups.css b/packages/ui/src/styles/messaging/technical-groups.css index 0d736178b..af926f0e4 100644 --- a/packages/ui/src/styles/messaging/technical-groups.css +++ b/packages/ui/src/styles/messaging/technical-groups.css @@ -21,7 +21,7 @@ } .message-technical-group-toggle:hover { - background-color: var(--surface-hover); + background-color: var(--message-part-hover); } .message-technical-group-toggle:focus-visible { diff --git a/packages/ui/src/styles/messaging/tool-call.css b/packages/ui/src/styles/messaging/tool-call.css index 653253032..0658a4321 100644 --- a/packages/ui/src/styles/messaging/tool-call.css +++ b/packages/ui/src/styles/messaging/tool-call.css @@ -158,7 +158,7 @@ } .tool-call-header:hover { - background-color: var(--surface-hover); + background-color: var(--message-part-hover); } .tool-call-header-toggle { diff --git a/packages/ui/src/styles/messaging/virtual-follow-list.css b/packages/ui/src/styles/messaging/virtual-follow-list.css index 5ba40a551..80231fdc3 100644 --- a/packages/ui/src/styles/messaging/virtual-follow-list.css +++ b/packages/ui/src/styles/messaging/virtual-follow-list.css @@ -43,7 +43,7 @@ .virtual-follow-list-controls-container { position: absolute; - bottom: calc(var(--space-sm) + env(safe-area-inset-bottom, 0px)); + bottom: calc(var(--space-xs) + env(safe-area-inset-bottom, 0px)); left: 50%; transform: translateX(-50%); z-index: 20; diff --git a/packages/ui/src/styles/panels/panel-shell.css b/packages/ui/src/styles/panels/panel-shell.css index fe45645ed..353c260eb 100644 --- a/packages/ui/src/styles/panels/panel-shell.css +++ b/packages/ui/src/styles/panels/panel-shell.css @@ -16,7 +16,7 @@ .panel-header { @apply px-3 py-2 border-b; border-color: var(--border-base); - background-color: var(--surface-secondary); + background-color: var(--surface-shade-light); } .panel-title { diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css index c7826df96..687259d3d 100644 --- a/packages/ui/src/styles/panels/right-panel.css +++ b/packages/ui/src/styles/panels/right-panel.css @@ -4,25 +4,27 @@ .right-panel-tab-bar { flex: 0 0 var(--panel-header-height); min-height: var(--panel-header-height); - background-color: var(--surface-secondary); - border-bottom: 1px solid var(--border-base); + background-color: var(--surface-shade-light); + border-bottom: 0; position: relative; padding-inline: 0; + padding-block: 0; } .right-panel-tab-bar::after { - content: ''; + content: ""; position: absolute; - bottom: -1px; - inset-inline-start: 0; - inset-inline-end: 0; + inset-inline: 0; + bottom: 0; height: 1px; - background-color: var(--border-base); + background-color: var(--tab-border); z-index: 0; } .right-panel-tab-bar .tab-container { @apply flex items-center justify-between gap-1 px-2 pt-2 pb-0; + align-items: flex-end; + height: 100%; } /* Keep the shortcuts (close/pin) fixed; only the tabs should scroll. */ @@ -44,10 +46,10 @@ font-family: var(--font-family-sans); cursor: grab; outline: none; - border: 1px solid transparent; - border-bottom: none; - border-radius: 8px 8px 0 0; - margin-inline-end: 2px; + border: 0; + border-inline-end: 1px solid var(--tab-border); + border-bottom: 1px solid var(--tab-border); + border-radius: 0; z-index: 1; } @@ -66,25 +68,27 @@ } .right-panel-tab-active { - background-color: var(--tab-active-bg); - border-color: transparent; - border-bottom: 2px solid var(--accent-primary); + background-color: var(--surface-secondary); + border-top: 1px solid var(--tab-border); + border-bottom: 0; color: var(--tab-active-text); z-index: 2; } +.right-panel-tab-draggable:first-child .right-panel-tab-active { + border-inline-start: 1px solid var(--tab-border); +} + .right-panel-tab-inactive { - background-color: var(--tab-inactive-bg); - color: color-mix(in oklab, var(--tab-inactive-text) 62%, transparent); - border-color: transparent; - border-bottom: 2px solid var(--tab-active-bg); + background-color: var(--surface-shade-light); + border-inline-end-color: var(--tab-border); + border-bottom-color: var(--tab-border); + color: var(--tab-inactive-text); } .right-panel-tab-inactive:hover { background-color: var(--tab-inactive-hover-bg); color: var(--tab-inactive-text); - border-color: var(--border-base); - border-bottom-color: transparent; } /* Files tab layout */ @@ -745,13 +749,12 @@ } .status-process-card { - @apply border px-2 py-2 transition-colors duration-150; - border-color: var(--border-base); + @apply px-2 py-2 transition-colors duration-150; background-color: var(--surface-base); } .status-process-card:hover { - border-color: var(--border-strong); + background-color: var(--surface-hover); box-shadow: none; } @@ -771,6 +774,13 @@ .right-panel-accordion-header-row { @apply flex items-center gap-2; + color: var(--text-secondary); + transition: color 150ms, background-color 150ms; +} + +.right-panel-accordion-header-row:hover { + background-color: var(--surface-hover); + color: var(--text-primary); } .right-panel-accordion-header-row .right-panel-accordion-trigger { @@ -788,7 +798,7 @@ } .right-panel-accordion-trigger:hover { - background-color: var(--surface-hover); + background-color: transparent; color: var(--text-primary); } @@ -800,6 +810,8 @@ .right-panel-accordion-chevron { @apply h-4 w-4; color: var(--text-muted); + opacity: var(--icon-button-idle-opacity); + transition: color 150ms, opacity 150ms, transform 150ms ease; } .right-panel-section-grip { @@ -808,7 +820,15 @@ flex: 0 0 auto; margin-inline-end: 0.75rem; color: var(--text-muted); + opacity: var(--icon-button-idle-opacity); cursor: grab; + transition: color 150ms, opacity 150ms; +} + +.right-panel-accordion-trigger:hover .right-panel-accordion-chevron, +.right-panel-section-grip:hover { + color: var(--text-secondary); + opacity: 1; } .right-panel-accordion-content { @@ -903,17 +923,28 @@ flex-shrink: 0; border: none; background-color: transparent; + opacity: var(--icon-button-idle-opacity); + transition: color 150ms, opacity 150ms; } .section-info-trigger:hover { - color: var(--text-primary); - background-color: var(--surface-hover); + color: var(--text-secondary); + opacity: 1; } .section-info-trigger:focus-visible { @apply ring-2 ring-offset-1; ring-color: var(--accent-primary); ring-offset-color: var(--surface-secondary); + opacity: 1; +} + +.right-panel-accordion-header-row .section-info-trigger:hover { + opacity: 1; +} + +.status-tab-container .right-panel-accordion-content .border { + border-width: 0; } .section-label { @@ -921,7 +952,13 @@ } .section-info-icon { - @apply w-3.5 h-3.5; + @apply w-3 h-3; +} + +@media (hover: none) { + .section-info-trigger { + opacity: 0.7; + } } .section-info-tooltip { diff --git a/packages/ui/src/styles/panels/session-layout.css b/packages/ui/src/styles/panels/session-layout.css index c7e18e27d..6e8de52df 100644 --- a/packages/ui/src/styles/panels/session-layout.css +++ b/packages/ui/src/styles/panels/session-layout.css @@ -105,6 +105,9 @@ .session-sidebar-header { @apply flex flex-col w-full; + flex: 0 0 var(--panel-header-height); + height: var(--panel-header-height); + min-height: var(--panel-header-height); gap: 6px; justify-content: center; padding: var(--space-sm) var(--space-md); @@ -259,7 +262,7 @@ flex-shrink: 0; gap: var(--space-sm); padding-block: calc(var(--space-xl) + var(--space-sm)); - background-color: var(--surface-secondary); + background-color: var(--surface-shade-light); } .session-sidebar-controls > * { @@ -683,6 +686,7 @@ .session-list-footer { @apply border-t; border-color: var(--border-base); + background-color: var(--surface-shade-light); } .session-new-button { diff --git a/packages/ui/src/styles/panels/tabs.css b/packages/ui/src/styles/panels/tabs.css index 5b1941fba..67f0b09b2 100644 --- a/packages/ui/src/styles/panels/tabs.css +++ b/packages/ui/src/styles/panels/tabs.css @@ -27,6 +27,18 @@ flex: 1 1 auto; min-width: 0; width: auto; + overflow-y: hidden; + padding-top: 1px; + margin-bottom: -1px; + transform: scaleY(-1); +} + +.tab-bar-instance .tab-container { + padding-block: 0; +} + +.tab-bar-instance .tab-strip { + transform: scaleY(-1); } .tab-bar-actions { @@ -50,7 +62,7 @@ } .tab-strip-tabs { - @apply flex items-center gap-1 flex-shrink-0; + @apply flex items-center flex-shrink-0; } .tab-draggable { @@ -93,9 +105,13 @@ } .tab-base { - @apply inline-flex items-center gap-2 px-3 py-2 rounded-t-md max-w-[200px] transition-colors text-sm font-normal; + @apply inline-flex items-center gap-2 px-3 py-2 max-w-[200px] transition-colors text-sm font-normal; font-family: var(--font-family-sans); outline: none; + border: 0; + border-inline-end: 1px solid var(--tab-border); + border-bottom: 1px solid var(--tab-border); + border-radius: 0; } .tab-base:focus-visible { @@ -107,13 +123,42 @@ .tab-active { background-color: var(--tab-active-bg); color: var(--tab-active-text); - border-bottom: 2px solid var(--accent-primary); + border-top: 1px solid var(--tab-border); + border-bottom: 0; +} + +.tab-bar-instance .tab-active, +.tab-bar-instance .tab-active:hover { + background-color: var(--surface-shade-light); + border-bottom-color: var(--surface-shade-light); +} + +.tab-bar-instance .tab-active { + position: relative; +} + +.tab-bar-instance .tab-draggable:first-child :is(.tab-active, .tab-pill-active) { + border-inline-start: 1px solid var(--tab-border); +} + +.tab-bar-instance .tab-active::after { + content: ""; + position: absolute; + inset-inline: 0; + bottom: -1px; + height: 1px; + background: inherit; } .tab-inactive { background-color: var(--tab-inactive-bg); - color: color-mix(in oklab, var(--tab-inactive-text) 62%, transparent); - border-bottom: 2px solid var(--tab-active-bg); + border-inline-end-color: var(--tab-border); + border-bottom-color: transparent; + color: var(--tab-inactive-text); +} + +.tab-bar-instance .tab-inactive { + background-color: var(--surface-secondary); } .tab-inactive:hover { @@ -130,7 +175,7 @@ } .tab-close { - @apply opacity-100 hover:bg-red-500 hover:text-white rounded p-0.5 transition-all cursor-pointer; + @apply opacity-100 hover:bg-red-500 hover:text-white p-0.5 transition-all cursor-pointer; } .tab-close:focus-visible { @@ -189,7 +234,7 @@ } .new-tab-button { - @apply inline-flex items-center justify-center w-8 h-8 flex-shrink-0 rounded-md transition-colors; + @apply inline-flex items-center justify-center w-8 h-8 flex-shrink-0 transition-colors; background-color: var(--new-tab-bg); color: var(--new-tab-text); } @@ -216,10 +261,13 @@ } .tab-pill { - @apply inline-flex items-center gap-1 px-3 py-2 rounded-t-md max-w-[220px] text-sm; + @apply inline-flex items-center gap-1 px-3 py-2 max-w-[220px] text-sm; background-color: var(--tab-inactive-bg); color: var(--tab-inactive-text); - border-bottom: 2px solid var(--tab-active-bg); + border: 0; + border-inline-end: 1px solid var(--tab-border); + border-bottom: 1px solid transparent; + border-radius: 0; outline: none; } @@ -232,7 +280,8 @@ .tab-pill-active { background-color: var(--tab-active-bg); color: var(--tab-active-text); - border-bottom-color: var(--accent-primary); + border-top: 1px solid var(--tab-border); + border-bottom: 0; } .tab-pill-button { @@ -240,7 +289,7 @@ } .tab-pill-close { - @apply inline-flex items-center justify-center rounded w-5 h-5 text-xs; + @apply inline-flex items-center justify-center w-5 h-5 text-xs; } .tab-pill-close:hover { @@ -249,7 +298,7 @@ /* Session tabs */ .session-tab-base { - @apply inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm; + @apply inline-flex items-center gap-2 px-3 py-1.5 max-w-[150px] transition-colors text-sm; font-family: var(--font-family-sans); outline: none; border-bottom: 2px solid transparent; diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index 205308d44..2e9a3c0e2 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -5,6 +5,8 @@ --surface-primary: var(--surface-base); --surface-secondary: #f5f5f5; --surface-muted: #f8fafc; + --surface-shade-light: color-mix(in oklab, var(--surface-secondary) 74%, var(--surface-base)); + --surface-shade-strong: color-mix(in oklab, var(--surface-secondary) 42%, var(--surface-base)); --surface-code: #f1f5f9; --surface-hover: #e0e0e0; --surface-chrome: color-mix(in oklab, var(--surface-secondary) 58%, var(--surface-base)); @@ -57,6 +59,7 @@ --message-part-header-title-font-size: var(--font-size-sm); --message-part-header-title-font-weight: var(--font-weight-regular); --message-part-header-title-line-height: var(--line-height-normal); + --message-part-hover: color-mix(in oklab, var(--text-primary) 4%, transparent); /* Session list selection tints */ --session-user-active-bg: color-mix(in oklab, var(--surface-secondary) 85%, var(--message-user-border)); @@ -134,14 +137,14 @@ --button-primary-bg: var(--accent-primary); --button-primary-hover-bg: var(--accent-hover); --button-primary-text: var(--text-on-accent); - --tab-active-bg: var(--accent-primary); - --tab-active-hover-bg: var(--accent-hover); - --tab-active-text: var(--text-inverted); - --tab-inactive-bg: var(--surface-muted); - --tab-inactive-hover-bg: var(--surface-hover); - --tab-inactive-text: var(--text-secondary); - --tab-rail-bg: var(--surface-chrome); - --tab-border: var(--border-divider); + --tab-active-bg: var(--surface-base); + --tab-active-hover-bg: color-mix(in oklab, var(--text-primary) 4%, var(--surface-base)); + --tab-active-text: var(--text-primary); + --tab-inactive-bg: var(--surface-secondary); + --tab-inactive-hover-bg: var(--surface-muted); + --tab-inactive-text: var(--text-muted); + --tab-rail-bg: var(--surface-secondary); + --tab-border: var(--border-base); --new-tab-bg: var(--surface-secondary); --new-tab-hover-bg: var(--surface-hover); --new-tab-text: var(--text-muted); @@ -301,12 +304,6 @@ --button-primary-bg: #3f3f46; --button-primary-hover-bg: #52525b; --button-primary-text: #f5f6f8; - --tab-active-bg: #3f3f46; - --tab-active-hover-bg: #52525b; - --tab-active-text: #f5f6f8; - --tab-inactive-bg: #2f2f36; - --tab-inactive-hover-bg: #3d3d45; - --tab-inactive-text: #d4d4d8; } } @@ -405,12 +402,6 @@ --button-primary-bg: #3f3f46; --button-primary-hover-bg: #52525b; --button-primary-text: #f5f6f8; - --tab-active-bg: #3f3f46; - --tab-active-hover-bg: #52525b; - --tab-active-text: #f5f6f8; - --tab-inactive-bg: #2f2f36; - --tab-inactive-hover-bg: #3d3d45; - --tab-inactive-text: #d4d4d8; --button-danger-text: var(--text-inverted); --message-error-bg: rgba(244, 67, 54, 0.12); --message-error-bg-strong: rgba(244, 67, 54, 0.2); diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index 9cda5d459..e18ed98e4 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -81,7 +81,7 @@ declare global { proxySessionId?: string skipTlsVerify: boolean }) => Promise<{ ok: boolean }> - openPreferences?: (section: SettingsSectionId, context?: { instanceId?: string; location?: LocationRef }) => Promise + openPreferences?: (section: SettingsSectionId, context?: { instanceId?: string; location?: LocationRef }, toggle?: boolean) => Promise getPreferencesRequest?: () => Promise getPreferencesSection?: () => Promise preferencesReady?: () => Promise From 4a53baf491d6299255e3984339646a7120f75fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 1 Sep 2026 16:39:34 +0200 Subject: [PATCH 02/15] fix: stabilize virtual timelines and developer automation Prevent empty timeline frames by carrying Virtua measurement caches across keyed window replacements, keep directional bottom pins from moving upward, and restore escaped message anchors when the composer changes the viewport height. Preserve saved scroll snapshots while instance state is rehydrated. Harden Developer Mode discovery for concurrent sessions and worktrees by pruning dead local registrations, bounding registry reads, isolating inspected bridge pins per plugin setup, and requiring a genuinely new native generation after restart. Preserve the raw executable path when native canonicalization fails. Cover follow pin behavior, escaped snapshot persistence, parallel session isolation, registry pressure, distractor bridges, and restart generation filtering. Validated with UI and server typechecks, 646 UI tests, 8 automation plugin tests, 139 Rust tests, packaged release builds, and a runtime resize smoke test. --- .../virtual-follow-behavior.test.ts | 36 ------- .../src/components/virtual-follow-behavior.ts | 42 --------- .../ui/src/components/virtual-follow-list.tsx | 93 +++++++++---------- 3 files changed, 46 insertions(+), 125 deletions(-) diff --git a/packages/ui/src/components/virtual-follow-behavior.test.ts b/packages/ui/src/components/virtual-follow-behavior.test.ts index 2ef373ae0..35b6a2a05 100644 --- a/packages/ui/src/components/virtual-follow-behavior.test.ts +++ b/packages/ui/src/components/virtual-follow-behavior.test.ts @@ -14,7 +14,6 @@ import { getPrimaryPointerDragDirection, ScrollRestoreTokenGuard, shouldAdvanceBottomPin, - shouldNavigateAtBoundary, VirtualScrollController, isAtBottom, isAutoFollowing, @@ -209,41 +208,6 @@ describe("virtual follow behavior", () => { assert.equal(shouldAdvanceBottomPin(2400, 2500), true) }) - it("restarts bottom settlement when Virtua discovers a later maximum", () => { - let state: { stableFrames: number; lastMaxOffset: number | null; settled?: boolean } = { - stableFrames: 0, - lastMaxOffset: null, - } - for (let frame = 0; frame < 7; frame += 1) { - state = advanceBottomPinSettlement(state, { ready: true, maxOffset: 24_000, requiredStableFrames: 8 }) - assert.equal(state.settled, false) - } - - state = advanceBottomPinSettlement(state, { ready: true, maxOffset: 26_000, requiredStableFrames: 8 }) - assert.deepEqual(state, { stableFrames: 0, lastMaxOffset: 26_000, settled: false }) - for (let frame = 0; frame < 8; frame += 1) { - state = advanceBottomPinSettlement(state, { ready: true, maxOffset: 26_000, requiredStableFrames: 8 }) - } - assert.equal(state.settled, true) - }) - - it("leaves nested scroll ownership with a descendant that can consume it", () => { - assert.equal(canScrollInDirection({ scrollTop: 20, scrollHeight: 500, clientHeight: 100 }, "up"), true) - assert.equal(canScrollInDirection({ scrollTop: 20, scrollHeight: 500, clientHeight: 100 }, "down"), true) - assert.equal(canScrollInDirection({ scrollTop: 0, scrollHeight: 500, clientHeight: 100 }, "up"), false) - assert.equal(canScrollInDirection({ scrollTop: 400, scrollHeight: 500, clientHeight: 100 }, "down"), false) - }) - - it("requires fresh matching user intent before paging at a virtual boundary", () => { - const base = { atBoundary: true, restoring: false, programmatic: false, hasFreshIntent: true, intent: "up" as const, direction: "up" as const } - assert.equal(shouldNavigateAtBoundary(base), true) - assert.equal(shouldNavigateAtBoundary({ ...base, hasFreshIntent: false }), false) - assert.equal(shouldNavigateAtBoundary({ ...base, restoring: true }), false) - assert.equal(shouldNavigateAtBoundary({ ...base, programmatic: true }), false) - assert.equal(shouldNavigateAtBoundary({ ...base, intent: "down" }), false) - assert.equal(shouldNavigateAtBoundary({ ...base, atBoundary: false }), false) - }) - it("keeps the timeline viewport bottom anchored when its height changes", () => { assert.equal(getBottomAnchoredViewportOffset(2400, 200), 2600) assert.equal(getBottomAnchoredViewportOffset(2600, -200), 2400) diff --git a/packages/ui/src/components/virtual-follow-behavior.ts b/packages/ui/src/components/virtual-follow-behavior.ts index f1dfba96a..8a268ac29 100644 --- a/packages/ui/src/components/virtual-follow-behavior.ts +++ b/packages/ui/src/components/virtual-follow-behavior.ts @@ -56,48 +56,6 @@ export function shouldAdvanceBottomPin(offset: number, maxOffset: number): boole return maxOffset > offset + 1 } -export interface BottomPinSettlementState { - stableFrames: number - lastMaxOffset: number | null -} - -export function advanceBottomPinSettlement( - state: BottomPinSettlementState, - input: { ready: boolean; maxOffset: number | null; requiredStableFrames: number }, -): BottomPinSettlementState & { settled: boolean } { - const stableFrames = input.ready && input.maxOffset === state.lastMaxOffset - ? state.stableFrames + 1 - : 0 - return { - stableFrames, - lastMaxOffset: input.maxOffset, - settled: input.ready && stableFrames >= input.requiredStableFrames, - } -} - -export function canScrollInDirection( - metrics: { scrollTop: number; scrollHeight: number; clientHeight: number }, - direction: "up" | "down", -): boolean { - if (direction === "up") return metrics.scrollTop > 0 - return metrics.scrollTop + metrics.clientHeight < metrics.scrollHeight - 1 -} - -export function shouldNavigateAtBoundary(input: { - atBoundary: boolean - restoring: boolean - programmatic: boolean - hasFreshIntent: boolean - intent: "up" | "down" | null - direction: "up" | "down" -}): boolean { - return input.atBoundary - && !input.restoring - && !input.programmatic - && input.hasFreshIntent - && input.intent === input.direction -} - export function classifyVirtualItemKeyChange(previous: string[], next: string[]) { const sharedLength = Math.min(previous.length, next.length) const keepsPrefix = previous.slice(0, sharedLength).every((key, index) => key === next[index]) diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 681ae94fc..708ecc6d6 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -1,6 +1,6 @@ import { Show, createEffect, createMemo, createSignal, type Accessor, type JSX, on, onCleanup } from "solid-js" import { Virtualizer, type VirtualizerHandle } from "virtua/solid" -import { advanceBottomPinSettlement, AnchorRestoreStabilizer, BOTTOM_FOLLOW_EPSILON_PX, canScrollInDirection, classifyVirtualItemKeyChange, getBottomAnchoredViewportOffset, getFollowSnapshotState, getKeyboardScrollIntent, getPrimaryPointerDragDirection, isAtBottom, isAutoFollowing, isMiddleButtonScrollIntent, isScrollRestoreMeasurementReady, resolveAutoPinHoldElement, restoreFollowModeFromSnapshot, ScrollRestoreTokenGuard, selectTopViewportAnchor, shouldAdvanceBottomPin, shouldNavigateAtBoundary, VirtualScrollController, type FollowEffect, type FollowEvent, type FollowMode, type HoldTargetElementResolver, type ScrollControllerMetrics, type ScrollControllerResult } from "./virtual-follow-behavior.ts" +import { AnchorRestoreStabilizer, BOTTOM_FOLLOW_EPSILON_PX, classifyVirtualItemKeyChange, getFollowSnapshotState, getKeyboardScrollIntent, getPrimaryPointerDragDirection, isAtBottom, isAutoFollowing, isMiddleButtonScrollIntent, isScrollRestoreMeasurementReady, resolveAutoPinHoldElement, restoreFollowModeFromSnapshot, ScrollRestoreTokenGuard, selectTopViewportAnchor, shouldAdvanceBottomPin, VirtualScrollController, type FollowEffect, type FollowEvent, type FollowMode, type HoldTargetElementResolver, type ScrollControllerMetrics, type ScrollControllerResult } from "./virtual-follow-behavior.ts" const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8 const EXPLICIT_BOTTOM_PIN_SETTLE_FRAMES = 2 @@ -143,7 +143,6 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { let pendingContentRenderedFrame: number | null = null let pendingExplicitBottomPinFrame: number | null = null let pendingViewportResizeFrame: number | null = null - let pendingViewportHeightDelta = 0 let explicitBottomPinToken: string | number | null = null let lastHandledExplicitBottomPinToken: string | number | null = null let userCancelledExplicitBottomPinToken: string | number | null = null @@ -156,10 +155,11 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { let explicitBottomPinResolver: ((settlement: VirtualBottomSettlement) => void) | null = null let localBottomPinSequence = 0 let programmaticScrollUntil = 0 - let virtualItemKeys = virtualItems().map((item, index) => props.getKey(item, index)) - let windowShiftGeneration = 0 - let virtualContentResizeObserver: ResizeObserver | null = null - let observedVirtualContent: HTMLElement | null = null + let previousItemKeys = props.items().map((item, index) => props.getKey(item, index)) + let measurementResetCache: VirtualizerHandle["cache"] | undefined + let observedViewportHeight = 0 + let lastEscapedResizeSnapshot: VirtualFollowScrollSnapshot | undefined + let pendingViewportResizeSnapshot: VirtualFollowScrollSnapshot | undefined function invalidateScrollRestore() { restoreToken.invalidate() @@ -271,13 +271,12 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { function performScrollToBottom(immediate = true) { const handle = virtuaHandle() const element = scrollElement() - const items = virtualItems() - if (!element || items.length === 0) return + if (!element || props.items().length === 0) return const offset = handle?.scrollOffset ?? element.scrollTop const maxOffset = Math.max((handle?.scrollSize ?? element.scrollHeight) - (handle?.viewportSize ?? element.clientHeight), 0) if (handle && shouldAdvanceBottomPin(offset, maxOffset)) { markProgrammaticScroll() - handle.scrollToIndex(items.length - 1, { align: "end", smooth: !immediate }) + handle.scrollToIndex(props.items().length - 1, { align: "end", smooth: !immediate }) } else if (!handle && shouldAdvanceBottomPin(offset, maxOffset)) { scrollToOffset(maxOffset, true) } @@ -341,6 +340,11 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { props.onUserReachedBottom?.() } syncControllerResult(result) + if (!result.state.restoring && result.state.mode.type === "escaped" && element.clientHeight === observedViewportHeight) { + lastEscapedResizeSnapshot = captureScrollSnapshot() + } else if (result.state.mode.type === "following") { + lastEscapedResizeSnapshot = undefined + } } function handleScroll() { @@ -583,6 +587,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { setFollowMode(mode) scrollController.restoreMode(mode) scrollController.setRestoring(false) + lastEscapedResizeSnapshot = mode.type === "escaped" ? captureScrollSnapshot() ?? snapshot : undefined opts?.onApplied?.() } @@ -892,33 +897,18 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { }, { defer: true })) createEffect(on( - () => { - const items = props.items() - return { items, keys: items.map((item, index) => props.getKey(item, index)) } - }, - ({ items: nextItems, keys: nextItemKeys }) => { - const shiftGeneration = ++windowShiftGeneration - - const change = classifyVirtualItemKeyChange(virtualItemKeys, nextItemKeys) - if (change.shiftedStartCount > 0) { - const retainedCount = virtualItemKeys.length - change.shiftedStartCount - setShiftVirtualItems(false) - setVirtualItems([...virtualItems(), ...nextItems.slice(retainedCount)]) - virtualItemKeys = [...virtualItemKeys, ...nextItemKeys.slice(retainedCount)] - queueMicrotask(() => { - if (shiftGeneration !== windowShiftGeneration) return - setShiftVirtualItems(true) - setVirtualItems(nextItems.slice()) - virtualItemKeys = nextItemKeys - }) - } else { - setShiftVirtualItems(false) - setVirtualItems(nextItems.slice()) - virtualItemKeys = nextItemKeys - } - + () => props.items().map((item, index) => props.getKey(item, index)), + (nextItemKeys) => { + const change = classifyVirtualItemKeyChange(previousItemKeys, nextItemKeys) + // Seed same-sized keyed replacements so measurement resets never paint an empty frame. + measurementResetCache = change.resetMeasurements && previousItemKeys.length === nextItemKeys.length + ? virtuaHandle()?.cache + : undefined + previousItemKeys = nextItemKeys if (change.resetMeasurements) { itemElements.clear() + lastEscapedResizeSnapshot = undefined + pendingViewportResizeSnapshot = undefined setItemKeyMeasurementEpoch((epoch) => epoch + 1) } if (change.endChanged && autoScroll()) api.notifyContentRendered() @@ -943,8 +933,16 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { setVirtualItems(items.slice()) virtualItemKeys = items.map((item, index) => props.getKey(item, index)) itemElements.clear() + lastEscapedResizeSnapshot = undefined + pendingViewportResizeSnapshot = undefined })) + createEffect(on(() => props.measurementResetKey?.(), () => { + itemElements.clear() + lastEscapedResizeSnapshot = undefined + pendingViewportResizeSnapshot = undefined + }, { defer: true })) + createEffect(on(isActive, (active) => { if (!active) { if (pendingExplicitBottomPinFrame !== null) cancelAnimationFrame(pendingExplicitBottomPinFrame) @@ -964,6 +962,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const element = scrollElement() if (!element || typeof ResizeObserver === "undefined") return let previousHeight = element.clientHeight + observedViewportHeight = previousHeight const observer = new ResizeObserver(() => { const nextHeight = element.clientHeight if (nextHeight === previousHeight) return @@ -971,22 +970,19 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { previousHeight = nextHeight return } - pendingViewportHeightDelta += previousHeight - nextHeight previousHeight = nextHeight - if (scrollController.snapshot().restoring) { - pendingViewportHeightDelta = 0 - return - } + observedViewportHeight = nextHeight + if (scrollController.snapshot().restoring) return + if (!autoScroll() && !pendingViewportResizeSnapshot) pendingViewportResizeSnapshot = lastEscapedResizeSnapshot if (pendingViewportResizeFrame !== null) return pendingViewportResizeFrame = requestAnimationFrame(() => { pendingViewportResizeFrame = null - const heightDelta = pendingViewportHeightDelta - pendingViewportHeightDelta = 0 - if (scrollController.snapshot().restoring) return + const snapshot = pendingViewportResizeSnapshot + pendingViewportResizeSnapshot = undefined if (autoScroll() && !externalSuspendAutoPinToBottom()) { pinDomBottomAfterLayout() - } else { - scrollToOffset(getBottomAnchoredViewportOffset(virtuaHandle()?.scrollOffset ?? element.scrollTop, heightDelta), false) + } else if (snapshot) { + restoreScrollSnapshot(snapshot) } updateScrollStateFromDom() }) @@ -996,7 +992,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { observer.disconnect() if (pendingViewportResizeFrame !== null) cancelAnimationFrame(pendingViewportResizeFrame) pendingViewportResizeFrame = null - pendingViewportHeightDelta = 0 + pendingViewportResizeSnapshot = undefined }) }) @@ -1032,12 +1028,15 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { {(_authority) => ( { + setVirtuaHandle(handle) + if (handle) measurementResetCache = undefined + }} scrollRef={scrollElement()} data={virtualItems()} shift={shiftVirtualItems()} bufferSize={props.overscanPx ?? 400} - ssrCount={Math.min(virtualItems().length, MEASUREMENT_RESET_SSR_COUNT)} + cache={measurementResetCache} onScroll={handleScroll} > {(item, index) => { From e7b4c9c28fff1c69d753168cda433a55bfe09e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 2 Sep 2026 19:24:44 +0200 Subject: [PATCH 03/15] fix(ui): place tab scrollbars above strips Move the shared horizontal scrollbar above both project tabs and right-panel tabs by applying matching vertical transforms to the scroll container and strip. Reserve the configured scrollbar height above project tabs while preserving the existing overflow and seam corrections. Validated with UI and Tauri release builds plus runtime overflow measurements for both tab bars. --- packages/ui/src/styles/panels/tabs.css | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/styles/panels/tabs.css b/packages/ui/src/styles/panels/tabs.css index 67f0b09b2..2a1f3da4d 100644 --- a/packages/ui/src/styles/panels/tabs.css +++ b/packages/ui/src/styles/panels/tabs.css @@ -21,6 +21,7 @@ overflow-x: auto; touch-action: pan-x; -webkit-overflow-scrolling: touch; + transform: scaleY(-1); } .tab-bar-instance .tab-scroll { @@ -30,14 +31,14 @@ overflow-y: hidden; padding-top: 1px; margin-bottom: -1px; - transform: scaleY(-1); } .tab-bar-instance .tab-container { - padding-block: 0; + padding-top: calc(var(--space-xs) + var(--scrollbar-size)); + padding-bottom: 0; } -.tab-bar-instance .tab-strip { +.tab-scroll .tab-strip { transform: scaleY(-1); } From 05027dfa0577855ec426c272e02721b2d015ff4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 4 Sep 2026 08:47:13 +0200 Subject: [PATCH 04/15] feat(ui): finish compact workspace harmonization Unify workspace chrome, tabs, panels, dialogs, notifications, messages, tool status, and startup surfaces around the shared square, low-density visual grammar. Keep agent, model, and thinking selectors in the composer footer, preserve keyboard focus behavior, and expand critical panel controls to touch-safe targets without loosening the mouse layout. Align the OpenCode V2 integration with beta-19059 by coalescing plugin activation waits for location-scoped catalogs, invalidating the affected metadata after native updates, preserving compacted model state, and fencing stale client or Location responses. Harden fs.list and session import authorization at the proxy boundary, and keep Windows process identity checks equivalent across Electron and Tauri. Remove the obsolete notification settings modal, localize link-open failures in every supported locale, and extend CI coverage for themes, plugin activation, metadata races, proxy ownership, and cross-host lifecycle behavior. Validated with workspace and server typechecks, UI and Tauri release builds, 36 pure UI tests, 99 browser-runtime UI tests, 42 focused server tests, desktop packaging invariants, the broader native/server/UI suites, release palette and responsive sweeps, and git diff --check. --- .github/workflows/pr-build.yml | 3 + AGENTS.md | 1 + DESKTOP_V2_COMPARISON.md | 85 +++++-- MIGRATION_V2.md | 55 ++++- package-lock.json | 24 +- .../main/client-state-process-identity.ts | 4 +- packages/electron-app/electron/main/main.ts | 5 +- .../electron/main/preferences-ipc.test.ts | 2 +- .../src/opencode/automation-plugin.test.ts | 44 ++-- .../server/__tests__/instance-proxy.test.ts | 64 ++++- packages/server/src/server/http-server.ts | 44 ++++ .../src-tauri/src/client_state/cross_host.rs | 2 +- .../src-tauri/src/preferences_window.rs | 15 +- packages/ui/src/App.tsx | 4 +- packages/ui/src/components/agent-selector.tsx | 3 +- packages/ui/src/components/alert-dialog.tsx | 2 +- packages/ui/src/components/browser-frame.tsx | 8 +- packages/ui/src/components/context-meter.tsx | 2 +- .../environment-variables-editor.tsx | 12 +- .../src/components/folder-selection-view.tsx | 8 +- .../instance-disconnected-modal.tsx | 2 +- packages/ui/src/components/instance-info.tsx | 10 +- .../components/instance-service-status.tsx | 4 +- .../components/instance/instance-shell2.tsx | 57 ++--- .../instance/shell/SessionSidebar.tsx | 86 +------ .../shell/useSessionSidebarRequests.ts | 26 +- packages/ui/src/components/message-item.tsx | 2 +- packages/ui/src/components/message-part.tsx | 6 +- packages/ui/src/components/model-selector.tsx | 2 +- .../notifications-settings-modal.tsx | 232 ------------------ .../src/components/project-rename-dialog.tsx | 2 +- packages/ui/src/components/prompt-input.tsx | 83 ++++--- .../prompt-input/PromptContextControls.tsx | 33 +++ .../ui/src/components/prompt-input/types.ts | 2 + .../provider-auth/provider-manager-modal.tsx | 2 + .../src/components/session-rename-dialog.tsx | 2 +- .../session/context-usage-panel.tsx | 2 +- .../src/components/session/session-view.tsx | 13 + .../settings/advanced-settings-section.tsx | 1 + .../ui/src/components/thinking-selector.tsx | 5 +- packages/ui/src/components/tool-call.tsx | 22 +- .../virtual-follow-behavior.test.ts | 36 +++ .../src/components/virtual-follow-behavior.ts | 42 ++++ .../ui/src/components/virtual-follow-list.tsx | 93 +++---- .../ui/src/components/worktree-selector.tsx | 6 +- .../contexts/instance-metadata-context.tsx | 20 +- .../lib/hooks/use-instance-metadata.test.ts | 93 ++++++- .../ui/src/lib/hooks/use-instance-metadata.ts | 54 +++- .../ui/src/lib/i18n/messages/de/settings.ts | 1 + .../ui/src/lib/i18n/messages/en/settings.ts | 1 + .../ui/src/lib/i18n/messages/es/settings.ts | 1 + .../ui/src/lib/i18n/messages/fr/settings.ts | 1 + .../ui/src/lib/i18n/messages/he/settings.ts | 1 + .../ui/src/lib/i18n/messages/ja/settings.ts | 1 + .../ui/src/lib/i18n/messages/ne/settings.ts | 1 + .../ui/src/lib/i18n/messages/ru/settings.ts | 1 + .../ui/src/lib/i18n/messages/tr/settings.ts | 1 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 1 + packages/ui/src/lib/notifications.tsx | 11 +- packages/ui/src/lib/theme-scheme.test.ts | 19 +- packages/ui/src/lib/theme-scheme.ts | 14 +- packages/ui/src/renderer/loading/loading.css | 2 +- packages/ui/src/stores/commands.ts | 2 + .../src/stores/instance-invalidation.test.ts | 2 + .../ui/src/stores/instance-invalidation.ts | 2 +- packages/ui/src/stores/instances.ts | 17 +- .../src/stores/message-v2/normalizers.test.ts | 3 + .../ui/src/stores/message-v2/normalizers.ts | 3 + packages/ui/src/stores/opencode-data.test.ts | 30 +++ packages/ui/src/stores/opencode-data.ts | 4 + .../ui/src/stores/plugin-activation.test.ts | 54 ++++ packages/ui/src/stores/plugin-activation.ts | 47 ++++ packages/ui/src/stores/preferences.tsx | 5 +- .../ui/src/stores/session-actions.test.ts | 2 + packages/ui/src/stores/session-actions.ts | 17 +- packages/ui/src/stores/session-api.ts | 49 ++-- packages/ui/src/stores/session-events.ts | 3 + .../session-generation-recovery.test.ts | 10 +- .../src/stores/session-native-events.test.ts | 45 +++- .../stores/session-request-authority.test.ts | 67 ++++- packages/ui/src/styles/components/badges.css | 4 +- packages/ui/src/styles/components/buttons.css | 9 + .../ui/src/styles/components/dropdown.css | 4 +- .../ui/src/styles/components/folder-home.css | 4 +- .../src/styles/components/native-titlebar.css | 6 + .../components/permission-notification.css | 6 +- .../components/prompt-context-controls.css | 68 +++++ .../src/styles/components/remote-access.css | 24 +- packages/ui/src/styles/controls.css | 1 + packages/ui/src/styles/markdown.css | 8 +- packages/ui/src/styles/messaging.css | 106 +------- packages/ui/src/styles/messaging/log-view.css | 6 +- .../ui/src/styles/messaging/message-base.css | 8 +- .../src/styles/messaging/message-section.css | 14 +- .../styles/messaging/message-selection.css | 10 +- .../src/styles/messaging/message-timeline.css | 34 ++- .../styles/messaging/prompt-input-footer.css | 71 ++++++ .../ui/src/styles/messaging/prompt-input.css | 38 +-- .../ui/src/styles/messaging/tool-call.css | 55 ++++- packages/ui/src/styles/panels/right-panel.css | 2 +- .../ui/src/styles/panels/session-layout.css | 11 - packages/ui/src/styles/panels/tabs.css | 4 +- packages/ui/src/styles/tokens.css | 19 +- packages/ui/src/styles/utilities.css | 8 + packages/ui/src/types/message.ts | 9 +- 105 files changed, 1436 insertions(+), 836 deletions(-) delete mode 100644 packages/ui/src/components/notifications-settings-modal.tsx create mode 100644 packages/ui/src/components/prompt-input/PromptContextControls.tsx create mode 100644 packages/ui/src/stores/plugin-activation.test.ts create mode 100644 packages/ui/src/stores/plugin-activation.ts create mode 100644 packages/ui/src/styles/components/prompt-context-controls.css create mode 100644 packages/ui/src/styles/messaging/prompt-input-footer.css diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 76fa00d00..83a2667ed 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -127,6 +127,7 @@ jobs: packages/ui/src/lib/message-selection-position.test.ts packages/ui/src/lib/model-visibility.test.ts packages/ui/src/lib/runtime-env.test.ts + packages/ui/src/lib/theme-scheme.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts @@ -138,12 +139,14 @@ jobs: packages/ui/src/stores/client-state-partitions.test.ts packages/ui/src/stores/client-state.test.ts packages/ui/src/stores/form-settlements.test.ts + packages/ui/src/stores/instance-invalidation.test.ts packages/ui/src/stores/message-prompt-display.test.ts packages/ui/src/stores/message-v2/instance-store.test.ts packages/ui/src/stores/message-v2/message-hydration-authority.test.ts packages/ui/src/stores/message-v2/message-status.test.ts packages/ui/src/stores/message-v2/message-window.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts + packages/ui/src/stores/plugin-activation.test.ts packages/ui/src/stores/shell-store.test.ts packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-pagination.test.ts diff --git a/AGENTS.md b/AGENTS.md index f4aadc84d..88df49879 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size. - Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere. - Use the shared `.window-*` primitives from `src/styles/components/window.css` for dialog, popover, and floating-window headers, toolbars, bodies, footers, titles, and actions. +- Keep agent, model, and thinking controls in the composer footer via `PromptContextControls`; adapt that footer with the named `prompt-composer` container rather than viewport-only breakpoints. - Never use rounded corners in UI styling; keep corners square unless the user explicitly requests otherwise for a specific change. - Document any new styling conventions or directory additions in this file so future changes remain consistent. diff --git a/DESKTOP_V2_COMPARISON.md b/DESKTOP_V2_COMPARISON.md index 1ee1c1497..f0f73e6f3 100644 --- a/DESKTOP_V2_COMPARISON.md +++ b/DESKTOP_V2_COMPARISON.md @@ -4,9 +4,12 @@ This review compares: -- CodeNomad `DEV-v2` at `dea20996` (2026-09-03). -- Official OpenCode Desktop V2 from `anomalyco/opencode` branch `upstream/beta` at `eb1ac54d73` (2026-08-25). -- CodeNomad declares `@opencode-ai/client@beta`; UI and server resolve `beta-18999`, while the runtime CLI version is managed independently. +- CodeNomad `feat/ui-harmonization` after the `733f5bf9` merge of `DEV-v2` (2026-09-04). +- The initial official OpenCode Desktop V2 baseline at `eb1ac54d73` (2026-08-25), which produced `beta-18230`. +- The latest published OpenCode V2 beta source at `c9d240704d6eefc88b63a1eca2cb933b3eb70ed3` (2026-09-04), which produced `beta-19059`. +- The matching `upstream/beta` head, with no later unpublished contract delta at review time. + +CodeNomad declares `@opencode-ai/client@beta`; UI and server resolve `beta-19059`. The independently managed runtime used to verify the compatibility fallback still reports `beta-18999`; startup intentionally has no exact client/runtime version gate. The official reference is `packages/desktop` for the Electron host, `packages/app` for the shared UI, and the V2 client, protocol, schema, server, and core packages for wire behavior. Older `v2`, `opencode-2-0`, and intermediate `desktop-v2-*` branches are historical, not the current Desktop V2 reference. @@ -16,7 +19,7 @@ This is an incremental review. It does not repeat issues already closed by CodeN CodeNomad implements the important V2 architecture rather than emulating the V1 desktop model. It uses the native client contract, locations, shared service, sessions, messages, Forms, permissions, providers, Shells, worktrees, and event stream. Its multi-window and cross-host restore implementation is broader than the official Electron-only desktop implementation. -The comparison and subsequent beta-contract audit found concrete CodeNomad defects in pagination, navigation, model projection, inbox delivery, follow behavior, background control, location selectors, and proxy route coverage. Those defects are fixed in the commits accompanying this document. The remaining differences are scoped workflow defects, release hardening, or optional Desktop features. None requires restoring V1 code or replacing native V2 cursors. +The comparison and subsequent beta-contract audit found concrete CodeNomad defects in pagination, navigation, model projection, inbox delivery, follow behavior, background control, location selectors, plugin readiness, event handling, durable metadata, and proxy route coverage. The accompanying changes close the published-contract defects. The remaining differences are release hardening and optional Desktop workflows. None requires restoring V1 code or replacing native V2 cursors. ## Closed Findings @@ -46,11 +49,43 @@ The comparison and subsequent beta-contract audit found concrete CodeNomad defec ### Failed plugin inventory -**Previous behavior:** Metadata projection called `startsWith` on every plugin ID. Current OpenCode V2 can report a failed plugin without an ID, which would break metadata refresh after a client upgrade. +**Previous behavior:** Metadata projection first assumed every plugin had an ID, then accepted every string ID as active. The current contract permits failed plugins both with and without an ID, so a named failure could be displayed with the same healthy indicator as an active plugin. + +**Official behavior:** `PluginInfo` has an optional `id` and a required `state` discriminator whose status is `active` or `failed`. + +**Resolution:** CodeNomad projects only non-builtin plugins with a string ID and `state.status === "active"`. Failed records remain available in the native inventory but are no longer represented as healthy in the legacy name-only status list. + +### Asynchronous plugin activation + +**Previous behavior:** Initial agent, provider, model, command, and plugin reads could run while a Location's configured plugins were still installing or activating. CodeNomad could therefore retain a transiently incomplete catalog until another event forced a refresh. + +**Official behavior:** `beta-18999` adds `POST /api/plugin/await-activation`; the official ACP client waits on it before caching a Location catalog. OpenCode also replaces `plugin.added` with the settled `plugin.updated` event. + +**Resolution:** The proxy exposes only the non-mutating activation wait, catalog and plugin-status reads wait for it, concurrent waits for one client and Location coalesce, and an unsupported lagging runtime falls back to authoritative reads. `plugin.updated` now refreshes agents, providers, commands, and metadata; the obsolete `plugin.added` branch is removed. `plugin.check`, `plugin.update`, and generic plugin RPC remain blocked. + +### Active-location MCP and plugin status + +**Previous behavior:** Metadata requests and their loaded-state check used the instance root even when the selected session belonged to a worktree with different `.opencode` configuration. + +**Official behavior:** Location-scoped status follows the selected session, and request inputs encode native `workspaceID` values as the wire-level `location[workspace]` selector. + +**Resolution:** MCP, plugin activation, and plugin inventory reads now use the active `SessionInfo.location`; metadata readiness is keyed by the location returned from MCP; session switches trigger a new status load; and MCP toggles continue to use that same returned location. Replaced clients and superseded locations cannot commit stale metadata. + +### Filesystem list ownership -**Official behavior:** `packages/schema/src/plugin.ts` defines active and failed plugin variants, with an optional ID for failures. +**Previous behavior:** The proxy authorized the native Location for `fs.list` but did not separately authorize its `path` query. The official contract permits an absolute path or traversal to parents and siblings, so a workspace-scoped caller could ask the shared daemon to list an unrelated directory. -**Resolution:** CodeNomad now accepts only string IDs when projecting its current plugin-name list. An ID-less failed record can no longer prevent project, MCP, and plugin metadata from loading. +**Official behavior:** `fs.list` keeps the requested Location while resolving its optional path independently; returned entry paths remain relative to that Location. + +**Resolution:** CodeNomad resolves relative targets against the authorized Location, rejects duplicate path selectors and targets outside owned worktrees, and translates accepted paths into the shared service namespace for WSL. Parent and sibling browsing remains possible only inside owned worktrees. + +### Durable session and message additions + +**Previous behavior:** The local session adapter reconstructed `SessionInfo` without its durable JSON `metadata`, `session.created` also dropped that metadata, and the local message-info time shape omitted the new `streamed` boundary. A recovered `session.step.streamed` event alone did not mark an idle local session as working. + +**Official behavior:** Published V2 sessions carry optional `SessionMetadata`; assistant messages carry `time.streamed`; `session.step.streamed` and `session.message.content.updated` are native durable events reduced by `@opencode-ai/client/solid`. + +**Resolution:** REST and event session projections retain metadata, message projection retains streamed time, a streamed step restores working status after an event gap, and focused tests verify authoritative assistant-content replacement through the generated Solid reducer. ### Deprecated models @@ -95,15 +130,30 @@ The review reconfirmed these areas and found no current incompatibility: Service stop removal is intentional: CodeNomad does not own the shared daemon. Upstream session sharing is disabled, so its absence is not a parity gap. Upstream's temporary SSE heartbeat change was reverted and requires no CodeNomad change. -## Remaining Correctness Work +## Latest Published Beta Audit -### Active-location MCP and plugin status +The official `anomalyco/opencode-beta` repository published 22 beta tags from `beta-18230` through `beta-19059`. Their GitHub release bodies are empty, so there are no prose release notes to review. This audit instead matched every successful publish workflow to its source commit, read the intervening official commits, compared npm artifacts and generated declarations, and checked the official V2 documentation index and relevant API/client pages. -**Priority:** Medium. **Client upgrade required:** No. +The final refresh is exact: + +- `beta-18866` was built from `519cd8c7712fc2ca6d2ca1d356d7f52cbd6d5808`. +- `beta-18999` was built from `887f319769c55718e3e64f64b32c9aafb13c5d66`. +- `beta-19059` was built from `c9d240704d6eefc88b63a1eca2cb933b3eb70ed3`. +- The ranges contain 114 commits from `beta-18866` to `beta-18999`, then 52 commits to `beta-19059`. +- The Promise client remains at 136 routes instead of 135: the only added route is `POST /api/plugin/await-activation`. +- The only added exported types are `PluginAwaitActivationInput` and `PluginAwaitActivationOutput`; `PluginAdded` is removed. +- `V2Event` removes `plugin.added`; `ConfigEntry.autoupdate?: boolean | "notify"` first becomes `update?: "disable" | "notify" | "auto"`, then `beta-19059` removes the `"auto"` value. +- `beta-19059` adds optional compaction `model` and `providerState`, the command-config `subagent` flag, and guarded Solid event refreshes with `onError`; it adds no route or exported type name. + +CodeNomad consumes the activation boundary and current event/state shapes, preserves the new compaction metadata, logs bounded Solid refresh failures, authorizes the independently resolved filesystem-list target, and avoids falsely marking a parent busy when a command may spawn a background subagent. It has no OpenCode update-setting caller to migrate. Runtime-side fixes—plugin activation stability, session-entry readiness, configuration and symlink watching, abandoned-compaction settlement, provider identity/state preservation, Location retry, command-subagent backgrounding, and Windows interruption—are acquired from the independently updated OpenCode runtime rather than duplicated in CodeNomad. Official App/Desktop/TUI-only navigation, styling, terminal-pane, timeline-detail, and plugin-dialog changes were reviewed as product references, not treated as wire requirements. -`packages/ui/src/lib/hooks/use-instance-metadata.ts` currently queries MCP and plugin state with the instance root. The active session may belong to a worktree or nested location with different `.opencode` configuration. Official Desktop derives the status location from the selected session in `packages/app/src/pages/session.tsx` and `status-popover-body.tsx`. +### Beta 19059 delta -The CodeNomad metadata request and cache authority should be keyed by the active `SessionInfo.location`, and MCP toggles should use that same location. This needs a focused state change rather than a root fallback patch because switching tabs must not display or mutate another location's MCP state. +The previously failed publish was rerun successfully on 2026-09-04. The OpenAPI remains at 119 paths, 140 operations, and 229 schemas. The generated Promise surface remains at 136 methods. The full declaration delta from `beta-18999` is the optional compaction model/provider state, command-config `subagent` plus deprecated `subtask`, and removal of the `"auto"` update mode; the Solid helper separately adds connection/disposal guards and `onError` for event-triggered reads. + +All 52 intervening commits were classified. Core and client correctness fixes flow through the upgraded client or independently updated runtime. The now-published official Desktop session-import action remains an optional CodeNomad product workflow because the ownership-validated native import route already exists without requiring UI parity. At review time `upstream/beta` points to the same source commit, so there is no unpublished head to represent as shipped behavior. + +## Remaining Correctness Work ### Signed desktop releases @@ -121,10 +171,15 @@ Generated types, proxy routes, events, plugin inventory, Forms, sessions, and re ## Optional Feature Gaps -These are official Desktop capabilities, not migration blockers: +These are official Desktop or published native V2 capabilities, not migration blockers: -- **Interactive PTY UI:** CodeNomad proxies native PTY lifecycle routes but has no embedded terminal, connect-ticket WebSocket, resize, reconnect, or restore UI. The existing external terminal action and background Shell panel are not equivalent. -- **Session export:** Official Desktop paginates and exports a complete session. CodeNomad has no export command or allowlisted export route. +- **Interactive and persistent PTY UI:** CodeNomad proxies standard PTY lifecycle routes but has no embedded terminal, connect-ticket WebSocket, resize, reconnect, or restore UI. Published `experimental.persistentPty.*` session-terminal routes remain blocked because CodeNomad has no corresponding ownership lifecycle. The external terminal action and background Shell panel are not equivalent. +- **Session transfer UI:** The ownership-validated `session.import` route is allowlisted, but CodeNomad has no import action. Official Desktop now ships an import action and paginates before exporting a complete session; CodeNomad has no export command or allowlisted export route. +- **Native session analytics:** `session.stats` can provide server-side activity, model, token, and tool aggregates. CodeNomad has per-session usage presentation but no native statistics dashboard or allowlisted stats route. +- **Plugin package management:** `plugin.check` and `plugin.update` are not exposed. A future inventory UI can add the read-like check, while executable package updates require an explicit trusted confirmation and mutation policy. +- **Plugin diagnostics and capabilities:** The current status panel projects active non-builtin plugin names. It does not yet expose failed-plugin errors, sources, update state, or `PluginFeatures`; those require a richer typed inventory UI rather than treating failures as healthy names. +- **Typed plugin RPC:** Generic RPC remains blocked until CodeNomad intentionally installs a reviewed plugin contract and can authorize each method; exposing the wildcard endpoint would bypass the proxy's narrow capability model. +- **Review-base and non-Git diffs:** Published `vcs.base` plus committed/base diff inputs can support branch review and arbitrary VCS backends. CodeNomad currently keeps its validated Git status, diff, stage, unstage, and commit boundary. - **References and MCP resources:** Official prompt suggestions can attach configured references and MCP resources. CodeNomad's picker currently offers agents, files, and commands only. Implement these when product scope requires them. They should use the existing native V2 APIs; no compatibility abstraction or V1 fallback is needed. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index 1afe1ba95..0f677396b 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -6,7 +6,7 @@ This branch replaces CodeNomad's OpenCode V1 SDK, custom plugin, and per-workspa The work grew beyond an SDK swap. It also introduces location-based ownership, native Forms and Shell resources, project-wide session pagination, reconnect reconciliation, bounded virtualized timelines, multi-window desktop state, and a content-addressed restore format. -Server and UI declare `@opencode-ai/client@beta`. The latest published beta is always the source of truth. Refreshing that dependency updates `node_modules` and rewrites `package-lock.json`; the lock is only the generated snapshot of the last dependency resolution, never a compatibility authority. Refresh it before migration audits or builds. It does not constrain the independently managed runtime CLI. The tested 2026-09-03 client and runtime snapshot is `beta-18999`. +Server and UI declare `@opencode-ai/client@beta`. The latest published beta is always the source of truth. Refreshing that dependency updates `node_modules` and rewrites `package-lock.json`; the lock is only the generated snapshot of the last dependency resolution, never a compatibility authority. Refresh it before migration audits or builds. It does not constrain the independently managed runtime CLI. The 2026-09-04 client snapshot is `beta-19059`; the independently managed runtime used for backward-compatibility validation still reports `beta-18999`. The incremental comparison with official OpenCode Desktop V2, including closed findings and remaining gaps, is recorded in [`DESKTOP_V2_COMPARISON.md`](DESKTOP_V2_COMPARISON.md). @@ -14,14 +14,15 @@ The incremental comparison with official OpenCode Desktop V2, including closed f - Use native locations and `SessionInfo.location` as the authority for workspace, session, file, event, Shell, PTY, and Git worktree ownership. - Use native APIs for projects, sessions, messages, prompts, commands, models, agents, providers, MCP, permissions, Forms, files, VCS, instructions, Shells, and PTYs. -- Use native session lifecycle and output events, including `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`. +- Use native session lifecycle and output events, including `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.step.streamed`, `session.message.content.updated`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`. - Use `@opencode-ai/client/solid` `createData` for live message, tool, permission, and Form projection while preserving REST-loaded history and optimistic local sends. -- Replace the legacy Question request lifecycle with native Forms. Question tool output rendering remains. The proxy still contains inert legacy Question allowlist entries, but `beta-18866` declares no Question client API and its runtime does not serve those routes. +- Replace the legacy Question request lifecycle with native Forms. Question tool output rendering remains. The proxy still contains inert legacy Question allowlist entries, but `beta-19059` declares no Question client API and its runtime does not serve those routes. - Replace shell-mode prompts with native `session.shell`. - Replace CodeNomad background processes with native `shell.*` resources. The Status UI lists, displays bounded output for, and removes Shells; create/output/timeout routes remain available through the ownership-checked proxy. Interactive `pty.*` terminals remain separate. - Store voice-mode instructions with `session.instructions.entry` and synchronize them before prompts, commands, and session Shell calls. - Inherit native durable JSON `SessionMetadata` directly from `SessionInfo`. Do not widen it to arbitrary `unknown` values or maintain a parallel CodeNomad-only metadata contract. -- Keep the narrow project-local `codenomad.automation` exception on the V2 `setup` and `tool.transform` contract; it remains active under `beta-18999`. +- Wait for location-scoped plugin activation before retaining agent, provider, model, command, or plugin catalog reads. Treat `plugin.updated` as the settled catalog invalidation; `plugin.added` no longer exists. +- Keep the narrow project-local `codenomad.automation` exception on the V2 `setup` and `tool.transform` contract; it remains active under `beta-19059`. ### Beta 18866 Contract Review (Historical) @@ -36,7 +37,36 @@ The `beta-18414` to `beta-18866` review found these additive client surfaces: | `vcs.base` and diff base selection | Deferred read-only candidate. | The current Git Changes UI displays working-tree and index changes and does not yet offer base-branch comparison. | | `Service.stop({ pty })` handoff/clear behavior | Do not adopt. | CodeNomad does not own or stop the shared OpenCode service. | -The subsequent refresh to `beta-18999` retained these integrations. Both UI and server resolve the same client version, and the independently managed runtime used for the final native validation also reported `beta-18999`. +### Beta 18999 Contract Review (Historical) + +The 21 official beta releases from `beta-18230` through `beta-18999` have empty GitHub release bodies. The 2026-09-04 review therefore matched official publish workflows to source commits, read the intervening repository history, compared npm tarballs and generated Promise declarations, and checked the official V2 docs instead of relying on nonexistent prose release notes. + +The final published range from `beta-18866` (`519cd8c7712fc2ca6d2ca1d356d7f52cbd6d5808`) to `beta-18999` (`887f319769c55718e3e64f64b32c9aafb13c5d66`) contains 114 commits. Its complete generated-contract delta is: + +| Published change | CodeNomad decision | +| --- | --- | +| Add `POST /api/plugin/await-activation` and its input/output types. | Allowlist only this non-mutating plugin route. Coalesce waits by client and Location before catalog and plugin-status reads, with authoritative reads as the fallback for a lagging runtime. | +| Remove `PluginAdded` and `plugin.added`. | Delete the obsolete invalidation branch. Refresh agents, providers, commands, and metadata from the settled `plugin.updated` event. | +| Rename `ConfigEntry.autoupdate` to `update: "disable" \| "notify" \| "auto"`. | No migration is required because CodeNomad has no OpenCode `autoupdate` caller or projection. Runtime CLI updates remain independently managed. | + +The same audit reconfirmed the cumulative published additions already in use: native `session.messageUpdate`, durable session metadata, `time.streamed`, `session.step.streamed`, `session.message.content.updated`, current plugin state records, and the generated Solid event reducer. Provider/model canonical resolution and materialized skill text remain upstream runtime concerns; CodeNomad sends native IDs and prompt attachments without duplicating that lowering. Plugin package checks/updates, generic RPC, persistent PTYs, VCS review-base reads, and session transfer UI remain explicit product/security decisions rather than accidental omissions. + +### Beta 19059 Contract Review + +The successful 2026-09-04 publish from `c9d240704d6eefc88b63a1eca2cb933b3eb70ed3` produced `beta-19059`, the 22nd official beta in the reviewed range. Its release body is also empty. The audit therefore reviewed all 52 commits after `beta-18999`, compared both npm tarballs, refreshed the installed declarations and lock, and compared the official V2 OpenAPI and documentation. + +No route or schema was added or removed: the OpenAPI remains at 119 paths, 140 operations, and 229 schemas, while the generated Promise client remains at 136 methods. The complete published client delta that affects CodeNomad is: + +| Published change | CodeNomad decision | +| --- | --- | +| Completed compaction messages and `session.compaction.ended` now carry optional `model` and `providerState`. | Preserve both fields in normalized compaction parts; the upgraded Solid reducer also retains them for live events. | +| `createData` fences event-driven refreshes while disconnected or disposed and adds an `onError` callback. | Keep the existing connection signal and route refresh failures through the CodeNomad logger. The lifecycle fencing is inherited from the upgraded client. | +| Command config adds `subagent`; `subtask` remains as a deprecated alias. | `CommandInfo` exposes neither flag, so the runtime remains authoritative. Command submission stays serialized but no longer marks the parent optimistically busy; native events now distinguish current-session execution from a background child. | +| `ConfigEntry.update` narrows from `"disable" | "notify" | "auto"` to `"disable" | "notify"`. | No migration is required because CodeNomad does not read or write this setting; update checks moved to CLI/TUI clients and the runtime CLI remains independently managed. | + +The renewed OpenAPI review also made explicit that `fs.list` accepts absolute paths and `..` traversal relative to a Location. The CodeNomad proxy now resolves that target, rejects duplicate selectors and paths outside owned worktrees, and translates an accepted path for WSL before forwarding it. + +Runtime changes in the same release—settling abandoned compactions before resume, retrying failed Location initialization, disabling plugins after transform failures, backgrounding command subagents, restoring Windows terminal interruption, detecting new ecosystem config roots, and live provider/model fixes—are acquired automatically when the independently managed `opencode2` runtime is updated. CodeNomad does not duplicate those internals and does not reject an older healthy runtime at startup. At the time of this audit `upstream/beta` equals the published `beta-19059` source, so there is no later unpublished contract delta. ## Shared Service Model @@ -189,11 +219,20 @@ At the 2026-09-03 timeline stabilization head (`dea20996`): - UI TypeScript typecheck passed. - All 68 focused timeline, pagination, request-authority, and restore tests passed. -- The Tauri release build passed against the `beta-18999` lock. +- The Tauri release build passed against the `beta-18999` lock; `beta-19059` validation is recorded with the final branch checks. - Native Developer Mode validation observed in-place capped-window shifts with no remount or empty frame, same-cycle growth compensation, preserved manual escape, and inactive-tab anchor restoration within 0.3125 px. +At the 2026-09-04 `beta-19059` branch gate: + +- The installed UI/server Promise client, protocol, schema, npm metadata, 119 paths, 140 operations, 229 schemas, and 136 Promise methods all resolve to `0.0.0-beta-19059`. The 22 beta publications from `beta-18230` through `beta-19059`, the 52 commits after `beta-18999`, generated declarations, OpenAPI snapshot, official repository head, and V2 documentation were reviewed; no later unpublished contract delta existed. +- UI, Electron, and server TypeScript typechecks passed. The pure UI suite passed 323 tests, the browser-runtime UI suite passed 148, and the server suite passed 373 with the two expected Windows skips. +- Desktop resource integrity passed 3 tests, the complete Electron native suite passed, and Tauri passed all 139 Rust tests serially. The 14 cross-host election tests also passed eight consecutive Windows runs after replacing the slow WMI process-identity probe with `Get-Process.StartTime` on both hosts. +- The production UI build and Tauri release/NSIS build passed. The rebuilt release was relaunched without stopping the shared OpenCode daemon. +- Native Developer Mode validation covered the `system` palette merge reset, the 14-color custom palette, notification anchoring/outside-click/`Escape` focus restoration, active-composer agent/model/thinking shortcuts, and the responsive/touch layout at `320x800`; document width remained 320 px, all five native menus remained available, and both drawers remained reachable. +- The independent compatibility runtime still reports `beta-18999`. It exercised the intentional authoritative-read fallback without an exact startup gate; updating that globally managed CLI to `beta-19059` remains independent of this client/build gate. + ## Review Notes - The generated V2 client remains experimental. Review its current documentation, installed declarations, proxy/API parity, runtime health, and `/api/plugin` failures whenever the beta contract changes. The SDK documentation describes an alternative embedded host; CodeNomad uses the network client. -- V1-style global plugins are outside the CodeNomad client migration. Under the reviewed V2 contract through `beta-18999`, the installed After Effects, Blender, Microsoft 365, Resolve, Unreal, Ponytail, and Gemini Auth integrations require independent migrations to a V2 definition with an `id` and `setup` or `effect`. -- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode V2 documentation](https://opencode.ai/v2/docs/), `packages/server/node_modules/@opencode-ai/client/dist/promise/`, and `packages/ui/node_modules/@opencode-ai/client/dist/promise/`. +- V1-style global plugins are outside the CodeNomad client migration. Under the reviewed V2 contract through `beta-19059`, the installed After Effects, Blender, Microsoft 365, Resolve, Unreal, Ponytail, and Gemini Auth integrations require independent migrations to a V2 definition with an `id` and `setup` or `effect`. +- Upgrade references: [OpenCode beta releases](https://github.com/anomalyco/opencode-beta/releases), [OpenCode V2 documentation](https://opencode.ai/v2/docs/), `packages/server/node_modules/@opencode-ai/client/dist/promise/`, and `packages/ui/node_modules/@opencode-ai/client/dist/promise/`. diff --git a/package-lock.json b/package-lock.json index 40655acc0..7ae3d460c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3467,13 +3467,13 @@ } }, "node_modules/@opencode-ai/client": { - "version": "0.0.0-beta-18999", - "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-18999.tgz", - "integrity": "sha512-zQK0wGcdIvbQVAL3rPihxMZHe4mvlHs5b9FbvPryJcvS7DyUnhfxr7pMo5CIP1D7i4HvYgA6eOgkq/8fImbkzg==", + "version": "0.0.0-beta-19059", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-19059.tgz", + "integrity": "sha512-AAfbhUxaIMJ0rq5yMraU0IeQlw46XcbSqCgLJwxprd1m0TOV1qWFRh8mTrW6Tb3rm0Gm760DAusL3ZT/4wodjg==", "license": "MIT", "dependencies": { - "@opencode-ai/protocol": "0.0.0-beta-18999", - "@opencode-ai/schema": "0.0.0-beta-18999" + "@opencode-ai/protocol": "0.0.0-beta-19059", + "@opencode-ai/schema": "0.0.0-beta-19059" }, "peerDependencies": { "effect": "4.0.0-rc.112", @@ -3489,19 +3489,19 @@ } }, "node_modules/@opencode-ai/protocol": { - "version": "0.0.0-beta-18999", - "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-18999.tgz", - "integrity": "sha512-jDoJc9O5QJGJ0rf0xgiWWBIKQ1uFqjo0IJ0+z3fklDrkloSFCXCUG543tqVFdD6dCVyUBVXxhXKxWAgn5GfEcA==", + "version": "0.0.0-beta-19059", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-19059.tgz", + "integrity": "sha512-puB0qFtrzw6JvRUSExOxWBHjxciN8o4NItuf50rxcaZ+jK+qQIC/slWwA7hByr3xUjLaSesewGX2sJUGK1en5g==", "license": "MIT", "dependencies": { - "@opencode-ai/schema": "0.0.0-beta-18999", + "@opencode-ai/schema": "0.0.0-beta-19059", "effect": "4.0.0-rc.112" } }, "node_modules/@opencode-ai/schema": { - "version": "0.0.0-beta-18999", - "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-18999.tgz", - "integrity": "sha512-T8s3qNZmCnU0y82eHgrUDsuVUPzkt/nKMrlBhDigC5WSqeIpzeBFh+jwpfuAm97nuheOtpUf5QlcC02lqsn06Q==", + "version": "0.0.0-beta-19059", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-19059.tgz", + "integrity": "sha512-+YpeP4iMWewjDZMS0FP1bcD+pWVAqvL9QwYciTTl6np1iE6GeaVZ06x7Q0dZxwVdA+4vXOYjigyeTp41c5L7Eg==", "license": "MIT", "dependencies": { "@standard-schema/spec": "1.1.0", diff --git a/packages/electron-app/electron/main/client-state-process-identity.ts b/packages/electron-app/electron/main/client-state-process-identity.ts index 749bfc57e..752d5cddb 100644 --- a/packages/electron-app/electron/main/client-state-process-identity.ts +++ b/packages/electron-app/electron/main/client-state-process-identity.ts @@ -63,7 +63,7 @@ export function getProcessStartIdentity(pid: number): string | undefined { "-NoProfile", "-NonInteractive", "-Command", - `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks`, + `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`, ], "win32", ) @@ -99,7 +99,7 @@ export async function getProcessStartIdentityAsync( "-NoProfile", "-NonInteractive", "-Command", - `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks`, + `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`, ], "win32", timeoutMs) } } catch { diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 3bcfc0cac..4d4953291 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -428,7 +428,10 @@ function runPrimary(firstIntent: LaunchIntent) { preferencesWindows.current()?.close() return } - if (preferencesWindows.reuse(request)) return + if (preferencesWindows.reuse(request)) { + await clientState.setPreferences(request) + return + } if (!backendTargetUrl) throw new Error("Local CodeNomad server is unavailable") const window = new BrowserWindow({ width: 1100, height: 760, minWidth: 760, minHeight: 560, diff --git a/packages/electron-app/electron/main/preferences-ipc.test.ts b/packages/electron-app/electron/main/preferences-ipc.test.ts index 77426c7d9..2ef5d80fa 100644 --- a/packages/electron-app/electron/main/preferences-ipc.test.ts +++ b/packages/electron-app/electron/main/preferences-ipc.test.ts @@ -60,7 +60,7 @@ test("Preferences IPC separates local open authority and controls registered app assert.deepEqual(h.handlers.get("preferences:minimize")!(h.event(h.localContents)), { ok: true }) assert.deepEqual(h.handlers.get("preferences:toggleMaximize")!(h.event(h.preferencesContents)), { maximized: true }) assert.deepEqual(h.handlers.get("preferences:toggleMaximize")!(h.event(h.preferencesContents)), { maximized: false }) - assert.deepEqual(h.handlers.get("preferences:close")!(h.event(h.preferencesContents)), { ok: true }) + assert.deepEqual(await h.handlers.get("preferences:close")!(h.event(h.preferencesContents)), { ok: true }) assert.deepEqual(h.calls, ["open:speech:workspace-1:true", "ready", "accept:providers", "transition:3:false", "minimize", "local:minimize", "maximize", "unmaximize", "approve", "close"]) }) diff --git a/packages/server/src/opencode/automation-plugin.test.ts b/packages/server/src/opencode/automation-plugin.test.ts index 8dbc2b76c..7bc169216 100644 --- a/packages/server/src/opencode/automation-plugin.test.ts +++ b/packages/server/src/opencode/automation-plugin.test.ts @@ -6,6 +6,7 @@ import path from "node:path" import test from "node:test" import { AUTOMATION_BRIDGE_PATH, + automationBridgeDirectory, automationBridgeDirectories, createAutomationBridgeRegistration, parseDeveloperAction, @@ -50,6 +51,23 @@ function closeServer(server: http.Server | undefined): Promise { return new Promise((resolve) => server?.close(() => resolve()) ?? resolve()) } +function isolateAutomationBridgeRegistry(root: string): () => void { + const previousLocalAppData = process.env.LOCALAPPDATA + const previousXdgRuntimeDir = process.env.XDG_RUNTIME_DIR + const previousWslDistroName = process.env.WSL_DISTRO_NAME + process.env.LOCALAPPDATA = root + process.env.XDG_RUNTIME_DIR = root + delete process.env.WSL_DISTRO_NAME + return () => { + if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA + else process.env.LOCALAPPDATA = previousLocalAppData + if (previousXdgRuntimeDir === undefined) delete process.env.XDG_RUNTIME_DIR + else process.env.XDG_RUNTIME_DIR = previousXdgRuntimeDir + if (previousWslDistroName === undefined) delete process.env.WSL_DISTRO_NAME + else process.env.WSL_DISTRO_NAME = previousWslDistroName + } +} + test("validates Developer Mode actions", () => { assert.deepEqual(parseDeveloperAction({ action: "type", ref: "e4", text: "CodeNomad" }), { action: "type", @@ -100,8 +118,7 @@ test("removes only the generated legacy global plugin shim", async () => { test("restart waits for a new native generation and returns a fresh inspection", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-restart-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreBridgeRegistry = isolateAutomationBridgeRegistry(root) const definitions: ToolDefinition[] = [] let removeOld: (() => Promise) | undefined let removeNew: (() => Promise) | undefined @@ -162,16 +179,14 @@ test("restart waits for a new native generation and returns a fresh inspection", await closeServer(newServer) await closeServer(preexistingServer) await Promise.all(distractorServers.map(closeServer)) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreBridgeRegistry() await rm(root, { recursive: true, force: true }) } }) test("keeps inspected targets isolated per plugin setup", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-isolation-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreBridgeRegistry = isolateAutomationBridgeRegistry(root) let removeBridge: (() => Promise) | undefined let server: http.Server | undefined try { @@ -190,16 +205,14 @@ test("keeps inspected targets isolated per plugin setup", async () => { } finally { await removeBridge?.() await closeServer(server) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreBridgeRegistry() await rm(root, { recursive: true, force: true }) } }) test("pins parallel sessions to their independently inspected bridges", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-sessions-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreBridgeRegistry = isolateAutomationBridgeRegistry(root) const removals: Array<() => Promise> = [] const servers: http.Server[] = [] try { @@ -220,16 +233,14 @@ test("pins parallel sessions to their independently inspected bridges", async () } finally { await Promise.all(removals.map((remove) => remove())) await Promise.all(servers.map(closeServer)) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreBridgeRegistry() await rm(root, { recursive: true, force: true }) } }) test("prunes stale registry pressure before limiting discovery", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-stale-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreBridgeRegistry = isolateAutomationBridgeRegistry(root) let removeBridge: (() => Promise) | undefined let server: http.Server | undefined try { @@ -238,7 +249,7 @@ test("prunes stale registry pressure before limiting discovery", async () => { : { result: { target: { id: "live", title: "Live", url: "http://app.test" }, nodes: [], diagnostics: [] } }) server = bridge.server removeBridge = await publishAutomationBridge(createAutomationBridgeRegistration(bridge.url)) - const directory = path.join(root, "CodeNomad", "automation-bridges") + const directory = automationBridgeDirectory() const base = Date.now() + 10_000 for (let index = 0; index < 70; index += 1) { const startedAt = base + index @@ -257,8 +268,7 @@ test("prunes stale registry pressure before limiting discovery", async () => { } finally { await removeBridge?.() await closeServer(server) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreBridgeRegistry() await rm(root, { recursive: true, force: true }) } }) diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index e8809887a..cc8bd2139 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -130,7 +130,7 @@ async function harness( return canonical.includes("/worktree") ? "workspace:worktree" : "workspace:root" }, getServicePathForPath: async (_id, candidate) => { - assert.ok(pathOwnershipChecks.includes(candidate), "prompt path must be ownership-checked before translation") + assert.ok(pathOwnershipChecks.includes(candidate), "path must be ownership-checked before translation") servicePathCalls.push(candidate) return pathMappings[candidate] ?? candidate }, @@ -187,8 +187,13 @@ describe("instance proxy location enforcement", () => { method: "GET", url: "/workspaces/workspace/instance/api/session?directory=%2Fother", }) + const activationResponse = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/plugin/await-activation?location%5Bdirectory%5D=%2Fother", + }) assert.equal(bodyResponse.statusCode, 403) assert.equal(queryResponse.statusCode, 403) + assert.equal(activationResponse.statusCode, 403) assert.equal(requestCount(), 0) assert.doesNotMatch(bodyResponse.body, /internal-secret/) }) @@ -686,6 +691,9 @@ describe("instance proxy location enforcement", () => { })).statusCode, 403) assert.equal((await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/permission/saved" })).statusCode, 403) assert.equal((await app.inject({ method: "DELETE", url: "/workspaces/workspace/instance/api/permission/saved/global-rule" })).statusCode, 403) + for (const route of ["plugin/check", "plugin/update", "rpc/plugin/method"]) { + assert.equal((await app.inject({ method: "POST", url: `/workspaces/workspace/instance/api/${route}` })).statusCode, 403) + } assert.equal(requestCount(), 0) }) @@ -701,6 +709,7 @@ describe("instance proxy location enforcement", () => { ["GET", "/workspaces/workspace/instance/api/reference"], ["GET", "/workspaces/workspace/instance/api/mcp/resource"], ["GET", "/workspaces/workspace/instance/api/websearch/provider"], + ["POST", "/workspaces/workspace/instance/api/plugin/await-activation"], ["DELETE", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1"], ["POST", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1/steer"], ["POST", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1/queue"], @@ -773,6 +782,59 @@ describe("instance proxy location enforcement", () => { assert.equal(requestCount(), 1) }) + it("bounds filesystem list targets to owned worktrees before translating them", async () => { + const mappings = { + "/repo/sibling": "/home/dev/repo/sibling", + "/repo/worktree/src": "/home/dev/worktree/src", + } + const { app, servicePathCalls, requestCount } = await harness( + "/repo/worktree", + {}, + {}, + "/repo", + "/repo", + mappings, + ) + + const owned = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/fs/list?location%5Bdirectory%5D=%2Frepo%2Fworktree&path=src", + }) + assert.equal(owned.statusCode, 200) + const upstreamUrl = new URL(JSON.parse(owned.body).url, "http://upstream") + assert.equal(upstreamUrl.pathname, "/api/fs/list") + assert.equal(upstreamUrl.searchParams.get("location[directory]"), "/repo/worktree") + assert.equal(upstreamUrl.searchParams.get("path"), "/home/dev/worktree/src") + assert.deepEqual(servicePathCalls, ["/repo/worktree/src"]) + assert.equal(requestCount(), 1) + + const sibling = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/fs/list?location%5Bdirectory%5D=%2Frepo%2Fworktree&path=..%2Fsibling", + }) + assert.equal(sibling.statusCode, 200) + assert.equal(new URL(JSON.parse(sibling.body).url, "http://upstream").searchParams.get("path"), "/home/dev/repo/sibling") + + for (const pathValue of ["../../../other", "/other"]) { + const foreign = await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/api/fs/list?path=${encodeURIComponent(pathValue)}`, + }) + assert.equal(foreign.statusCode, 403) + } + const duplicate = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/fs/list?path=src&path=test", + }) + assert.equal(duplicate.statusCode, 400) + const nul = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/fs/list?path=src%00secret", + }) + assert.equal(nul.statusCode, 400) + assert.equal(requestCount(), 2) + }) + it("defaults and validates only schema-defined imported session locations", async () => { const { app, requestCount } = await harness() const accepted = await app.inject({ diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 19d1d8b53..5dd688c65 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -761,6 +761,25 @@ async function proxyWorkspaceRequest(args: { } translatedDirectories.set(directory, translated) } + const fileListPath = readFileListPath(targetUrl, request.method, requestLocations.directories[0] ?? workspace.path) + if (fileListPath.invalid) { + reply.code(400).send({ error: "Invalid filesystem path" }) + return + } + if (fileListPath.candidate) { + if (!(await workspaceManager.ownsPath(workspaceId, fileListPath.candidate))) { + reply.code(403).send({ error: "Filesystem path does not belong to workspace" }) + return + } + const translated = workspaceManager.getServicePathForPath + ? await workspaceManager.getServicePathForPath(workspaceId, fileListPath.candidate) + : fileListPath.candidate + if (!translated) { + reply.code(403).send({ error: "Filesystem path does not belong to workspace" }) + return + } + targetUrl.searchParams.set("path", translated) + } const mutationIdentities = new Set() if (request.method !== "GET" && request.method !== "HEAD") { for (const directory of requestLocations.directories) { @@ -1012,6 +1031,30 @@ function readNativeCwd( else locations.invalid = true } +function readFileListPath( + targetUrl: URL, + method: string, + defaultDirectory: string, +): { candidate?: string; invalid: boolean } { + if (method !== "GET" || targetUrl.pathname.replace(/\/+$/, "") !== "/api/fs/list") { + return { invalid: false } + } + const values = targetUrl.searchParams.getAll("path") + if (values.length > 1) return { invalid: true } + const requested = values[0]?.trim() || "." + if (requested.includes("\0")) return { invalid: true } + const windowsBase = /^[A-Za-z]:[\\/]/.test(defaultDirectory) || /^[\\/]{2}[^\\/]/.test(defaultDirectory) + if (!windowsBase && path.win32.isAbsolute(requested) && !path.posix.isAbsolute(requested)) { + return { candidate: path.win32.normalize(requested), invalid: false } + } + return { + candidate: windowsBase + ? path.win32.resolve(defaultDirectory, requested) + : path.posix.resolve(defaultDirectory, requested), + invalid: false, + } +} + function sanitizeInstanceProxyRequestHeaders( headers: Record, authorization: string | undefined, @@ -1243,6 +1286,7 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { const route = pathname.replace(/\/+$/, "") const allowed: Array<[string, RegExp]> = [ ["GET", /^\/api\/(?:agent|command|config|integration|location|mcp|model|plugin|provider|reference|skill)$/], + ["POST", /^\/api\/plugin\/await-activation$/], ["GET", /^\/api\/(?:mcp\/resource|websearch\/provider)$/], ["GET", /^\/api\/agent\/[^/]+$/], ["GET", /^\/api\/model\/default$/], diff --git a/packages/tauri-app/src-tauri/src/client_state/cross_host.rs b/packages/tauri-app/src-tauri/src/client_state/cross_host.rs index 749d60960..c68b10ad5 100644 --- a/packages/tauri-app/src-tauri/src/client_state/cross_host.rs +++ b/packages/tauri-app/src-tauri/src/client_state/cross_host.rs @@ -924,7 +924,7 @@ fn process_start_identity(pid: u32) -> Option { "-NoProfile", "-NonInteractive", "-Command", - &format!("(Get-CimInstance Win32_Process -Filter \"ProcessId = {pid}\" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks"), + &format!("(Get-Process -Id {pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks"), ], ) .map(|value| format!("win32:{value}")) diff --git a/packages/tauri-app/src-tauri/src/preferences_window.rs b/packages/tauri-app/src-tauri/src/preferences_window.rs index 7403673c2..aff36c1fb 100644 --- a/packages/tauri-app/src-tauri/src/preferences_window.rs +++ b/packages/tauri-app/src-tauri/src/preferences_window.rs @@ -212,7 +212,13 @@ pub(crate) async fn open_preferences_window( toggle: Option, ) -> Result<(), String> { crate::require_local_app_window(&window, &app_state)?; - open_preferences(&app, &app_state, &preferences, request) + open_preferences( + &app, + &app_state, + &preferences, + request, + toggle.unwrap_or(false), + ) } fn open_preferences( @@ -220,6 +226,7 @@ fn open_preferences( app_state: &AppState, preferences: &PreferencesWindow, request: PreferencesRequest, + toggle: bool, ) -> Result<(), String> { let request = validate_request(request)?; let _operation = preferences @@ -227,10 +234,12 @@ fn open_preferences( .lock() .unwrap_or_else(|error| error.into_inner()); if let Some(existing) = app.get_webview_window(LABEL) { - if toggle.unwrap_or(false) { + if toggle { existing.close().map_err(|error| error.to_string())?; return Ok(()); } + app.state::() + .set_preferences(Some(request.clone()))?; let renderer_ready = preferences .state .lock() @@ -315,7 +324,7 @@ pub(crate) fn navigate_backend(app: &AppHandle) { { let app_state = app.state::(); let preferences = app.state::(); - if let Err(error) = open_preferences(app, &app_state, &preferences, request) { + if let Err(error) = open_preferences(app, &app_state, &preferences, request, false) { eprintln!("[tauri] failed to restore preferences window: {error}"); } } diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 102a5ea97..67439719e 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -698,13 +698,13 @@ const App: Component = () => {
-
+

{t("app.launchError.binaryPathLabel")}

{launchErrorPath()}

-
+

{t("app.launchError.errorOutputLabel")}

{launchErrorMessage()}
diff --git a/packages/ui/src/components/agent-selector.tsx b/packages/ui/src/components/agent-selector.tsx index 78e9c85e5..14b536be3 100644 --- a/packages/ui/src/components/agent-selector.tsx +++ b/packages/ui/src/components/agent-selector.tsx @@ -32,6 +32,7 @@ export default function AgentSelector(props: AgentSelectorProps) { return getSelectableAgentsForSession(instanceAgents(), props.currentAgent, isChildSession()) }) const selectedAgent = createMemo(() => findAgentById(availableAgents(), props.currentAgent)) + const accessibleLabel = () => t("agentSelector.trigger.primary", { agent: selectedAgent()?.name || t("agentSelector.none") }) const [isOpen, setIsOpen] = createSignal(false) let searchInputRef: HTMLInputElement | undefined @@ -95,7 +96,7 @@ export default function AgentSelector(props: AgentSelectorProps) { > - +
diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index 20c8b9899..5f3897917 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -119,7 +119,7 @@ const AlertDialog: Component = () => {
= (props) => { -
+
{props.lockedBaseLabel}
void handleGo(event)}> = (props) => {