From 9f8abf5f1298aec1e5d5a348816fe550ea5ef827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 20 Jul 2026 13:47:11 +0200 Subject: [PATCH 1/7] feat(settings): reorganize preferences by workflow Replace the broad settings groups with General, Chat, Notifications, Voice, Connections, Runtime, Advanced, and About so preferences are easier to find. Keep general interaction controls in General, speech input in Voice, and desktop transport configuration in Advanced. Reuse shared behavior rows, locale selection, and remote-server workflows while preserving existing preference keys and dirty-state guards. Provider management now identifies the exact ready instance, and remote-server removal controls expose server-specific accessible names. Validated with the UI typecheck, production UI build, diff checks, final review, and a Tauri release build without bundling. --- packages/ui/src/App.tsx | 2 +- .../src/components/folder-selection-view.tsx | 272 +---------- packages/ui/src/components/instance-tabs.tsx | 4 +- .../ui/src/components/locale-selector.tsx | 69 +++ .../src/components/remote-server-dialog.tsx | 80 ++++ .../ui/src/components/settings-screen.tsx | 55 +-- ...tion.tsx => advanced-settings-section.tsx} | 97 ++-- .../settings/appearance-settings-section.tsx | 437 ------------------ .../settings/behavior-setting-rows.tsx | 153 ++++++ .../settings/chat-settings-section.tsx | 210 +++++++++ .../settings/connections-settings-section.tsx | 65 +++ .../settings/general-settings-section.tsx | 85 ++++ .../settings/runtime-settings-section.tsx | 45 ++ .../settings/saved-remote-servers-card.tsx | 67 +++ .../settings/speech-settings-section.tsx | 15 +- .../lib/hooks/use-remote-server-profiles.ts | 77 +++ .../ui/src/lib/i18n/messages/de/settings.ts | 11 + .../ui/src/lib/i18n/messages/en/settings.ts | 11 + .../ui/src/lib/i18n/messages/es/settings.ts | 11 + .../ui/src/lib/i18n/messages/fr/settings.ts | 11 + .../ui/src/lib/i18n/messages/he/settings.ts | 11 + .../ui/src/lib/i18n/messages/ja/settings.ts | 11 + .../ui/src/lib/i18n/messages/ne/settings.ts | 11 + .../ui/src/lib/i18n/messages/ru/settings.ts | 11 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 11 + packages/ui/src/stores/settings-screen.ts | 18 +- .../components/config-files-settings.css | 5 + 27 files changed, 1063 insertions(+), 792 deletions(-) create mode 100644 packages/ui/src/components/locale-selector.tsx create mode 100644 packages/ui/src/components/remote-server-dialog.tsx rename packages/ui/src/components/settings/{opencode-settings-section.tsx => advanced-settings-section.tsx} (60%) delete mode 100644 packages/ui/src/components/settings/appearance-settings-section.tsx create mode 100644 packages/ui/src/components/settings/behavior-setting-rows.tsx create mode 100644 packages/ui/src/components/settings/chat-settings-section.tsx create mode 100644 packages/ui/src/components/settings/connections-settings-section.tsx create mode 100644 packages/ui/src/components/settings/general-settings-section.tsx create mode 100644 packages/ui/src/components/settings/runtime-settings-section.tsx create mode 100644 packages/ui/src/components/settings/saved-remote-servers-card.tsx create mode 100644 packages/ui/src/lib/hooks/use-remote-server-profiles.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 189957c7f..cea0b2917 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -327,7 +327,7 @@ const App: Component = () => { function handleLaunchErrorAdvanced() { clearLaunchError() - openSettings("opencode") + openSettings("runtime") } function handleNewInstanceRequest() { diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 7c8652a68..eb36d6f7f 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -1,7 +1,6 @@ import { Dialog } from "@kobalte/core/dialog" -import { Select } from "@kobalte/core/select" import { Component, createMemo, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" -import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2, GitBranch, Pencil } from "lucide-solid" +import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, X, Globe, Loader2, GitBranch, Pencil } from "lucide-solid" import { useConfig } from "../stores/preferences" import DirectoryBrowserDialog from "./directory-browser-dialog" import Kbd from "./kbd" @@ -12,14 +11,16 @@ import VersionPill from "./version-pill" import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons" import { githubStars } from "../stores/github-stars" import { formatCompactCount } from "../lib/formatters" -import { useI18n, type Locale } from "../lib/i18n" +import { useI18n } from "../lib/i18n" import { showAlertDialog } from "../stores/alerts" import { openSettings, settingsOpen } from "../stores/settings-screen" import { openExternalUrl } from "../lib/external-url" import { serverApi } from "../lib/api-client" -import { canOpenRemoteWindows, isTauriHost } from "../lib/runtime-env" -import { openRemoteServerWindow } from "../lib/native/remote-window" +import { canOpenRemoteWindows } from "../lib/runtime-env" import { getExistingInstanceForFolder, updateProjectNameForFolder } from "../stores/instances" +import { LocaleSelector } from "./locale-selector" +import { RemoteServerDialog } from "./remote-server-dialog" +import { useRemoteServerProfiles } from "../lib/hooks/use-remote-server-profiles" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad" @@ -41,15 +42,10 @@ const FolderSelectionView: Component = (props) => { recentFolders, removeRecentFolder, renameRecentFolderProject, - preferences, - updatePreferences, serverSettings, - remoteServers, - saveRemoteServerProfile, - markRemoteServerConnected, - removeRemoteServerProfile, } = useConfig() - const { t, locale } = useI18n() + const { remoteServers, connectingServerId, saveServer, connectSavedServer, removeRemoteServerProfile } = useRemoteServerProfiles() + const { t } = useI18n() const [selectedIndex, setSelectedIndex] = createSignal(0) const [hoveredRecentActionPath, setHoveredRecentActionPath] = createSignal(null) const [focusedRecentActionPath, setFocusedRecentActionPath] = createSignal(null) @@ -67,32 +63,10 @@ const FolderSelectionView: Component = (props) => { const [isCloningRepository, setIsCloningRepository] = createSignal(false) const [activeTab, setActiveTab] = createSignal("local") const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false) - const [serverName, setServerName] = createSignal("") - const [serverUrl, setServerUrl] = createSignal("") - const [skipTlsVerify, setSkipTlsVerify] = createSignal(false) - const [serverDialogError, setServerDialogError] = createSignal(null) - const [isSavingServer, setIsSavingServer] = createSignal(false) - const [connectingServerId, setConnectingServerId] = createSignal(null) let homeRootRef: HTMLDivElement | undefined let actionsColumnRef: HTMLDivElement | undefined let recentListRef: HTMLDivElement | undefined - type LanguageOption = { value: Locale; label: string } - - const languageOptions: LanguageOption[] = [ - { value: "en", label: "English" }, - { value: "es", label: "Español" }, - { value: "fr", label: "Français" }, - { value: "ru", label: "Русский" }, - { value: "ja", label: "日本語" }, - { value: "zh-Hans", label: "简体中文" }, - { value: "he", label: "עברית" }, - { value: "de", label: "Deutsch" }, - { value: "ne", label: "नेपाली" }, - ] - - const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0] - const folders = () => recentFolders() const serverList = () => remoteServers() const isLoading = () => Boolean(props.isLoading) @@ -222,7 +196,7 @@ const FolderSelectionView: Component = (props) => { const server = serverList()[index] if (server) { - void handleConnectSavedServer(server.id) + void connectSavedServer(server.id) } } @@ -378,109 +352,11 @@ const FolderSelectionView: Component = (props) => { } } - function resetServerDialog() { - setServerName("") - setServerUrl("") - setSkipTlsVerify(false) - setServerDialogError(null) - } - function openServerDialog() { if (!canUseRemoteServerWindows()) return - resetServerDialog() setIsServerDialogOpen(true) } - async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) { - if (openWindow && !canUseRemoteServerWindows()) { - throw new Error("Remote server windows can only be opened from a local desktop window") - } - - const trimmedName = input.name.trim() - const trimmedUrl = input.baseUrl.trim() - if (!trimmedName || !trimmedUrl) { - throw new Error(t("folderSelection.servers.dialog.errorRequired")) - } - - const probe = await serverApi.probeRemoteServer({ - baseUrl: trimmedUrl, - skipTlsVerify: input.skipTlsVerify, - }) - - if (!probe.ok) { - throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect")) - } - - const profile = await saveRemoteServerProfile({ - id: input.id, - name: trimmedName, - baseUrl: probe.normalizedUrl, - skipTlsVerify: input.skipTlsVerify, - }) - - if (openWindow) { - const remoteProxySession = - isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://") - ? await serverApi.createRemoteProxySession({ - baseUrl: profile.baseUrl, - skipTlsVerify: profile.skipTlsVerify, - }) - : undefined - - try { - await openRemoteServerWindow(profile, remoteProxySession?.windowUrl, remoteProxySession?.sessionId) - } catch (error) { - if (remoteProxySession) { - void serverApi.deleteRemoteProxySession(remoteProxySession.sessionId).catch(() => {}) - } - throw error - } - - await markRemoteServerConnected(profile.id) - } - - return profile - } - - async function handleSaveServer(openWindow: boolean) { - if (isSavingServer()) return - setIsSavingServer(true) - setServerDialogError(null) - try { - await probeAndOpenServer( - { - name: serverName(), - baseUrl: serverUrl(), - skipTlsVerify: skipTlsVerify(), - }, - openWindow, - ) - setIsServerDialogOpen(false) - resetServerDialog() - } catch (error) { - setServerDialogError(error instanceof Error ? error.message : String(error)) - } finally { - setIsSavingServer(false) - } - } - - async function handleConnectSavedServer(id: string) { - if (!canUseRemoteServerWindows()) return - const target = remoteServers().find((entry) => entry.id === id) - if (!target || connectingServerId()) return - setConnectingServerId(id) - try { - await probeAndOpenServer(target, true) - } catch (error) { - showAlertDialog(error instanceof Error ? error.message : String(error), { - title: t("folderSelection.servers.errorTitle"), - variant: "warning", - }) - } finally { - setConnectingServerId(null) - } - } - async function handleBrowse() { if (isLoading()) return setFocusMode("new") @@ -647,54 +523,13 @@ const FolderSelectionView: Component = (props) => { aria-busy={isLoading() ? "true" : "false"} >
- - value={selectedLanguageOption()} - onChange={(value) => { - if (!value) return - if (value.value === locale()) return - updatePreferences({ locale: value.value }) - }} - options={languageOptions} - optionValue="value" - optionTextValue="label" - itemComponent={(itemProps) => ( - - {itemProps.item.rawValue.label} - - )} - > - - - - - - - - - +
@@ -1143,7 +979,7 @@ const FolderSelectionView: Component = (props) => { {/* OpenCode settings section */}
- - - -
- -
- - + ) } diff --git a/packages/ui/src/components/instance-tabs.tsx b/packages/ui/src/components/instance-tabs.tsx index 548667085..57e5899cf 100644 --- a/packages/ui/src/components/instance-tabs.tsx +++ b/packages/ui/src/components/instance-tabs.tsx @@ -226,7 +226,7 @@ const InstanceTabs: Component = (props) => { + + + + + + + + ) +} diff --git a/packages/ui/src/components/settings-screen.tsx b/packages/ui/src/components/settings-screen.tsx index 989bfe5b2..ce12b0d48 100644 --- a/packages/ui/src/components/settings-screen.tsx +++ b/packages/ui/src/components/settings-screen.tsx @@ -1,6 +1,6 @@ import { Dialog } from "@kobalte/core/dialog" import { Select } from "@kobalte/core/select" -import { Settings, Bell, ChevronDown, FileCog, Info, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid" +import { Settings, Bell, ChevronDown, FileCog, Info, MessageSquare, SlidersHorizontal, Terminal, Volume2, Wifi, X } from "lucide-solid" import { createMemo, For, type Component } from "solid-js" import { useI18n } from "../lib/i18n" import { @@ -10,15 +10,14 @@ import { setActiveSettingsSection, type SettingsSectionId, } from "../stores/settings-screen" -import { AppearanceSettingsSection } from "./settings/appearance-settings-section" +import { GeneralSettingsSection } from "./settings/general-settings-section" +import { ChatSettingsSection } from "./settings/chat-settings-section" import { InfoSettingsSection } from "./settings/info-settings-section" import { NotificationsSettingsSection } from "./settings/notifications-settings-section" -import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" -import { ConfigFilesSettingsSection } from "./settings/config-files-settings-section" -import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" import { SpeechSettingsSection } from "./settings/speech-settings-section" -import { SideCarsSettingsSection } from "./settings/sidecars-settings-section" -import { canOpenRemoteWindows } from "../lib/runtime-env" +import { ConnectionsSettingsSection } from "./settings/connections-settings-section" +import { RuntimeSettingsSection } from "./settings/runtime-settings-section" +import { AdvancedSettingsSection } from "./settings/advanced-settings-section" import { confirmSettingsDiscard } from "../stores/settings-dirty-guard" type SettingsSectionOption = { @@ -32,19 +31,15 @@ export const SettingsScreen: Component = () => { const sections = createMemo(() => { const items: SettingsSectionOption[] = [ - { id: "appearance", icon: Paintbrush, label: t("settings.nav.appearance") }, + { id: "general", icon: SlidersHorizontal, label: t("settings.nav.general") }, + { id: "chat", icon: MessageSquare, label: t("settings.nav.chat") }, { id: "notifications", icon: Bell, label: t("settings.nav.notifications") }, - { id: "speech", icon: Volume2, label: t("settings.nav.speech") }, - { id: "sidecars", icon: Globe, label: t("settings.nav.sidecars") }, - { id: "opencode", icon: Terminal, label: t("settings.nav.opencode") }, - { id: "config-files", icon: FileCog, label: t("settings.nav.configFiles") }, - { id: "info", icon: Info, label: t("settings.nav.info") }, + { id: "voice", icon: Volume2, label: t("settings.nav.voice") }, + { id: "connections", icon: Wifi, label: t("settings.nav.connections") }, + { id: "runtime", icon: Terminal, label: t("settings.nav.runtime") }, + { id: "advanced", icon: FileCog, label: t("settings.nav.advanced") }, + { id: "about", icon: Info, label: t("settings.nav.about") }, ] - - if (canOpenRemoteWindows()) { - items.splice(2, 0, { id: "remote", icon: MonitorUp, label: t("settings.nav.remote") }) - } - return items }) @@ -52,23 +47,23 @@ export const SettingsScreen: Component = () => { const renderSection = () => { switch (activeSettingsSection()) { + case "chat": + return case "notifications": return - case "remote": - return canOpenRemoteWindows() ? : - case "speech": + case "voice": return - case "sidecars": - return - case "opencode": - return - case "config-files": - return - case "info": + case "connections": + return + case "runtime": + return + case "advanced": + return + case "about": return - case "appearance": + case "general": default: - return + return } } diff --git a/packages/ui/src/components/settings/opencode-settings-section.tsx b/packages/ui/src/components/settings/advanced-settings-section.tsx similarity index 60% rename from packages/ui/src/components/settings/opencode-settings-section.tsx rename to packages/ui/src/components/settings/advanced-settings-section.tsx index 0f8799aca..1c601ade6 100644 --- a/packages/ui/src/components/settings/opencode-settings-section.tsx +++ b/packages/ui/src/components/settings/advanced-settings-section.tsx @@ -1,22 +1,20 @@ import { Select } from "@kobalte/core/select" -import { createEffect, createMemo, createSignal, type Component } from "solid-js" -import { ChevronDown, Terminal } from "lucide-solid" -import OpenCodeBinarySelector from "../opencode-binary-selector" -import EnvironmentVariablesEditor from "../environment-variables-editor" -import { useConfig } from "../../stores/preferences" -import type { ServerLogLevel } from "../../stores/preferences" +import { ChevronDown } from "lucide-solid" +import { createMemo, Show, type Component } from "solid-js" import { useI18n } from "../../lib/i18n" -import { OpenCodeUpdateCard } from "./opencode-update-card" +import { getBehaviorSettings } from "../../lib/settings/behavior-registry" +import { isTauriHost } from "../../lib/runtime-env" +import { useConfig, type ServerLogLevel } from "../../stores/preferences" +import EnvironmentVariablesEditor from "../environment-variables-editor" +import { BehaviorSettingRows } from "./behavior-setting-rows" +import { ConfigFilesSettingsSection } from "./config-files-settings-section" -type LogLevelOption = { - value: ServerLogLevel - label: string -} +type LogLevelOption = { value: ServerLogLevel; label: string } -export const OpenCodeSettingsSection: Component = () => { +export const AdvancedSettingsSection: Component = () => { const { t } = useI18n() - const { serverSettings, updateLastUsedBinary, updateLogLevel } = useConfig() - const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") + const config = useConfig() + const { serverSettings, updateLogLevel } = config const logLevelOptions = createMemo(() => [ { value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") }, { value: "INFO", label: t("settings.opencode.logLevel.option.info") }, @@ -26,36 +24,12 @@ export const OpenCodeSettingsSection: Component = () => { const selectedLogLevel = createMemo( () => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0], ) - - createEffect(() => { - const binary = serverSettings().opencodeBinary || "opencode" - setSelectedBinary((current) => (current === binary ? current : binary)) - }) - - const handleBinaryChange = (binary: string) => { - setSelectedBinary(binary) - updateLastUsedBinary(binary) - } + const transportSettings = createMemo(() => + getBehaviorSettings(config).filter((setting) => setting.id === "behavior.tauriNativeEventTransport"), + ) return (
-
-
-
- -
-

{t("settings.opencode.runtime.title")}

-

{t("settings.opencode.runtime.subtitle")}

-
-
- {t("settings.scope.server")} -
- - -
- - -
@@ -72,10 +46,7 @@ export const OpenCodeSettingsSection: Component = () => {
value={selectedLogLevel()} - onChange={(option) => { - if (!option) return - updateLogLevel(option.value) - }} + onChange={(option) => option && updateLogLevel(option.value)} options={logLevelOptions()} optionValue="value" optionTextValue="label" @@ -88,22 +59,13 @@ export const OpenCodeSettingsSection: Component = () => {
> - {(state) => ( - - {state.selectedOption()?.label} - - )} + {(state) => {state.selectedOption()?.label}}
- - - +
- - - - +
@@ -120,6 +82,27 @@ export const OpenCodeSettingsSection: Component = () => {
+ + +
+
+ +
+
+
+ +
+
+
+

{t("settings.configFiles.title")}

+

{t("settings.configFiles.subtitle")}

+
+ {t("settings.scope.server")} +
+
+ +
+
) } diff --git a/packages/ui/src/components/settings/appearance-settings-section.tsx b/packages/ui/src/components/settings/appearance-settings-section.tsx deleted file mode 100644 index cac4e62b4..000000000 --- a/packages/ui/src/components/settings/appearance-settings-section.tsx +++ /dev/null @@ -1,437 +0,0 @@ -import { Select } from "@kobalte/core/select" -import { createEffect, createMemo, createSignal, For, type Component } from "solid-js" -import { Check, ChevronDown, Laptop, Moon, Sun } from "lucide-solid" -import { useI18n } from "../../lib/i18n" -import { useTheme, type ThemeMode } from "../../lib/theme" -import { useConfig, type ExpansionPreference, type ToolCallExpansionPreset } from "../../stores/preferences" -import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry" -import { StartupStateSettingsCard } from "./startup-state-settings-card" -import { - buildToolExpansionPresetDefaults, - getConfigurableToolEntries, - OTHER_TOOL_NAME, - THINKING_EXPANSION_PRESETS, -} from "../tool-call/tool-registry" - -const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [ - { value: "system", icon: Laptop }, - { value: "light", icon: Sun }, - { value: "dark", icon: Moon }, -] - -const toolExpansionPresetOptions: ToolCallExpansionPreset[] = ["minimal", "balanced", "detailed", "everything"] - -export const AppearanceSettingsSection: Component = () => { - const { t } = useI18n() - const { themeMode, setThemeMode } = useTheme() - const { - preferences, - useTauriNativeEventTransport, - setUseTauriNativeEventTransport, - updatePreferences, - toggleShowThinkingBlocks, - toggleKeyboardShortcutHints, - toggleShowMessageTimeline, - toggleShowTimelineTools, - toggleUsageMetrics, - toggleAutoCleanupBlankSessions, - togglePromptSubmitOnEnter, - toggleShowPromptVoiceInput, - setDiffViewMode, - setToolOutputExpansion, - setDiagnosticsExpansion, - setThinkingBlocksExpansion, - setToolInputsVisibility, - } = useConfig() - const behaviorSettings = createMemo(() => - getBehaviorSettings({ - preferences, - useTauriNativeEventTransport, - setUseTauriNativeEventTransport, - updatePreferences, - toggleShowThinkingBlocks, - toggleKeyboardShortcutHints, - toggleShowMessageTimeline, - toggleShowTimelineTools, - toggleUsageMetrics, - toggleAutoCleanupBlankSessions, - togglePromptSubmitOnEnter, - toggleShowPromptVoiceInput, - setDiffViewMode, - setToolOutputExpansion, - setDiagnosticsExpansion, - setThinkingBlocksExpansion, - setToolInputsVisibility, - }).filter( - (setting) => setting.id !== "behavior.thinkingBlocksDefault" && setting.id !== "behavior.toolOutputsDefault", - ), - ) - - const [overrides, setOverrides] = createSignal>(new Map()) - - const setOverride = (id: string, value: unknown) => { - setOverrides((prev) => { - const next = new Map(prev) - next.set(id, value) - return next - }) - } - - createEffect(() => { - const current = overrides() - if (current.size === 0) return - - const prefs = preferences() - const settings = behaviorSettings() - - let changed = false - const next = new Map(current) - for (const setting of settings) { - if (!next.has(setting.id)) continue - const overrideValue = next.get(setting.id) - const actualValue = setting.get(prefs) - if (Object.is(actualValue, overrideValue)) { - next.delete(setting.id) - changed = true - } - } - - if (changed) { - setOverrides(next) - } - }) - - const readSettingValue = (setting: BehaviorSetting) => { - const current = overrides() - if (current.has(setting.id)) return current.get(setting.id) - return setting.get(preferences()) - } - - type SelectOption = { value: string; label: string } - - type ExpansionRow = - | { kind: "thinking"; key: "thinking"; label: string } - | { kind: "tool"; key: string; label: string } - - const expansionOptions = createMemo(() => [ - { value: "collapsed", label: t("commands.common.collapsed") }, - { value: "expanded", label: t("commands.common.expanded") }, - ]) - - const toolExpansionRows = createMemo(() => [ - { kind: "thinking", key: "thinking", label: t("settings.behavior.expansionDefaults.thinking") }, - ...getConfigurableToolEntries().map((entry) => ({ - kind: "tool" as const, - key: entry.tool, - label: entry.labelKey ? t(entry.labelKey) : entry.label, - })), - ]) - - const currentPreset = createMemo(() => preferences().toolCallExpansionDefaults.preset) - - const currentToolMode = (tool: string): ExpansionPreference => { - const pref = preferences().toolCallExpansionDefaults - const entry = getConfigurableToolEntries().find((item) => item.tool === tool) - if (pref.tools[tool]) return pref.tools[tool] - if (pref.preset !== "custom" && entry) return entry.expansionPresets[pref.preset] - return pref.tools[OTHER_TOOL_NAME] ?? "expanded" - } - - const currentThinkingMode = (): ExpansionPreference => { - const pref = preferences().toolCallExpansionDefaults - if (pref.thinking) return pref.thinking - if (pref.preset !== "custom") return THINKING_EXPANSION_PRESETS[pref.preset] - return preferences().thinkingBlocksExpansion ?? "expanded" - } - - const materializeToolModes = () => { - const tools: Record = {} - for (const entry of getConfigurableToolEntries()) { - tools[entry.tool] = currentToolMode(entry.tool) - } - return tools - } - - const applyExpansionPreset = (preset: ToolCallExpansionPreset) => { - const tools = buildToolExpansionPresetDefaults(preset) - const thinking = THINKING_EXPANSION_PRESETS[preset] - updatePreferences({ - toolCallExpansionDefaults: { preset, thinking, tools }, - thinkingBlocksExpansion: thinking, - toolOutputExpansion: tools[OTHER_TOOL_NAME] ?? "expanded", - }) - } - - const setExpansionRowMode = (row: ExpansionRow, mode: ExpansionPreference) => { - const tools = materializeToolModes() - const thinking = row.kind === "thinking" ? mode : currentThinkingMode() - if (row.kind === "tool") { - tools[row.key] = mode - } - updatePreferences({ - toolCallExpansionDefaults: { preset: "custom", thinking, tools }, - thinkingBlocksExpansion: thinking, - toolOutputExpansion: tools[OTHER_TOOL_NAME] ?? preferences().toolOutputExpansion, - }) - } - - const rowMode = (row: ExpansionRow): ExpansionPreference => - row.kind === "thinking" ? currentThinkingMode() : currentToolMode(row.key) - - const selectedExpansionOption = (mode: ExpansionPreference) => - expansionOptions().find((opt) => opt.value === mode) - - const BehaviorRow: Component<{ setting: BehaviorSetting }> = (props) => { - const setting = props.setting - const disabled = createMemo(() => (setting.disabled ? Boolean(setting.disabled()) : false)) - - if (setting.kind === "toggle") { - const options = createMemo(() => [ - { value: "true", label: t("settings.common.enabled") }, - { value: "false", label: t("settings.common.disabled") }, - ]) - const currentValue = createMemo(() => String(Boolean(readSettingValue(setting)))) - const selectedOption = createMemo(() => options().find((opt) => opt.value === currentValue())) - - return ( -
-
-
{t(setting.titleKey)}
-
{t(setting.subtitleKey)}
-
- - value={selectedOption()} - onChange={(opt) => { - if (!opt) return - const next = opt.value === "true" - setOverride(setting.id, next) - setting.set(next) - }} - options={options()} - optionValue="value" - optionTextValue="label" - disabled={disabled()} - itemComponent={(itemProps) => ( - - {itemProps.item.rawValue.label} - - )} - > - -
- > - {(state) => ( - - {state.selectedOption()?.label} - - )} - -
- - - -
- - - - - - - -
- ) - } - - const enumSetting = setting as Extract - const options = createMemo(() => - enumSetting.options.map((opt: { value: string; labelKey: string }) => ({ - value: String(opt.value), - label: t(opt.labelKey), - })), - ) - const currentValue = createMemo(() => String(readSettingValue(setting) ?? "")) - const selectedOption = createMemo(() => options().find((opt) => opt.value === currentValue())) - - return ( -
-
-
{t(setting.titleKey)}
-
{t(setting.subtitleKey)}
-
- - value={selectedOption()} - onChange={(opt) => { - if (!opt) return - setOverride(setting.id, opt.value) - enumSetting.set(opt.value as any) - }} - options={options()} - optionValue="value" - optionTextValue="label" - disabled={disabled()} - itemComponent={(itemProps) => ( - - {itemProps.item.rawValue.label} - - )} - > - -
- > - {(state) => ( - - {state.selectedOption()?.label} - - )} - -
- - - -
- - - - - - - -
- ) - } - - const modeLabel = (mode: ThemeMode) => { - if (mode === "system") return t("theme.mode.system") - if (mode === "light") return t("theme.mode.light") - return t("theme.mode.dark") - } - - const presetLabel = (preset: ToolCallExpansionPreset | "custom") => t(`settings.behavior.expansionPreset.${preset}.title`) - - return ( -
-
-
-
-

{t("settings.appearance.theme.title")}

-

{t("settings.appearance.theme.subtitle")}

-
- {t("settings.scope.device")} -
-
- {themeModeOptions.map((option) => { - const Icon = option.icon - return ( - - ) - })} -
-
- - - -
-
-
-

{t("settings.appearance.behavior.title")}

-

{t("settings.appearance.behavior.subtitle")}

-
- {presetLabel(currentPreset())} -
- -
- - {(preset) => ( - - )} - -
- -
-
- {t("settings.behavior.expansionDefaults.itemColumn")} - {t("settings.behavior.expansionDefaults.stateColumn")} -
- - {(row) => { - const selected = createMemo(() => selectedExpansionOption(rowMode(row))) - return ( -
-
- {row.label} -
-
- - value={selected()} - onChange={(opt) => { - if (!opt) return - setExpansionRowMode(row, opt.value as ExpansionPreference) - }} - options={expansionOptions()} - optionValue="value" - optionTextValue="label" - itemComponent={(itemProps) => ( - - {itemProps.item.rawValue.label} - - )} - > - -
- > - {(state) => ( - - {state.selectedOption()?.label} - - )} - -
- - - -
- - - - - - - -
-
- ) - }} -
-
- -
- {(setting) => } -
-
-
- ) -} diff --git a/packages/ui/src/components/settings/behavior-setting-rows.tsx b/packages/ui/src/components/settings/behavior-setting-rows.tsx new file mode 100644 index 000000000..bc5762069 --- /dev/null +++ b/packages/ui/src/components/settings/behavior-setting-rows.tsx @@ -0,0 +1,153 @@ +import { Select } from "@kobalte/core/select" +import { ChevronDown } from "lucide-solid" +import { createEffect, createMemo, createSignal, For, type Accessor, type Component } from "solid-js" +import { useI18n } from "../../lib/i18n" +import type { BehaviorSetting } from "../../lib/settings/behavior-registry" +import type { Preferences } from "../../stores/preferences" + +type SelectOption = { value: string; label: string } + +interface BehaviorSettingRowsProps { + settings: Accessor + preferences: Accessor +} + +export const BehaviorSettingRows: Component = (props) => { + const { t } = useI18n() + const [overrides, setOverrides] = createSignal>(new Map()) + + const setOverride = (id: string, value: unknown) => { + setOverrides((previous) => { + const next = new Map(previous) + next.set(id, value) + return next + }) + } + + createEffect(() => { + const current = overrides() + if (current.size === 0) return + + let changed = false + const next = new Map(current) + for (const setting of props.settings()) { + if (!next.has(setting.id)) continue + if (Object.is(setting.get(props.preferences()), next.get(setting.id))) { + next.delete(setting.id) + changed = true + } + } + + if (changed) setOverrides(next) + }) + + const readValue = (setting: BehaviorSetting) => { + const current = overrides() + return current.has(setting.id) ? current.get(setting.id) : setting.get(props.preferences()) + } + + const BehaviorRow: Component<{ setting: BehaviorSetting }> = (rowProps) => { + const setting = rowProps.setting + const disabled = createMemo(() => Boolean(setting.disabled?.())) + + if (setting.kind === "toggle") { + const options = createMemo(() => [ + { value: "true", label: t("settings.common.enabled") }, + { value: "false", label: t("settings.common.disabled") }, + ]) + const selectedOption = createMemo(() => options().find((option) => option.value === String(Boolean(readValue(setting))))) + + return ( +
+
+
{t(setting.titleKey)}
+
{t(setting.subtitleKey)}
+
+ + value={selectedOption()} + onChange={(option) => { + if (!option) return + const next = option.value === "true" + setOverride(setting.id, next) + setting.set(next) + }} + options={options()} + optionValue="value" + optionTextValue="label" + disabled={disabled()} + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + > + +
+ > + {(state) => {state.selectedOption()?.label}} + +
+ + + +
+ + + + + + +
+ ) + } + + const options = createMemo(() => + setting.options.map((option) => ({ value: String(option.value), label: t(option.labelKey) })), + ) + const selectedOption = createMemo(() => options().find((option) => option.value === String(readValue(setting) ?? ""))) + + return ( +
+
+
{t(setting.titleKey)}
+
{t(setting.subtitleKey)}
+
+ + value={selectedOption()} + onChange={(option) => { + if (!option) return + setOverride(setting.id, option.value) + setting.set(option.value) + }} + options={options()} + optionValue="value" + optionTextValue="label" + disabled={disabled()} + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + > + +
+ > + {(state) => {state.selectedOption()?.label}} + +
+ + + +
+ + + + + + +
+ ) + } + + return {(setting) => } +} diff --git a/packages/ui/src/components/settings/chat-settings-section.tsx b/packages/ui/src/components/settings/chat-settings-section.tsx new file mode 100644 index 000000000..ccb00faf6 --- /dev/null +++ b/packages/ui/src/components/settings/chat-settings-section.tsx @@ -0,0 +1,210 @@ +import { Select } from "@kobalte/core/select" +import { createMemo, For, type Component } from "solid-js" +import { ChevronDown } from "lucide-solid" +import { useI18n } from "../../lib/i18n" +import { useConfig, type ExpansionPreference, type ToolCallExpansionPreset } from "../../stores/preferences" +import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry" +import { + buildToolExpansionPresetDefaults, + getConfigurableToolEntries, + OTHER_TOOL_NAME, + THINKING_EXPANSION_PRESETS, +} from "../tool-call/tool-registry" +import { BehaviorSettingRows } from "./behavior-setting-rows" + +const toolExpansionPresetOptions: ToolCallExpansionPreset[] = ["minimal", "balanced", "detailed", "everything"] + +export const ChatSettingsSection: Component = () => { + const { t } = useI18n() + const config = useConfig() + const { preferences, updatePreferences } = config + const behaviorSettings = createMemo(() => [ + ...getBehaviorSettings(config).filter( + (setting) => + setting.id !== "behavior.keyboardShortcutHints" && + setting.id !== "behavior.tauriNativeEventTransport" && + setting.id !== "behavior.messageTimeline" && + setting.id !== "behavior.autoCleanupBlankSessions" && + setting.id !== "behavior.keepUnseenSubagentIdleStatus" && + setting.id !== "behavior.promptVoiceInput" && + setting.id !== "behavior.promptSubmitOnEnter" && + setting.id !== "behavior.thinkingBlocksDefault" && + setting.id !== "behavior.toolOutputsDefault", + ), + { + 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 }), + }, + ]) + + type SelectOption = { value: string; label: string } + + type ExpansionRow = + | { kind: "thinking"; key: "thinking"; label: string } + | { kind: "tool"; key: string; label: string } + + const expansionOptions = createMemo(() => [ + { value: "collapsed", label: t("commands.common.collapsed") }, + { value: "expanded", label: t("commands.common.expanded") }, + ]) + + const toolExpansionRows = createMemo(() => [ + { kind: "thinking", key: "thinking", label: t("settings.behavior.expansionDefaults.thinking") }, + ...getConfigurableToolEntries().map((entry) => ({ + kind: "tool" as const, + key: entry.tool, + label: entry.labelKey ? t(entry.labelKey) : entry.label, + })), + ]) + + const currentPreset = createMemo(() => preferences().toolCallExpansionDefaults.preset) + + const currentToolMode = (tool: string): ExpansionPreference => { + const pref = preferences().toolCallExpansionDefaults + const entry = getConfigurableToolEntries().find((item) => item.tool === tool) + if (pref.tools[tool]) return pref.tools[tool] + if (pref.preset !== "custom" && entry) return entry.expansionPresets[pref.preset] + return pref.tools[OTHER_TOOL_NAME] ?? "expanded" + } + + const currentThinkingMode = (): ExpansionPreference => { + const pref = preferences().toolCallExpansionDefaults + if (pref.thinking) return pref.thinking + if (pref.preset !== "custom") return THINKING_EXPANSION_PRESETS[pref.preset] + return preferences().thinkingBlocksExpansion ?? "expanded" + } + + const materializeToolModes = () => { + const tools: Record = {} + for (const entry of getConfigurableToolEntries()) { + tools[entry.tool] = currentToolMode(entry.tool) + } + return tools + } + + const applyExpansionPreset = (preset: ToolCallExpansionPreset) => { + const tools = buildToolExpansionPresetDefaults(preset) + const thinking = THINKING_EXPANSION_PRESETS[preset] + updatePreferences({ + toolCallExpansionDefaults: { preset, thinking, tools }, + thinkingBlocksExpansion: thinking, + toolOutputExpansion: tools[OTHER_TOOL_NAME] ?? "expanded", + }) + } + + const setExpansionRowMode = (row: ExpansionRow, mode: ExpansionPreference) => { + const tools = materializeToolModes() + const thinking = row.kind === "thinking" ? mode : currentThinkingMode() + if (row.kind === "tool") { + tools[row.key] = mode + } + updatePreferences({ + toolCallExpansionDefaults: { preset: "custom", thinking, tools }, + thinkingBlocksExpansion: thinking, + toolOutputExpansion: tools[OTHER_TOOL_NAME] ?? preferences().toolOutputExpansion, + }) + } + + const rowMode = (row: ExpansionRow): ExpansionPreference => + row.kind === "thinking" ? currentThinkingMode() : currentToolMode(row.key) + + const selectedExpansionOption = (mode: ExpansionPreference) => + expansionOptions().find((opt) => opt.value === mode) + + const presetLabel = (preset: ToolCallExpansionPreset | "custom") => t(`settings.behavior.expansionPreset.${preset}.title`) + + return ( +
+
+
+
+

{t("settings.appearance.behavior.title")}

+

{t("settings.appearance.behavior.subtitle")}

+
+ {presetLabel(currentPreset())} +
+ +
+ + {(preset) => ( + + )} + +
+ +
+
+ {t("settings.behavior.expansionDefaults.itemColumn")} + {t("settings.behavior.expansionDefaults.stateColumn")} +
+ + {(row) => { + const selected = createMemo(() => selectedExpansionOption(rowMode(row))) + return ( +
+
+ {row.label} +
+
+ + value={selected()} + onChange={(opt) => { + if (!opt) return + setExpansionRowMode(row, opt.value as ExpansionPreference) + }} + options={expansionOptions()} + optionValue="value" + optionTextValue="label" + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + > + +
+ > + {(state) => ( + + {state.selectedOption()?.label} + + )} + +
+ + + +
+ + + + + + + +
+
+ ) + }} +
+
+ +
+ +
+
+
+ ) +} diff --git a/packages/ui/src/components/settings/connections-settings-section.tsx b/packages/ui/src/components/settings/connections-settings-section.tsx new file mode 100644 index 000000000..504810c1b --- /dev/null +++ b/packages/ui/src/components/settings/connections-settings-section.tsx @@ -0,0 +1,65 @@ +import { PlugZap } from "lucide-solid" +import { createMemo, createSignal, Show, type Component } from "solid-js" +import { useI18n } from "../../lib/i18n" +import { canOpenRemoteWindows } from "../../lib/runtime-env" +import { activeInstanceId, instances } from "../../stores/instances" +import { ProviderManagerModal } from "../provider-auth/provider-manager-modal" +import { RemoteAccessSettingsSection } from "./remote-access-settings-section" +import { SavedRemoteServersCard } from "./saved-remote-servers-card" + +export const ConnectionsSettingsSection: Component = () => { + const { t } = useI18n() + const [providerManagerOpen, setProviderManagerOpen] = createSignal(false) + const providerInstance = createMemo(() => { + const id = activeInstanceId() + return id ? instances().get(id) ?? null : null + }) + const providerInstanceLabel = createMemo(() => { + const instance = providerInstance() + if (!instance) return "" + return `${instance.projectName || instance.folder} (${instance.id})` + }) + + return ( + <> +
+ + + + + +
+
+
+ +
+

{t("settings.providers.title")}

+

{t("settings.providers.subtitle")}

+

+ {providerInstance() + ? t("settings.providers.target", { workspace: providerInstanceLabel() }) + : t("settings.providers.empty.noInstance")} +

+
+
+ +
+
+
+ + + + ) +} diff --git a/packages/ui/src/components/settings/general-settings-section.tsx b/packages/ui/src/components/settings/general-settings-section.tsx new file mode 100644 index 000000000..91af6831b --- /dev/null +++ b/packages/ui/src/components/settings/general-settings-section.tsx @@ -0,0 +1,85 @@ +import { Check, Laptop, Moon, Sun } from "lucide-solid" +import { createMemo, type Component } from "solid-js" +import { useI18n } from "../../lib/i18n" +import { getBehaviorSettings } from "../../lib/settings/behavior-registry" +import { useTheme, type ThemeMode } from "../../lib/theme" +import { useConfig } from "../../stores/preferences" +import { LocaleSelector } from "../locale-selector" +import { BehaviorSettingRows } from "./behavior-setting-rows" +import { StartupStateSettingsCard } from "./startup-state-settings-card" + +const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [ + { value: "system", icon: Laptop }, + { value: "light", icon: Sun }, + { value: "dark", icon: Moon }, +] + +export const GeneralSettingsSection: Component = () => { + const { t } = useI18n() + const { themeMode, setThemeMode } = useTheme() + const config = useConfig() + const generalSettings = createMemo(() => + getBehaviorSettings(config).filter( + (setting) => + setting.id === "behavior.keyboardShortcutHints" || + setting.id === "behavior.messageTimeline" || + setting.id === "behavior.autoCleanupBlankSessions" || + setting.id === "behavior.keepUnseenSubagentIdleStatus" || + setting.id === "behavior.promptSubmitOnEnter", + ), + ) + + const modeLabel = (mode: ThemeMode) => { + if (mode === "system") return t("theme.mode.system") + if (mode === "light") return t("theme.mode.light") + return t("theme.mode.dark") + } + + return ( +
+
+
+
+

{t("settings.appearance.theme.title")}

+

{t("settings.appearance.theme.subtitle")}

+
+ {t("settings.scope.device")} +
+
+ {themeModeOptions.map((option) => { + const Icon = option.icon + return ( + + ) + })} +
+
+ +
+
+
+

{t("folderSelection.language.ariaLabel")}

+

{t("settings.general.language.subtitle")}

+
+ {t("settings.scope.device")} +
+ +
+ + + +
+
+ +
+
+
+ ) +} diff --git a/packages/ui/src/components/settings/runtime-settings-section.tsx b/packages/ui/src/components/settings/runtime-settings-section.tsx new file mode 100644 index 000000000..ef56c26e1 --- /dev/null +++ b/packages/ui/src/components/settings/runtime-settings-section.tsx @@ -0,0 +1,45 @@ +import { createEffect, createSignal, type Component } from "solid-js" +import { Terminal } from "lucide-solid" +import OpenCodeBinarySelector from "../opencode-binary-selector" +import { useConfig } from "../../stores/preferences" +import { useI18n } from "../../lib/i18n" +import { OpenCodeUpdateCard } from "./opencode-update-card" +import { SideCarsSettingsSection } from "./sidecars-settings-section" + +export const RuntimeSettingsSection: Component = () => { + const { t } = useI18n() + const { serverSettings, updateLastUsedBinary } = useConfig() + const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") + + createEffect(() => { + const binary = serverSettings().opencodeBinary || "opencode" + setSelectedBinary((current) => (current === binary ? current : binary)) + }) + + const handleBinaryChange = (binary: string) => { + setSelectedBinary(binary) + updateLastUsedBinary(binary) + } + + return ( +
+
+
+
+ +
+

{t("settings.opencode.runtime.title")}

+

{t("settings.opencode.runtime.subtitle")}

+
+
+ {t("settings.scope.server")} +
+ + +
+ + + +
+ ) +} diff --git a/packages/ui/src/components/settings/saved-remote-servers-card.tsx b/packages/ui/src/components/settings/saved-remote-servers-card.tsx new file mode 100644 index 000000000..139a0a530 --- /dev/null +++ b/packages/ui/src/components/settings/saved-remote-servers-card.tsx @@ -0,0 +1,67 @@ +import { Globe, Loader2, Plus, Trash2 } from "lucide-solid" +import { createSignal, For, Show, type Component } from "solid-js" +import { useI18n } from "../../lib/i18n" +import { useRemoteServerProfiles } from "../../lib/hooks/use-remote-server-profiles" +import { RemoteServerDialog } from "../remote-server-dialog" + +export const SavedRemoteServersCard: Component = () => { + const { t } = useI18n() + const { remoteServers, connectingServerId, saveServer, connectSavedServer, removeRemoteServerProfile } = useRemoteServerProfiles() + const [dialogOpen, setDialogOpen] = createSignal(false) + + return ( + <> +
+
+
+ +
+

{t("folderSelection.tabs.servers")}

+

{t("folderSelection.servers.empty.description")}

+
+
+ +
+
+ 0} fallback={
{t("folderSelection.servers.empty.title")}
}> + + {(server) => ( +
+
+
{server.name}
+
{server.baseUrl}
+
+
+ + +
+
+ )} +
+
+
+
+ + + ) +} diff --git a/packages/ui/src/components/settings/speech-settings-section.tsx b/packages/ui/src/components/settings/speech-settings-section.tsx index 939b279cc..681161e5a 100644 --- a/packages/ui/src/components/settings/speech-settings-section.tsx +++ b/packages/ui/src/components/settings/speech-settings-section.tsx @@ -1,9 +1,22 @@ -import type { Component } from "solid-js" +import { createMemo, type Component } from "solid-js" +import { getBehaviorSettings } from "../../lib/settings/behavior-registry" +import { useConfig } from "../../stores/preferences" +import { BehaviorSettingRows } from "./behavior-setting-rows" import SpeechSettingsCard from "./speech-settings-card" export const SpeechSettingsSection: Component = () => { + const config = useConfig() + const voiceSettings = createMemo(() => + getBehaviorSettings(config).filter((setting) => setting.id === "behavior.promptVoiceInput"), + ) + return (
+
+
+ +
+
) diff --git a/packages/ui/src/lib/hooks/use-remote-server-profiles.ts b/packages/ui/src/lib/hooks/use-remote-server-profiles.ts new file mode 100644 index 000000000..1fea22b11 --- /dev/null +++ b/packages/ui/src/lib/hooks/use-remote-server-profiles.ts @@ -0,0 +1,77 @@ +import { createSignal } from "solid-js" +import { serverApi } from "../api-client" +import { useI18n } from "../i18n" +import { openRemoteServerWindow } from "../native/remote-window" +import { canOpenRemoteWindows, isTauriHost } from "../runtime-env" +import { showAlertDialog } from "../../stores/alerts" +import { useConfig } from "../../stores/preferences" + +export type RemoteServerInput = { + id?: string + name: string + baseUrl: string + skipTlsVerify: boolean +} + +export function useRemoteServerProfiles() { + const { t } = useI18n() + const { remoteServers, saveRemoteServerProfile, markRemoteServerConnected, removeRemoteServerProfile } = useConfig() + const [connectingServerId, setConnectingServerId] = createSignal(null) + + const saveServer = async (input: RemoteServerInput, openWindow: boolean) => { + if (openWindow && !canOpenRemoteWindows()) { + throw new Error("Remote server windows can only be opened from a local desktop window") + } + + const name = input.name.trim() + const baseUrl = input.baseUrl.trim() + if (!name || !baseUrl) throw new Error(t("folderSelection.servers.dialog.errorRequired")) + + const probe = await serverApi.probeRemoteServer({ baseUrl, skipTlsVerify: input.skipTlsVerify }) + if (!probe.ok) throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect")) + + const profile = await saveRemoteServerProfile({ + id: input.id, + name, + baseUrl: probe.normalizedUrl, + skipTlsVerify: input.skipTlsVerify, + }) + + if (openWindow) { + const proxySession = + isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://") + ? await serverApi.createRemoteProxySession({ baseUrl: profile.baseUrl, skipTlsVerify: true }) + : undefined + + try { + await openRemoteServerWindow(profile, proxySession?.windowUrl, proxySession?.sessionId) + } catch (error) { + if (proxySession) void serverApi.deleteRemoteProxySession(proxySession.sessionId).catch(() => {}) + throw error + } + await markRemoteServerConnected(profile.id) + } + + return profile + } + + const connectSavedServer = async (id: string) => { + if (!canOpenRemoteWindows() || connectingServerId()) return + const target = remoteServers().find((server) => server.id === id) + if (!target) return + + setConnectingServerId(id) + try { + await saveServer(target, true) + } catch (error) { + showAlertDialog(error instanceof Error ? error.message : String(error), { + title: t("folderSelection.servers.errorTitle"), + variant: "warning", + }) + } finally { + setConnectingServerId(null) + } + } + + return { remoteServers, connectingServerId, saveServer, connectSavedServer, removeRemoteServerProfile } +} diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 233bd75e3..5e1ca4e9f 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -98,6 +98,16 @@ export const settingsMessages = { "settings.open.title": "Einstellungen öffnen", "settings.open.ariaLabel": "Einstellungen öffnen", "settings.nav.appearance": "Aussehen", + "settings.nav.general": "Allgemein", + "settings.nav.chat": "Chat", + "settings.nav.voice": "Sprache", + "settings.nav.connections": "Verbindungen", + "settings.nav.runtime": "Laufzeit", + "settings.nav.advanced": "Erweitert", + "settings.nav.about": "Über", + "settings.general.language.subtitle": "Wähle die Sprache für die gesamte Anwendung.", + "settings.behavior.holdLongAssistantReplies.title": "Lange Assistentenantworten anhalten", + "settings.behavior.holdLongAssistantReplies.subtitle": "Automatisches Folgen beenden, wenn eine laufende Antwort über das Ansichtsfenster hinausgeht.", "settings.nav.notifications": "Benachrichtigungen", "settings.nav.remote": "Remote-Zugriff", "settings.nav.speech": "Sprache", @@ -200,6 +210,7 @@ export const settingsMessages = { "settings.configFiles.confirmDiscard.cancelLabel": "Weiter bearbeiten", "settings.providers.title": "Anbieter-Authentifizierung", + "settings.providers.target": "OpenCode-Instanz: {workspace}", "settings.providers.subtitle": "API-Schlüssel oder OAuth-Konten für die aktive OpenCode-Instanz verbinden.", "settings.providers.refresh": "Aktualisieren", "settings.providers.selectProvider": "Anbieter auswählen", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index cdd440122..6b1b060cc 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -98,6 +98,16 @@ export const settingsMessages = { "settings.open.title": "Open settings", "settings.open.ariaLabel": "Open settings", "settings.nav.appearance": "Appearance", + "settings.nav.general": "General", + "settings.nav.chat": "Chat", + "settings.nav.voice": "Voice", + "settings.nav.connections": "Connections", + "settings.nav.runtime": "Runtime", + "settings.nav.advanced": "Advanced", + "settings.nav.about": "About", + "settings.general.language.subtitle": "Choose the language used throughout the application.", + "settings.behavior.holdLongAssistantReplies.title": "Hold long assistant replies", + "settings.behavior.holdLongAssistantReplies.subtitle": "Stop following automatically when a streaming reply grows beyond the viewport.", "settings.nav.notifications": "Notifications", "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", @@ -200,6 +210,7 @@ export const settingsMessages = { "settings.configFiles.confirmDiscard.cancelLabel": "Keep editing", "settings.providers.title": "Provider Authentication", + "settings.providers.target": "OpenCode instance: {workspace}", "settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.", "settings.providers.refresh": "Refresh", "settings.providers.selectProvider": "Select provider", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 021bae380..75bc94617 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -98,6 +98,16 @@ export const settingsMessages = { "settings.open.title": "Abrir configuración", "settings.open.ariaLabel": "Abrir configuración", "settings.nav.appearance": "Apariencia", + "settings.nav.general": "General", + "settings.nav.chat": "Chat", + "settings.nav.voice": "Voz", + "settings.nav.connections": "Conexiones", + "settings.nav.runtime": "Entorno de ejecución", + "settings.nav.advanced": "Avanzado", + "settings.nav.about": "Acerca de", + "settings.general.language.subtitle": "Elige el idioma utilizado en toda la aplicación.", + "settings.behavior.holdLongAssistantReplies.title": "Retener respuestas largas del asistente", + "settings.behavior.holdLongAssistantReplies.subtitle": "Deja de seguir automáticamente cuando una respuesta en curso supera la ventana.", "settings.nav.notifications": "Notificaciones", "settings.nav.remote": "Acceso remoto", "settings.nav.speech": "Voz", @@ -200,6 +210,7 @@ export const settingsMessages = { "settings.configFiles.confirmDiscard.cancelLabel": "Seguir editando", "settings.providers.title": "Autenticación de proveedores", + "settings.providers.target": "Instancia de OpenCode: {workspace}", "settings.providers.subtitle": "Conecta claves API o cuentas OAuth para la instancia activa de OpenCode.", "settings.providers.refresh": "Actualizar", "settings.providers.selectProvider": "Seleccionar proveedor", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 2d1aacaa0..fc5ac6964 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -98,6 +98,16 @@ export const settingsMessages = { "settings.open.title": "Ouvrir les paramètres", "settings.open.ariaLabel": "Ouvrir les paramètres", "settings.nav.appearance": "Apparence", + "settings.nav.general": "Général", + "settings.nav.chat": "Chat", + "settings.nav.voice": "Voix", + "settings.nav.connections": "Connexions", + "settings.nav.runtime": "Exécution", + "settings.nav.advanced": "Avancé", + "settings.nav.about": "À propos", + "settings.general.language.subtitle": "Choisissez la langue utilisée dans toute l'application.", + "settings.behavior.holdLongAssistantReplies.title": "Suspendre le suivi des longues réponses", + "settings.behavior.holdLongAssistantReplies.subtitle": "Arrêter le suivi automatique lorsqu'une réponse en cours dépasse la fenêtre.", "settings.nav.notifications": "Notifications", "settings.nav.remote": "Accès distant", "settings.nav.speech": "Voix", @@ -200,6 +210,7 @@ export const settingsMessages = { "settings.configFiles.confirmDiscard.cancelLabel": "Continuer la modification", "settings.providers.title": "Authentification des fournisseurs", + "settings.providers.target": "Instance OpenCode : {workspace}", "settings.providers.subtitle": "Connectez des clés API ou des comptes OAuth pour l'instance OpenCode active.", "settings.providers.refresh": "Actualiser", "settings.providers.selectProvider": "Sélectionner un fournisseur", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 741dbd089..a52b18b60 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -98,6 +98,16 @@ export const settingsMessages = { "settings.open.title": "פתח הגדרות", "settings.open.ariaLabel": "פתח הגדרות", "settings.nav.appearance": "מראה", + "settings.nav.general": "כללי", + "settings.nav.chat": "צ׳אט", + "settings.nav.voice": "קול", + "settings.nav.connections": "חיבורים", + "settings.nav.runtime": "זמן ריצה", + "settings.nav.advanced": "מתקדם", + "settings.nav.about": "אודות", + "settings.general.language.subtitle": "בחר את השפה שתשמש בכל היישום.", + "settings.behavior.holdLongAssistantReplies.title": "השהיית מעקב אחר תשובות ארוכות", + "settings.behavior.holdLongAssistantReplies.subtitle": "הפסק מעקב אוטומטי כאשר תשובה זורמת חורגת מחלון התצוגה.", "settings.nav.notifications": "התראות", "settings.nav.remote": "גישה מרוחקת", "settings.nav.speech": "קול", @@ -200,6 +210,7 @@ export const settingsMessages = { "settings.configFiles.confirmDiscard.cancelLabel": "המשך לערוך", "settings.providers.title": "אימות ספקים", + "settings.providers.target": "מופע OpenCode: {workspace}", "settings.providers.subtitle": "חבר מפתחות API או חשבונות OAuth עבור מופע OpenCode הפעיל.", "settings.providers.refresh": "רענן", "settings.providers.selectProvider": "בחר ספק", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 1d2448689..6a37cfd8d 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -98,6 +98,16 @@ export const settingsMessages = { "settings.open.title": "設定を開く", "settings.open.ariaLabel": "設定を開く", "settings.nav.appearance": "外観", + "settings.nav.general": "一般", + "settings.nav.chat": "チャット", + "settings.nav.voice": "音声", + "settings.nav.connections": "接続", + "settings.nav.runtime": "ランタイム", + "settings.nav.advanced": "詳細設定", + "settings.nav.about": "情報", + "settings.general.language.subtitle": "アプリケーション全体で使用する言語を選択します。", + "settings.behavior.holdLongAssistantReplies.title": "長いアシスタント応答を保持", + "settings.behavior.holdLongAssistantReplies.subtitle": "ストリーミング応答が画面を超えたときに自動追従を停止します。", "settings.nav.notifications": "通知", "settings.nav.remote": "リモートアクセス", "settings.nav.speech": "音声", @@ -200,6 +210,7 @@ export const settingsMessages = { "settings.configFiles.confirmDiscard.cancelLabel": "編集を続ける", "settings.providers.title": "プロバイダー認証", + "settings.providers.target": "OpenCode インスタンス: {workspace}", "settings.providers.subtitle": "アクティブな OpenCode インスタンスで使う API キーまたは OAuth アカウントを接続します。", "settings.providers.refresh": "更新", "settings.providers.selectProvider": "プロバイダーを選択", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 0da3e9cfd..edea8cfc7 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -98,6 +98,16 @@ export const settingsMessages = { "settings.open.title": "सेटिङहरू खोल्नुहोस्", "settings.open.ariaLabel": "सेटिङहरू खोल्नुहोस्", "settings.nav.appearance": "रूप (Appearance)", + "settings.nav.general": "सामान्य", + "settings.nav.chat": "च्याट", + "settings.nav.voice": "आवाज", + "settings.nav.connections": "जडानहरू", + "settings.nav.runtime": "रनटाइम", + "settings.nav.advanced": "उन्नत", + "settings.nav.about": "बारेमा", + "settings.general.language.subtitle": "सम्पूर्ण अनुप्रयोगमा प्रयोग हुने भाषा छान्नुहोस्।", + "settings.behavior.holdLongAssistantReplies.title": "लामो सहायक जवाफहरू रोक्नुहोस्", + "settings.behavior.holdLongAssistantReplies.subtitle": "स्ट्रिमिङ जवाफ दृश्यभन्दा बाहिर जाँदा स्वचालित पछ्याइ रोक्नुहोस्।", "settings.nav.notifications": "सूचनाहरू", "settings.nav.remote": "रिमोट पहुँच", "settings.nav.speech": "वाचन (Speech)", @@ -200,6 +210,7 @@ export const settingsMessages = { "settings.configFiles.confirmDiscard.cancelLabel": "सम्पादन जारी राख्नुहोस्", "settings.providers.title": "प्रदायक प्रमाणीकरण", + "settings.providers.target": "OpenCode इन्स्ट्यान्स: {workspace}", "settings.providers.subtitle": "सक्रिय OpenCode उदाहरणका लागि API कुञ्जीहरू वा OAuth खाताहरू जडान गर्नुहोस्।", "settings.providers.refresh": "रिफ्रेस", "settings.providers.selectProvider": "प्रदायक चयन गर्नुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 201da2216..9ac702c38 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -98,6 +98,16 @@ export const settingsMessages = { "settings.open.title": "Открыть настройки", "settings.open.ariaLabel": "Открыть настройки", "settings.nav.appearance": "Внешний вид", + "settings.nav.general": "Общие", + "settings.nav.chat": "Чат", + "settings.nav.voice": "Голос", + "settings.nav.connections": "Подключения", + "settings.nav.runtime": "Среда выполнения", + "settings.nav.advanced": "Расширенные", + "settings.nav.about": "О программе", + "settings.general.language.subtitle": "Выберите язык, используемый во всём приложении.", + "settings.behavior.holdLongAssistantReplies.title": "Удерживать длинные ответы ассистента", + "settings.behavior.holdLongAssistantReplies.subtitle": "Прекращать автоматическое следование, когда потоковый ответ выходит за пределы окна.", "settings.nav.notifications": "Уведомления", "settings.nav.remote": "Удалённый доступ", "settings.nav.speech": "Речь", @@ -200,6 +210,7 @@ export const settingsMessages = { "settings.configFiles.confirmDiscard.cancelLabel": "Продолжить редактирование", "settings.providers.title": "Аутентификация провайдеров", + "settings.providers.target": "Экземпляр OpenCode: {workspace}", "settings.providers.subtitle": "Подключите API-ключи или OAuth-аккаунты для активного экземпляра OpenCode.", "settings.providers.refresh": "Обновить", "settings.providers.selectProvider": "Выберите провайдера", 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 4aafaa980..995426f8e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -98,6 +98,16 @@ export const settingsMessages = { "settings.open.title": "打开设置", "settings.open.ariaLabel": "打开设置", "settings.nav.appearance": "外观", + "settings.nav.general": "常规", + "settings.nav.chat": "聊天", + "settings.nav.voice": "语音", + "settings.nav.connections": "连接", + "settings.nav.runtime": "运行时", + "settings.nav.advanced": "高级", + "settings.nav.about": "关于", + "settings.general.language.subtitle": "选择整个应用程序使用的语言。", + "settings.behavior.holdLongAssistantReplies.title": "暂停跟随较长的助手回复", + "settings.behavior.holdLongAssistantReplies.subtitle": "当流式回复超出视口时停止自动跟随。", "settings.nav.notifications": "通知", "settings.nav.remote": "远程访问", "settings.nav.speech": "语音", @@ -200,6 +210,7 @@ export const settingsMessages = { "settings.configFiles.confirmDiscard.cancelLabel": "继续编辑", "settings.providers.title": "提供商认证", + "settings.providers.target": "OpenCode 实例:{workspace}", "settings.providers.subtitle": "为当前 OpenCode 实例连接 API 密钥或 OAuth 账号。", "settings.providers.refresh": "刷新", "settings.providers.selectProvider": "选择提供商", diff --git a/packages/ui/src/stores/settings-screen.ts b/packages/ui/src/stores/settings-screen.ts index 2a79e61f3..f083324a8 100644 --- a/packages/ui/src/stores/settings-screen.ts +++ b/packages/ui/src/stores/settings-screen.ts @@ -1,19 +1,19 @@ import { createSignal } from "solid-js" export type SettingsSectionId = - | "appearance" + | "general" + | "chat" | "notifications" - | "remote" - | "speech" - | "opencode" - | "config-files" - | "sidecars" - | "info" + | "voice" + | "connections" + | "runtime" + | "advanced" + | "about" const [settingsOpen, setSettingsOpen] = createSignal(false) -const [activeSettingsSection, setActiveSettingsSection] = createSignal("appearance") +const [activeSettingsSection, setActiveSettingsSection] = createSignal("general") -export function openSettings(section: SettingsSectionId = "appearance") { +export function openSettings(section: SettingsSectionId = "general") { setActiveSettingsSection(section) setSettingsOpen(true) } diff --git a/packages/ui/src/styles/components/config-files-settings.css b/packages/ui/src/styles/components/config-files-settings.css index c8190d4cd..929cd46a1 100644 --- a/packages/ui/src/styles/components/config-files-settings.css +++ b/packages/ui/src/styles/components/config-files-settings.css @@ -5,6 +5,11 @@ flex-direction: column; } +.advanced-config-files { + height: 36rem; + min-height: 28rem; +} + .config-files-card { height: 100%; min-height: 0; From 0b0b4e68e3eb8670c66e3f1d772c1da7ba2d7be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 20 Jul 2026 14:03:14 +0200 Subject: [PATCH 2/7] feat(settings): broaden chat detail presets Let the four chat presets control thinking visibility, timeline tool calls, diagnostics expansion, tool-input visibility, and usage metrics alongside the existing tool expansion defaults. Balanced retains the current default behavior while Minimal, Detailed, and Everything provide progressively richer transcript detail. Move diff layout and long-reply follow behavior to General, remove the redundant outer Chat card, and localize the visible preset heading and description. Validated with the UI typecheck, production UI build, diff checks, focused review, and a Tauri release build without bundling. --- .../settings/chat-settings-section.tsx | 235 ++++++++++-------- .../settings/general-settings-section.tsx | 18 +- .../ui/src/lib/i18n/messages/de/settings.ts | 4 +- .../ui/src/lib/i18n/messages/en/settings.ts | 2 +- .../ui/src/lib/i18n/messages/es/settings.ts | 4 +- .../ui/src/lib/i18n/messages/fr/settings.ts | 4 +- .../ui/src/lib/i18n/messages/he/settings.ts | 4 +- .../ui/src/lib/i18n/messages/ja/settings.ts | 4 +- .../ui/src/lib/i18n/messages/ne/settings.ts | 4 +- .../ui/src/lib/i18n/messages/ru/settings.ts | 4 +- .../src/lib/i18n/messages/zh-Hans/settings.ts | 4 +- 11 files changed, 156 insertions(+), 131 deletions(-) diff --git a/packages/ui/src/components/settings/chat-settings-section.tsx b/packages/ui/src/components/settings/chat-settings-section.tsx index ccb00faf6..687633a64 100644 --- a/packages/ui/src/components/settings/chat-settings-section.tsx +++ b/packages/ui/src/components/settings/chat-settings-section.tsx @@ -2,44 +2,52 @@ import { Select } from "@kobalte/core/select" import { createMemo, For, type Component } from "solid-js" import { ChevronDown } from "lucide-solid" import { useI18n } from "../../lib/i18n" -import { useConfig, type ExpansionPreference, type ToolCallExpansionPreset } from "../../stores/preferences" -import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry" +import { useConfig, type ExpansionPreference, type Preferences, type ToolCallExpansionPreset } from "../../stores/preferences" import { buildToolExpansionPresetDefaults, getConfigurableToolEntries, OTHER_TOOL_NAME, THINKING_EXPANSION_PRESETS, } from "../tool-call/tool-registry" -import { BehaviorSettingRows } from "./behavior-setting-rows" - const toolExpansionPresetOptions: ToolCallExpansionPreset[] = ["minimal", "balanced", "detailed", "everything"] +const transcriptDetailPresets = { + minimal: { + showThinkingBlocks: false, + showTimelineTools: false, + diagnosticsExpansion: "collapsed", + toolInputsVisibility: "hidden", + showUsageMetrics: false, + }, + balanced: { + showThinkingBlocks: false, + showTimelineTools: true, + diagnosticsExpansion: "expanded", + toolInputsVisibility: "collapsed", + showUsageMetrics: true, + }, + detailed: { + showThinkingBlocks: true, + showTimelineTools: true, + diagnosticsExpansion: "expanded", + toolInputsVisibility: "collapsed", + showUsageMetrics: true, + }, + everything: { + showThinkingBlocks: true, + showTimelineTools: true, + diagnosticsExpansion: "expanded", + toolInputsVisibility: "expanded", + showUsageMetrics: true, + }, +} as const satisfies Record< + ToolCallExpansionPreset, + Pick +> export const ChatSettingsSection: Component = () => { const { t } = useI18n() const config = useConfig() const { preferences, updatePreferences } = config - const behaviorSettings = createMemo(() => [ - ...getBehaviorSettings(config).filter( - (setting) => - setting.id !== "behavior.keyboardShortcutHints" && - setting.id !== "behavior.tauriNativeEventTransport" && - setting.id !== "behavior.messageTimeline" && - setting.id !== "behavior.autoCleanupBlankSessions" && - setting.id !== "behavior.keepUnseenSubagentIdleStatus" && - setting.id !== "behavior.promptVoiceInput" && - setting.id !== "behavior.promptSubmitOnEnter" && - setting.id !== "behavior.thinkingBlocksDefault" && - setting.id !== "behavior.toolOutputsDefault", - ), - { - 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 }), - }, - ]) type SelectOption = { value: string; label: string } @@ -61,7 +69,19 @@ export const ChatSettingsSection: Component = () => { })), ]) - const currentPreset = createMemo(() => preferences().toolCallExpansionDefaults.preset) + const currentPreset = createMemo(() => { + const current = preferences() + const preset = current.toolCallExpansionDefaults.preset + if (preset === "custom") return preset + const detail = transcriptDetailPresets[preset] + return current.showThinkingBlocks === detail.showThinkingBlocks && + current.showTimelineTools === detail.showTimelineTools && + current.diagnosticsExpansion === detail.diagnosticsExpansion && + current.toolInputsVisibility === detail.toolInputsVisibility && + current.showUsageMetrics === detail.showUsageMetrics + ? preset + : "custom" + }) const currentToolMode = (tool: string): ExpansionPreference => { const pref = preferences().toolCallExpansionDefaults @@ -90,6 +110,7 @@ export const ChatSettingsSection: Component = () => { const tools = buildToolExpansionPresetDefaults(preset) const thinking = THINKING_EXPANSION_PRESETS[preset] updatePreferences({ + ...transcriptDetailPresets[preset], toolCallExpansionDefaults: { preset, thinking, tools }, thinkingBlocksExpansion: thinking, toolOutputExpansion: tools[OTHER_TOOL_NAME] ?? "expanded", @@ -118,93 +139,87 @@ export const ChatSettingsSection: Component = () => { const presetLabel = (preset: ToolCallExpansionPreset | "custom") => t(`settings.behavior.expansionPreset.${preset}.title`) return ( -
-
-
-
-

{t("settings.appearance.behavior.title")}

-

{t("settings.appearance.behavior.subtitle")}

-
- {presetLabel(currentPreset())} -
- -
- - {(preset) => ( - - )} - +
+
+
+

{t("settings.behavior.expansionPresets.ariaLabel")}

+

{t("settings.appearance.behavior.subtitle")}

+ {presetLabel(currentPreset())} +
-
-
- {t("settings.behavior.expansionDefaults.itemColumn")} - {t("settings.behavior.expansionDefaults.stateColumn")} -
- - {(row) => { - const selected = createMemo(() => selectedExpansionOption(rowMode(row))) - return ( -
-
- {row.label} -
-
- - value={selected()} - onChange={(opt) => { - if (!opt) return - setExpansionRowMode(row, opt.value as ExpansionPreference) - }} - options={expansionOptions()} - optionValue="value" - optionTextValue="label" - itemComponent={(itemProps) => ( - - {itemProps.item.rawValue.label} - - )} - > - -
- > - {(state) => ( - - {state.selectedOption()?.label} - - )} - -
- - - -
- - - - - - - -
-
- ) - }} -
-
+
+ + {(preset) => ( + + )} + +
-
- +
+
+ {t("settings.behavior.expansionDefaults.itemColumn")} + {t("settings.behavior.expansionDefaults.stateColumn")}
+ + {(row) => { + const selected = createMemo(() => selectedExpansionOption(rowMode(row))) + return ( +
+
+ {row.label} +
+
+ + value={selected()} + onChange={(opt) => { + if (!opt) return + setExpansionRowMode(row, opt.value as ExpansionPreference) + }} + options={expansionOptions()} + optionValue="value" + optionTextValue="label" + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + > + +
+ > + {(state) => ( + + {state.selectedOption()?.label} + + )} + +
+ + + +
+ + + + + + + +
+
+ ) + }} +
-
+
) } diff --git a/packages/ui/src/components/settings/general-settings-section.tsx b/packages/ui/src/components/settings/general-settings-section.tsx index 91af6831b..122144c80 100644 --- a/packages/ui/src/components/settings/general-settings-section.tsx +++ b/packages/ui/src/components/settings/general-settings-section.tsx @@ -1,7 +1,7 @@ import { Check, Laptop, Moon, Sun } from "lucide-solid" import { createMemo, type Component } from "solid-js" import { useI18n } from "../../lib/i18n" -import { getBehaviorSettings } from "../../lib/settings/behavior-registry" +import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry" import { useTheme, type ThemeMode } from "../../lib/theme" import { useConfig } from "../../stores/preferences" import { LocaleSelector } from "../locale-selector" @@ -18,16 +18,26 @@ export const GeneralSettingsSection: Component = () => { const { t } = useI18n() const { themeMode, setThemeMode } = useTheme() const config = useConfig() - const generalSettings = createMemo(() => - getBehaviorSettings(config).filter( + const { updatePreferences } = config + const generalSettings = createMemo(() => [ + ...getBehaviorSettings(config).filter( (setting) => setting.id === "behavior.keyboardShortcutHints" || setting.id === "behavior.messageTimeline" || + setting.id === "behavior.diffViewMode" || setting.id === "behavior.autoCleanupBlankSessions" || setting.id === "behavior.keepUnseenSubagentIdleStatus" || 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 }), + }, + ]) const modeLabel = (mode: ThemeMode) => { if (mode === "system") return t("theme.mode.system") diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 5e1ca4e9f..49794fa81 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -269,8 +269,8 @@ export const settingsMessages = { "settings.appearance.behavior.title": "Interaktion", - "settings.appearance.behavior.subtitle": "Standardwerte für Nachrichten, Diffs und Eingaben.", - "settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets", + "settings.appearance.behavior.subtitle": "Steuere Details fuer Nachrichten, Tools, Diagnosen und Nutzung gemeinsam.", + "settings.behavior.expansionPresets.ariaLabel": "Voreinstellungen fuer Gespraechsdetails", "settings.behavior.expansionPreset.minimal.title": "Minimal", "settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.", "settings.behavior.expansionPreset.balanced.title": "Balanced", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 6b1b060cc..7e79f5e06 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -269,7 +269,7 @@ export const settingsMessages = { "settings.appearance.behavior.title": "Interaction", - "settings.appearance.behavior.subtitle": "Message, diff, and input defaults.", + "settings.appearance.behavior.subtitle": "Control message, tool, diagnostics, and usage detail together.", "settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets", "settings.behavior.expansionPreset.minimal.title": "Minimal", "settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 75bc94617..268c30623 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -268,8 +268,8 @@ export const settingsMessages = { "settings.providers.errors.envDisconnectUnavailable": "Este proveedor está configurado desde variables de entorno y no se puede desconectar desde la UI.", "settings.appearance.behavior.title": "Interacción", - "settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.", - "settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets", + "settings.appearance.behavior.subtitle": "Controla conjuntamente el detalle de mensajes, herramientas, diagnosticos y uso.", + "settings.behavior.expansionPresets.ariaLabel": "Preajustes de detalle de conversacion", "settings.behavior.expansionPreset.minimal.title": "Minimal", "settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.", "settings.behavior.expansionPreset.balanced.title": "Balanced", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index fc5ac6964..65c924f73 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -268,8 +268,8 @@ export const settingsMessages = { "settings.providers.errors.envDisconnectUnavailable": "Ce fournisseur est configuré depuis des variables d'environnement et ne peut pas être déconnecté depuis l'interface.", "settings.appearance.behavior.title": "Interaction", - "settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.", - "settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets", + "settings.appearance.behavior.subtitle": "Controlez ensemble le niveau de detail des messages, outils, diagnostics et metriques.", + "settings.behavior.expansionPresets.ariaLabel": "Prereglages de detail de conversation", "settings.behavior.expansionPreset.minimal.title": "Minimal", "settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.", "settings.behavior.expansionPreset.balanced.title": "Balanced", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index a52b18b60..a7f9c7fb6 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -268,8 +268,8 @@ export const settingsMessages = { "settings.providers.errors.envDisconnectUnavailable": "ספק זה מוגדר ממשתני סביבה ולא ניתן לנתק אותו מהממשק.", "settings.appearance.behavior.title": "אינטראקציה", - "settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.", - "settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets", + "settings.appearance.behavior.subtitle": "שליטה משותפת ברמת הפירוט של הודעות, כלים, אבחון ושימוש.", + "settings.behavior.expansionPresets.ariaLabel": "הגדרות קבועות מראש לפירוט השיחה", "settings.behavior.expansionPreset.minimal.title": "Minimal", "settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.", "settings.behavior.expansionPreset.balanced.title": "Balanced", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 6a37cfd8d..6b266a108 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -268,8 +268,8 @@ export const settingsMessages = { "settings.providers.errors.envDisconnectUnavailable": "このプロバイダーは環境変数から設定されているため、UI から切断できません。", "settings.appearance.behavior.title": "操作", - "settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。", - "settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets", + "settings.appearance.behavior.subtitle": "メッセージ、ツール、診断、使用量の詳細表示をまとめて制御します。", + "settings.behavior.expansionPresets.ariaLabel": "会話詳細プリセット", "settings.behavior.expansionPreset.minimal.title": "Minimal", "settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.", "settings.behavior.expansionPreset.balanced.title": "Balanced", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index edea8cfc7..cbfbb0953 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -269,8 +269,8 @@ export const settingsMessages = { "settings.appearance.behavior.title": "अन्तरक्रिया", - "settings.appearance.behavior.subtitle": "सन्देश, डिफ र इनपुट पूर्वनिर्धारितहरू।", - "settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets", + "settings.appearance.behavior.subtitle": "सन्देश, उपकरण, निदान र प्रयोग विवरण एकै ठाउँबाट नियन्त्रण गर्नुहोस्।", + "settings.behavior.expansionPresets.ariaLabel": "संवाद विवरण प्रिसेटहरू", "settings.behavior.expansionPreset.minimal.title": "Minimal", "settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.", "settings.behavior.expansionPreset.balanced.title": "Balanced", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 9ac702c38..d364ab5a6 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -268,8 +268,8 @@ export const settingsMessages = { "settings.providers.errors.envDisconnectUnavailable": "Этот провайдер настроен через переменные окружения и не может быть отключён из интерфейса.", "settings.appearance.behavior.title": "Взаимодействие", - "settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.", - "settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets", + "settings.appearance.behavior.subtitle": "Общее управление детализацией сообщений, инструментов, диагностики и использования.", + "settings.behavior.expansionPresets.ariaLabel": "Предустановки детализации диалога", "settings.behavior.expansionPreset.minimal.title": "Minimal", "settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.", "settings.behavior.expansionPreset.balanced.title": "Balanced", 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 995426f8e..1eed86ce0 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -268,8 +268,8 @@ export const settingsMessages = { "settings.providers.errors.envDisconnectUnavailable": "此提供商由环境变量配置,无法从界面断开连接。", "settings.appearance.behavior.title": "交互", - "settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。", - "settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets", + "settings.appearance.behavior.subtitle": "统一控制消息、工具、诊断和用量详情。", + "settings.behavior.expansionPresets.ariaLabel": "对话详情预设", "settings.behavior.expansionPreset.minimal.title": "Minimal", "settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.", "settings.behavior.expansionPreset.balanced.title": "Balanced", From 2bca14de890c5f13455eb42a52f6468ecc15ea4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 22 Jul 2026 16:47:05 +0200 Subject: [PATCH 3/7] fix(settings): restore chat detail controls Return independent controls for thinking visibility, timeline tools, diagnostics expansion, tool input visibility, and usage metrics while keeping the broader transcript presets as convenient defaults. Custom combinations remain available and continue to mark the active preset as custom. Restore the Chat section card used by the other settings groups and remove only the redundant expansion-table frame. Validate with the workspace typecheck, UI production build, 144 runnable UI tests, gatekeeper review, and a Tauri release build launched on Windows. --- .../settings/chat-settings-section.tsx | 176 ++++++++++-------- .../src/styles/components/settings-screen.css | 4 - 2 files changed, 97 insertions(+), 83 deletions(-) diff --git a/packages/ui/src/components/settings/chat-settings-section.tsx b/packages/ui/src/components/settings/chat-settings-section.tsx index 687633a64..d059d6dd4 100644 --- a/packages/ui/src/components/settings/chat-settings-section.tsx +++ b/packages/ui/src/components/settings/chat-settings-section.tsx @@ -2,6 +2,7 @@ import { Select } from "@kobalte/core/select" import { createMemo, For, type Component } from "solid-js" import { ChevronDown } from "lucide-solid" import { useI18n } from "../../lib/i18n" +import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry" import { useConfig, type ExpansionPreference, type Preferences, type ToolCallExpansionPreset } from "../../stores/preferences" import { buildToolExpansionPresetDefaults, @@ -9,6 +10,7 @@ import { OTHER_TOOL_NAME, THINKING_EXPANSION_PRESETS, } from "../tool-call/tool-registry" +import { BehaviorSettingRows } from "./behavior-setting-rows" const toolExpansionPresetOptions: ToolCallExpansionPreset[] = ["minimal", "balanced", "detailed", "everything"] const transcriptDetailPresets = { minimal: { @@ -48,6 +50,16 @@ export const ChatSettingsSection: Component = () => { const { t } = useI18n() const config = useConfig() const { preferences, updatePreferences } = config + const transcriptSettings = createMemo(() => + getBehaviorSettings(config).filter( + (setting) => + setting.id === "behavior.thinkingBlocks" || + setting.id === "behavior.timelineToolCalls" || + setting.id === "behavior.diagnosticsDefault" || + setting.id === "behavior.toolInputsVisibility" || + setting.id === "behavior.usageMetrics", + ), + ) type SelectOption = { value: string; label: string } @@ -139,87 +151,93 @@ export const ChatSettingsSection: Component = () => { const presetLabel = (preset: ToolCallExpansionPreset | "custom") => t(`settings.behavior.expansionPreset.${preset}.title`) return ( -
-
-
-

{t("settings.behavior.expansionPresets.ariaLabel")}

-

{t("settings.appearance.behavior.subtitle")}

+
+
+
+
+

{t("settings.behavior.expansionPresets.ariaLabel")}

+

{t("settings.appearance.behavior.subtitle")}

+
+ {presetLabel(currentPreset())}
- {presetLabel(currentPreset())} -
- -
- - {(preset) => ( - - )} - -
- -
-
- {t("settings.behavior.expansionDefaults.itemColumn")} - {t("settings.behavior.expansionDefaults.stateColumn")} + +
+ + {(preset) => ( + + )} +
- - {(row) => { - const selected = createMemo(() => selectedExpansionOption(rowMode(row))) - return ( -
-
- {row.label} -
-
- - value={selected()} - onChange={(opt) => { - if (!opt) return - setExpansionRowMode(row, opt.value as ExpansionPreference) - }} - options={expansionOptions()} - optionValue="value" - optionTextValue="label" - itemComponent={(itemProps) => ( - - {itemProps.item.rawValue.label} - - )} - > - -
- > - {(state) => ( - - {state.selectedOption()?.label} - - )} - -
- - - -
- - - - - - - + +
+
+ {t("settings.behavior.expansionDefaults.itemColumn")} + {t("settings.behavior.expansionDefaults.stateColumn")} +
+ + {(row) => { + const selected = createMemo(() => selectedExpansionOption(rowMode(row))) + return ( +
+
+ {row.label} +
+
+ + value={selected()} + onChange={(opt) => { + if (!opt) return + setExpansionRowMode(row, opt.value as ExpansionPreference) + }} + options={expansionOptions()} + optionValue="value" + optionTextValue="label" + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + > + +
+ > + {(state) => ( + + {state.selectedOption()?.label} + + )} + +
+ + + +
+ + + + + + + +
-
- ) - }} - -
-
+ ) + }} + +
+ +
+ +
+ +
) } diff --git a/packages/ui/src/styles/components/settings-screen.css b/packages/ui/src/styles/components/settings-screen.css index 022efdba0..e6c943388 100644 --- a/packages/ui/src/styles/components/settings-screen.css +++ b/packages/ui/src/styles/components/settings-screen.css @@ -550,10 +550,6 @@ .settings-expansion-table { margin-bottom: 1.25rem; - border: 1px solid var(--border-base); - border-radius: 0; - background: var(--surface-base); - overflow: hidden; } .settings-expansion-table-header, From 35a0b9eb3934c0fe3580d96fbc37d41d2f024610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 23 Jul 2026 16:58:34 +0200 Subject: [PATCH 4/7] feat(settings): unify chat block visibility Consolidate chat transcript controls into one tri-state visibility list for thinking, tool output, diagnostics, tool inputs, and token metrics. Move timeline tool entries into General settings and preserve preset-aware migration behavior. Implement true hidden tool rendering while keeping active permission and question requests visible. Add a compact token summary that can expand to reasoning and cache details, with localized accessible labels. Validated with workspace typechecks, 144 UI behavior tests, the server config schema test, UI production builds, and a Tauri release build. The existing Windows-only git-worktrees fixture failure remains unrelated. --- packages/server/src/config/schema.test.ts | 15 ++ packages/server/src/config/schema.ts | 3 +- packages/ui/src/components/message-block.tsx | 79 +++++-- .../ui/src/components/message-preview.tsx | 3 +- .../ui/src/components/message-section.tsx | 12 +- .../settings/chat-settings-section.tsx | 207 ++++++++++-------- .../settings/general-settings-section.tsx | 1 + packages/ui/src/components/tool-call.tsx | 12 +- .../src/components/tool-call/tool-registry.ts | 24 +- packages/ui/src/lib/hooks/use-commands.ts | 11 +- .../ui/src/lib/i18n/messages/de/commands.ts | 8 +- .../ui/src/lib/i18n/messages/de/messaging.ts | 2 + .../ui/src/lib/i18n/messages/en/commands.ts | 8 +- .../ui/src/lib/i18n/messages/en/messaging.ts | 2 + .../ui/src/lib/i18n/messages/es/commands.ts | 8 +- .../ui/src/lib/i18n/messages/es/messaging.ts | 2 + .../ui/src/lib/i18n/messages/fr/commands.ts | 8 +- .../ui/src/lib/i18n/messages/fr/messaging.ts | 2 + .../ui/src/lib/i18n/messages/he/commands.ts | 8 +- .../ui/src/lib/i18n/messages/he/messaging.ts | 2 + .../ui/src/lib/i18n/messages/ja/commands.ts | 8 +- .../ui/src/lib/i18n/messages/ja/messaging.ts | 2 + .../ui/src/lib/i18n/messages/ne/commands.ts | 8 +- .../ui/src/lib/i18n/messages/ne/messaging.ts | 2 + .../ui/src/lib/i18n/messages/ru/commands.ts | 8 +- .../ui/src/lib/i18n/messages/ru/messaging.ts | 2 + .../src/lib/i18n/messages/zh-Hans/commands.ts | 8 +- .../lib/i18n/messages/zh-Hans/messaging.ts | 2 + .../ui/src/lib/settings/behavior-registry.ts | 56 +++-- packages/ui/src/stores/preferences.tsx | 45 ++-- .../ui/src/styles/messaging/message-base.css | 9 + 31 files changed, 375 insertions(+), 192 deletions(-) create mode 100644 packages/server/src/config/schema.test.ts diff --git a/packages/server/src/config/schema.test.ts b/packages/server/src/config/schema.test.ts new file mode 100644 index 000000000..1e7cf13b0 --- /dev/null +++ b/packages/server/src/config/schema.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { PreferencesSchema } from "./schema" + +describe("chat visibility preferences", () => { + it("accepts hidden diagnostics and collapsed usage metrics", () => { + const preferences = PreferencesSchema.parse({ + diagnosticsExpansion: "hidden", + usageMetricsExpansion: "collapsed", + }) + + assert.equal(preferences.diagnosticsExpansion, "hidden") + assert.equal(preferences.usageMetricsExpansion, "collapsed") + }) +}) diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index ec65a5d24..c2ca2a0e0 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -22,8 +22,9 @@ const PreferencesSchema = z modelThinkingSelections: z.record(z.string(), z.string()).default({}), diffViewMode: z.enum(["split", "unified"]).default("split"), toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), - diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), + diagnosticsExpansion: z.enum(["hidden", "expanded", "collapsed"]).default("expanded"), showUsageMetrics: z.boolean().default(true), + usageMetricsExpansion: z.enum(["expanded", "collapsed"]).default("collapsed"), autoCleanupBlankSessions: z.boolean().default(true), listeningMode: z.enum(["local", "all"]).default("local"), logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"), diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index a39ee56b6..cc3691ac5 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -21,6 +21,7 @@ import type { SessionSearchMatch } from "../lib/session-search" import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overflow-menu" import { copyToClipboard } from "../lib/clipboard" import SpeechActionButton from "./speech-action-button" +import type { VisibilityPreference } from "../stores/preferences" function DeleteUpToIcon() { return ( @@ -633,7 +634,8 @@ interface MessageBlockProps { lastAssistantIndex: () => number showThinking: () => boolean thinkingDefaultExpanded: () => boolean - showUsageMetrics: () => boolean + usageMetricsVisibility: () => VisibilityPreference + toolVisibility: (toolName: string) => VisibilityPreference deleteHover?: () => DeleteHoverState onDeleteHoverChange?: (state: DeleteHoverState) => void selectedMessageIds?: () => Set @@ -720,7 +722,7 @@ export default function MessageBlock(props: MessageBlockProps) { isQueued ? 1 : 0, props.showThinking() ? 1 : 0, props.thinkingDefaultExpanded() ? 1 : 0, - props.showUsageMetrics() ? 1 : 0, + props.usageMetricsVisibility(), ].join("|") const cachedBlock = sessionCache.messageBlocks.get(current.id) @@ -828,7 +830,7 @@ export default function MessageBlock(props: MessageBlockProps) { if (part.type === "step-finish") { flushContent() - if (props.showUsageMetrics()) { + if (props.usageMetricsVisibility() !== "hidden") { const key = `${current.id}:${part.id ?? partIndex}:${part.type}` const accentColor = lastAccentColor || defaultAccentColor items.push({ type: part.type, key, part, messageInfo: info, accentColor }) @@ -890,9 +892,23 @@ export default function MessageBlock(props: MessageBlockProps) { return resultBlock }) + const isDisplayItemVisible = (item: MessageBlockItem) => { + if (item.type !== "tool") return true + const part = props.store().getMessage(item.messageId)?.parts[item.partId]?.data + if (part?.type !== "tool" || props.toolVisibility(part.tool || "") !== "hidden") return true + return Boolean( + part.pendingPermission?.active || + props.store().getPermissionState(item.messageId, item.partId)?.active || + props.store().getQuestionState(item.messageId, item.partId)?.active, + ) + } + + const firstVisibleItemIndex = () => (block()?.items ?? []).findIndex(isDisplayItemVisible) + return ( {(resolvedBlock) => ( +
{ blockRef = el @@ -915,7 +931,7 @@ export default function MessageBlock(props: MessageBlockProps) { startPartId={(item() as ContentDisplayItem).startPartId} messageIndex={props.messageIndex} lastAssistantIndex={props.lastAssistantIndex} - showDeleteMessage={index === 0} + showDeleteMessage={index === firstVisibleItemIndex()} onDeleteHoverChange={props.onDeleteHoverChange} onRevert={props.onRevert} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} @@ -929,6 +945,7 @@ export default function MessageBlock(props: MessageBlockProps) { {(() => { const toolItem = item() as ToolDisplayItem return ( +
+
) })()} @@ -955,7 +973,7 @@ export default function MessageBlock(props: MessageBlockProps) { part={(item() as StepDisplayItem).part} messageInfo={(item() as StepDisplayItem).messageInfo} showAgentMeta - showDeleteMessage={index === 0} + showDeleteMessage={index === firstVisibleItemIndex()} instanceId={props.instanceId} sessionId={props.sessionId} messageId={props.messageId} @@ -971,9 +989,9 @@ export default function MessageBlock(props: MessageBlockProps) { kind="finish" part={(item() as StepDisplayItem).part} messageInfo={(item() as StepDisplayItem).messageInfo} - showUsage={props.showUsageMetrics()} + usageVisibility={props.usageMetricsVisibility()} borderColor={(item() as StepDisplayItem).accentColor} - showDeleteMessage={index === 0} + showDeleteMessage={index === firstVisibleItemIndex()} instanceId={props.instanceId} sessionId={props.sessionId} messageId={props.messageId} @@ -993,7 +1011,7 @@ export default function MessageBlock(props: MessageBlockProps) { instanceId={props.instanceId} sessionId={props.sessionId} messageId={(item() as CompactionDisplayItem).messageId} - showDeleteMessage={index === 0} + showDeleteMessage={index === firstVisibleItemIndex()} onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} selectedMessageIds={props.selectedMessageIds} @@ -1010,7 +1028,7 @@ export default function MessageBlock(props: MessageBlockProps) { messageId={(item() as ReasoningDisplayItem).messageId} showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta} defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded} - showDeleteMessage={index === 0} + showDeleteMessage={index === firstVisibleItemIndex()} onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} selectedMessageIds={props.selectedMessageIds} @@ -1024,6 +1042,7 @@ export default function MessageBlock(props: MessageBlockProps) { )}
+
)}
) @@ -1034,7 +1053,7 @@ interface StepCardProps { part: ClientPart messageInfo?: MessageInfo showAgentMeta?: boolean - showUsage?: boolean + usageVisibility?: VisibilityPreference borderColor?: string showDeleteMessage?: boolean instanceId?: string @@ -1171,6 +1190,15 @@ function StepCard(props: StepCardProps) { const { t } = useI18n() const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false) + const [usageExpandedOverride, setUsageExpandedOverride] = createSignal(null) + const usageExpanded = () => usageExpandedOverride() ?? props.usageVisibility === "expanded" + let usagePartId = (props.part as { id?: string }).id + createEffect(() => { + const nextPartId = (props.part as { id?: string }).id + if (nextPartId === usagePartId) return + usagePartId = nextPartId + setUsageExpandedOverride(null) + }) const isSelectedForDeletion = () => Boolean(props.messageId && props.selectedMessageIds?.().has(props.messageId)) const isSelectedCompanion = () => { const partId = (props.part as { id?: unknown })?.id @@ -1204,7 +1232,7 @@ function StepCard(props: StepCardProps) { } const usageStats = () => { - if (props.kind !== "finish" || !props.showUsage) { + if (props.kind !== "finish" || props.usageVisibility === "hidden") { return null } const info = props.messageInfo @@ -1315,16 +1343,22 @@ function StepCard(props: StepCardProps) { } - const renderUsageChips = (usage: NonNullable>) => { - const entries = [ + const usageEntries = (usage: NonNullable>) => [ { label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal }, { label: t("messageBlock.usage.output"), value: usage.output, formatter: formatTokenTotal }, - { label: t("messageBlock.usage.reasoning"), value: usage.reasoning, formatter: formatTokenTotal }, - { label: t("messageBlock.usage.cacheRead"), value: usage.cacheRead, formatter: formatTokenTotal }, - { label: t("messageBlock.usage.cacheWrite"), value: usage.cacheWrite, formatter: formatTokenTotal }, + ...(usageExpanded() + ? [ + { label: t("messageBlock.usage.reasoning"), value: usage.reasoning, formatter: formatTokenTotal }, + { label: t("messageBlock.usage.cacheRead"), value: usage.cacheRead, formatter: formatTokenTotal }, + { label: t("messageBlock.usage.cacheWrite"), value: usage.cacheWrite, formatter: formatTokenTotal }, + ] + : []), { label: t("messageBlock.usage.cost"), value: usage.cost, formatter: formatCostValue }, ] + const renderUsageChips = (usage: NonNullable>) => { + const entries = usageEntries(usage) + return (
@@ -1360,7 +1394,16 @@ function StepCard(props: StepCardProps) {
- {renderUsageChips(usage)} +
) } diff --git a/packages/ui/src/components/message-preview.tsx b/packages/ui/src/components/message-preview.tsx index fbac99f9d..6b7c855e6 100644 --- a/packages/ui/src/components/message-preview.tsx +++ b/packages/ui/src/components/message-preview.tsx @@ -29,7 +29,8 @@ const MessagePreview: Component = (props) => { lastAssistantIndex={lastAssistantIndex} showThinking={() => false} thinkingDefaultExpanded={() => false} - showUsageMetrics={() => false} + usageMetricsVisibility={() => "hidden"} + toolVisibility={() => "expanded"} deleteHover={props.deleteHover} onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 88a69c00a..ee34a83f3 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -23,7 +23,7 @@ import { buildRecordDisplayData } from "../stores/message-v2/record-display-cach import { getPartCharCount } from "../lib/token-utils" import { buildSessionSearchMatches } from "../lib/session-search" import type { SessionSearchMatch } from "../lib/session-search" -import { resolveThinkingExpansionDefault } from "./tool-call/tool-registry" +import { resolveThinkingExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry" import { collectToolDeletionCompanionPartIds, executeBulkDeletionPlan } from "./tool-deletion-companions" const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" @@ -57,7 +57,8 @@ export interface MessageSectionProps { export default function MessageSection(props: MessageSectionProps) { const { preferences, updatePreferences } = useConfig() const { t } = useI18n() - const showUsagePreference = () => preferences().showUsageMetrics ?? true + const usageMetricsVisibility = () => + preferences().showUsageMetrics ? preferences().usageMetricsExpansion : "hidden" const showMessageTimelinePreference = () => preferences().showMessageTimeline ?? true const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true @@ -121,8 +122,8 @@ export default function MessageSection(props: MessageSectionProps) { const pref = preferences() const showThinking = pref.showThinkingBlocks ? 1 : 0 const thinkingExpansion = resolveThinkingExpansionDefault(pref) ? "expanded" : "collapsed" - const showUsage = (pref.showUsageMetrics ?? true) ? 1 : 0 - return `${showThinking}|${thinkingExpansion}|${showUsage}` + const usageVisibility = pref.showUsageMetrics ? pref.usageMetricsExpansion : "hidden" + return `${showThinking}|${thinkingExpansion}|${usageVisibility}` }) const handleTimelineSegmentClick = (segment: TimelineSegment) => { @@ -1605,7 +1606,8 @@ export default function MessageSection(props: MessageSectionProps) { lastAssistantIndex={lastAssistantIndex} showThinking={() => preferences().showThinkingBlocks} thinkingDefaultExpanded={() => resolveThinkingExpansionDefault(preferences())} - showUsageMetrics={showUsagePreference} + usageMetricsVisibility={usageMetricsVisibility} + toolVisibility={(toolName) => resolveToolVisibility(preferences(), toolName)} deleteHover={deleteHover} onDeleteHoverChange={setDeleteHover} selectedMessageIds={selectedForDeletion} diff --git a/packages/ui/src/components/settings/chat-settings-section.tsx b/packages/ui/src/components/settings/chat-settings-section.tsx index d059d6dd4..595448b7c 100644 --- a/packages/ui/src/components/settings/chat-settings-section.tsx +++ b/packages/ui/src/components/settings/chat-settings-section.tsx @@ -2,100 +2,94 @@ import { Select } from "@kobalte/core/select" import { createMemo, For, type Component } from "solid-js" import { ChevronDown } from "lucide-solid" import { useI18n } from "../../lib/i18n" -import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry" -import { useConfig, type ExpansionPreference, type Preferences, type ToolCallExpansionPreset } from "../../stores/preferences" +import { + useConfig, + type Preferences, + type ToolCallExpansionPreset, + type VisibilityPreference, +} from "../../stores/preferences" import { buildToolExpansionPresetDefaults, getConfigurableToolEntries, OTHER_TOOL_NAME, THINKING_EXPANSION_PRESETS, } from "../tool-call/tool-registry" -import { BehaviorSettingRows } from "./behavior-setting-rows" + const toolExpansionPresetOptions: ToolCallExpansionPreset[] = ["minimal", "balanced", "detailed", "everything"] + const transcriptDetailPresets = { minimal: { showThinkingBlocks: false, - showTimelineTools: false, diagnosticsExpansion: "collapsed", toolInputsVisibility: "hidden", showUsageMetrics: false, + usageMetricsExpansion: "collapsed", }, balanced: { showThinkingBlocks: false, - showTimelineTools: true, diagnosticsExpansion: "expanded", toolInputsVisibility: "collapsed", showUsageMetrics: true, + usageMetricsExpansion: "collapsed", }, detailed: { showThinkingBlocks: true, - showTimelineTools: true, diagnosticsExpansion: "expanded", toolInputsVisibility: "collapsed", showUsageMetrics: true, + usageMetricsExpansion: "expanded", }, everything: { showThinkingBlocks: true, - showTimelineTools: true, diagnosticsExpansion: "expanded", toolInputsVisibility: "expanded", showUsageMetrics: true, + usageMetricsExpansion: "expanded", }, } as const satisfies Record< ToolCallExpansionPreset, - Pick + Pick< + Preferences, + | "showThinkingBlocks" + | "diagnosticsExpansion" + | "toolInputsVisibility" + | "showUsageMetrics" + | "usageMetricsExpansion" + > > -export const ChatSettingsSection: Component = () => { - const { t } = useI18n() - const config = useConfig() - const { preferences, updatePreferences } = config - const transcriptSettings = createMemo(() => - getBehaviorSettings(config).filter( - (setting) => - setting.id === "behavior.thinkingBlocks" || - setting.id === "behavior.timelineToolCalls" || - setting.id === "behavior.diagnosticsDefault" || - setting.id === "behavior.toolInputsVisibility" || - setting.id === "behavior.usageMetrics", - ), - ) +type SelectOption = { value: VisibilityPreference; label: string } - type SelectOption = { value: string; label: string } +type VisibilityRow = + | { kind: "thinking"; key: "thinking"; label: string } + | { kind: "tool"; key: string; label: string } + | { kind: "diagnostics"; key: "diagnostics"; label: string } + | { kind: "inputs"; key: "inputs"; label: string } + | { kind: "usage"; key: "usage"; label: string } - type ExpansionRow = - | { kind: "thinking"; key: "thinking"; label: string } - | { kind: "tool"; key: string; label: string } +export const ChatSettingsSection: Component = () => { + const { t } = useI18n() + const { preferences, updatePreferences } = useConfig() - const expansionOptions = createMemo(() => [ + const visibilityOptions = createMemo(() => [ + { value: "hidden", label: t("commands.common.hidden") }, { value: "collapsed", label: t("commands.common.collapsed") }, { value: "expanded", label: t("commands.common.expanded") }, ]) - const toolExpansionRows = createMemo(() => [ + const visibilityRows = createMemo(() => [ { kind: "thinking", key: "thinking", label: t("settings.behavior.expansionDefaults.thinking") }, ...getConfigurableToolEntries().map((entry) => ({ kind: "tool" as const, key: entry.tool, label: entry.labelKey ? t(entry.labelKey) : entry.label, })), + { kind: "diagnostics", key: "diagnostics", label: t("settings.behavior.diagnosticsDefault.title") }, + { kind: "inputs", key: "inputs", label: t("settings.behavior.toolInputsVisibility.title") }, + { kind: "usage", key: "usage", label: t("settings.behavior.usageMetrics.title") }, ]) - const currentPreset = createMemo(() => { - const current = preferences() - const preset = current.toolCallExpansionDefaults.preset - if (preset === "custom") return preset - const detail = transcriptDetailPresets[preset] - return current.showThinkingBlocks === detail.showThinkingBlocks && - current.showTimelineTools === detail.showTimelineTools && - current.diagnosticsExpansion === detail.diagnosticsExpansion && - current.toolInputsVisibility === detail.toolInputsVisibility && - current.showUsageMetrics === detail.showUsageMetrics - ? preset - : "custom" - }) - - const currentToolMode = (tool: string): ExpansionPreference => { + const currentToolMode = (tool: string): VisibilityPreference => { const pref = preferences().toolCallExpansionDefaults const entry = getConfigurableToolEntries().find((item) => item.tool === tool) if (pref.tools[tool]) return pref.tools[tool] @@ -103,18 +97,40 @@ export const ChatSettingsSection: Component = () => { return pref.tools[OTHER_TOOL_NAME] ?? "expanded" } - const currentThinkingMode = (): ExpansionPreference => { - const pref = preferences().toolCallExpansionDefaults + const currentThinkingExpansion = () => { + const current = preferences() + const pref = current.toolCallExpansionDefaults if (pref.thinking) return pref.thinking if (pref.preset !== "custom") return THINKING_EXPANSION_PRESETS[pref.preset] - return preferences().thinkingBlocksExpansion ?? "expanded" + return current.thinkingBlocksExpansion ?? "expanded" + } + + const currentThinkingMode = (): VisibilityPreference => + preferences().showThinkingBlocks ? currentThinkingExpansion() : "hidden" + + const currentUsageMode = (): VisibilityPreference => { + const current = preferences() + return current.showUsageMetrics ? current.usageMetricsExpansion : "hidden" } + const currentPreset = createMemo(() => { + const current = preferences() + const preset = current.toolCallExpansionDefaults.preset + if (preset === "custom") return preset + const detail = transcriptDetailPresets[preset] + const expectedThinking = detail.showThinkingBlocks ? THINKING_EXPANSION_PRESETS[preset] : "hidden" + return currentThinkingMode() === expectedThinking && + current.diagnosticsExpansion === detail.diagnosticsExpansion && + current.toolInputsVisibility === detail.toolInputsVisibility && + current.showUsageMetrics === detail.showUsageMetrics && + current.usageMetricsExpansion === detail.usageMetricsExpansion + ? preset + : "custom" + }) + const materializeToolModes = () => { - const tools: Record = {} - for (const entry of getConfigurableToolEntries()) { - tools[entry.tool] = currentToolMode(entry.tool) - } + const tools: Record = {} + for (const entry of getConfigurableToolEntries()) tools[entry.tool] = currentToolMode(entry.tool) return tools } @@ -125,30 +141,56 @@ export const ChatSettingsSection: Component = () => { ...transcriptDetailPresets[preset], toolCallExpansionDefaults: { preset, thinking, tools }, thinkingBlocksExpansion: thinking, - toolOutputExpansion: tools[OTHER_TOOL_NAME] ?? "expanded", + toolOutputExpansion: tools[OTHER_TOOL_NAME] === "expanded" ? "expanded" : "collapsed", }) } - const setExpansionRowMode = (row: ExpansionRow, mode: ExpansionPreference) => { - const tools = materializeToolModes() - const thinking = row.kind === "thinking" ? mode : currentThinkingMode() - if (row.kind === "tool") { - tools[row.key] = mode + const setVisibilityRowMode = (row: VisibilityRow, mode: VisibilityPreference) => { + const current = preferences() + if (row.kind === "diagnostics") { + updatePreferences({ diagnosticsExpansion: mode }) + return + } + if (row.kind === "inputs") { + updatePreferences({ toolInputsVisibility: mode }) + return } + if (row.kind === "usage") { + updatePreferences({ + showUsageMetrics: mode !== "hidden", + usageMetricsExpansion: mode === "hidden" ? current.usageMetricsExpansion : mode, + }) + return + } + + const tools = materializeToolModes() + const thinking = row.kind === "thinking" && mode !== "hidden" ? mode : currentThinkingExpansion() + if (row.kind === "tool") tools[row.key] = mode updatePreferences({ + showThinkingBlocks: row.kind === "thinking" ? mode !== "hidden" : current.showThinkingBlocks, toolCallExpansionDefaults: { preset: "custom", thinking, tools }, - thinkingBlocksExpansion: thinking, - toolOutputExpansion: tools[OTHER_TOOL_NAME] ?? preferences().toolOutputExpansion, + thinkingBlocksExpansion: + row.kind === "thinking" && mode !== "hidden" ? mode : current.thinkingBlocksExpansion, + toolOutputExpansion: + row.kind === "tool" && row.key === OTHER_TOOL_NAME && mode !== "hidden" + ? mode + : current.toolOutputExpansion, }) } - const rowMode = (row: ExpansionRow): ExpansionPreference => - row.kind === "thinking" ? currentThinkingMode() : currentToolMode(row.key) + const rowMode = (row: VisibilityRow): VisibilityPreference => { + if (row.kind === "thinking") return currentThinkingMode() + if (row.kind === "tool") return currentToolMode(row.key) + if (row.kind === "diagnostics") return preferences().diagnosticsExpansion + if (row.kind === "inputs") return preferences().toolInputsVisibility + return currentUsageMode() + } - const selectedExpansionOption = (mode: ExpansionPreference) => - expansionOptions().find((opt) => opt.value === mode) + const selectedVisibilityOption = (mode: VisibilityPreference) => + visibilityOptions().find((option) => option.value === mode) - const presetLabel = (preset: ToolCallExpansionPreset | "custom") => t(`settings.behavior.expansionPreset.${preset}.title`) + const presetLabel = (preset: ToolCallExpansionPreset | "custom") => + t(`settings.behavior.expansionPreset.${preset}.title`) return (
@@ -182,22 +224,17 @@ export const ChatSettingsSection: Component = () => { {t("settings.behavior.expansionDefaults.itemColumn")} {t("settings.behavior.expansionDefaults.stateColumn")}
- + {(row) => { - const selected = createMemo(() => selectedExpansionOption(rowMode(row))) + const selected = createMemo(() => selectedVisibilityOption(rowMode(row))) return (
-
- {row.label} -
+
{row.label}
value={selected()} - onChange={(opt) => { - if (!opt) return - setExpansionRowMode(row, opt.value as ExpansionPreference) - }} - options={expansionOptions()} + onChange={(option) => option && setVisibilityRowMode(row, option.value)} + options={visibilityOptions()} optionValue="value" optionTextValue="label" itemComponent={(itemProps) => ( @@ -206,25 +243,19 @@ export const ChatSettingsSection: Component = () => { )} > - +
> - {(state) => ( - - {state.selectedOption()?.label} - - )} + {(state) => {state.selectedOption()?.label}}
- - - +
- - - - +
@@ -233,10 +264,6 @@ export const ChatSettingsSection: Component = () => { }}
- -
- -
) diff --git a/packages/ui/src/components/settings/general-settings-section.tsx b/packages/ui/src/components/settings/general-settings-section.tsx index 122144c80..4d71318c7 100644 --- a/packages/ui/src/components/settings/general-settings-section.tsx +++ b/packages/ui/src/components/settings/general-settings-section.tsx @@ -24,6 +24,7 @@ export const GeneralSettingsSection: Component = () => { (setting) => setting.id === "behavior.keyboardShortcutHints" || setting.id === "behavior.messageTimeline" || + setting.id === "behavior.timelineToolCalls" || setting.id === "behavior.diffViewMode" || setting.id === "behavior.autoCleanupBlankSessions" || setting.id === "behavior.keepUnseenSubagentIdleStatus" || diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 2ecd50a63..0446ca23b 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -12,7 +12,7 @@ import { getPermissionSessionId } from "../types/permission" import type { QuestionRequest } from "../types/question" import { useI18n } from "../lib/i18n" import { resolveToolRenderer } from "./tool-call/renderers" -import { resolveToolExpansionDefault } from "./tool-call/tool-registry" +import { resolveToolExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry" import { QuestionToolBlock } from "./tool-call/question-block" import { PermissionToolBlock } from "./tool-call/permission-block" import { createAnsiContentRenderer } from "./tool-call/ansi-render" @@ -744,7 +744,9 @@ export default function ToolCall(props: ToolCallProps) { return undefined }) - const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded") + const diagnosticsVisibility = createMemo(() => preferences().diagnosticsExpansion || "expanded") + const diagnosticsDefaultExpanded = createMemo(() => diagnosticsVisibility() === "expanded") + const toolVisibility = createMemo(() => resolveToolVisibility(preferences(), toolCallMemo()?.tool || "")) const defaultExpandedForTool = createMemo(() => { if (props.forceCollapsed) { @@ -793,6 +795,8 @@ export default function ToolCall(props: ToolCallProps) { return active?.kind === "question" && active.id === pending.request.id }) + const isToolVisible = createMemo(() => toolVisibility() !== "hidden" || isPermissionActive() || isQuestionActive()) + const expanded = () => { if (isPermissionActive() || isQuestionActive()) return true const override = userExpanded() @@ -1046,6 +1050,7 @@ export default function ToolCall(props: ToolCallProps) { const status = () => toolState()?.status || "" return ( +
{ @@ -1168,7 +1173,7 @@ export default function ToolCall(props: ToolCallProps) { /> - + {renderDiagnosticsSection( t, @@ -1182,5 +1187,6 @@ export default function ToolCall(props: ToolCallProps) { )}
+
) } diff --git a/packages/ui/src/components/tool-call/tool-registry.ts b/packages/ui/src/components/tool-call/tool-registry.ts index 2358e129d..004a6c2ca 100644 --- a/packages/ui/src/components/tool-call/tool-registry.ts +++ b/packages/ui/src/components/tool-call/tool-registry.ts @@ -1,4 +1,9 @@ -import type { ExpansionPreference, Preferences, ToolCallExpansionPreset } from "../../stores/preferences" +import type { + ExpansionPreference, + Preferences, + ToolCallExpansionPreset, + VisibilityPreference, +} from "../../stores/preferences" import type { ToolRenderer } from "./types" import { applyPatchRenderer } from "./renderers/apply-patch" import { bashRenderer } from "./renderers/bash" @@ -30,14 +35,14 @@ export interface ToolRegistryEntry { labelKey?: string renderer: ToolRenderer configurable: boolean - expansionPresets: Record + expansionPresets: Record aliases?: string[] } const expanded = "expanded" satisfies ExpansionPreference const collapsed = "collapsed" satisfies ExpansionPreference -function presets(values: Record) { +function presets(values: Record) { return values } @@ -172,26 +177,29 @@ export function getConfigurableToolEntries(): ToolRegistryEntry[] { return TOOL_REGISTRY.filter((entry) => entry.configurable) } -export function buildToolExpansionPresetDefaults(preset: ToolCallExpansionPreset): Record { - const defaults: Record = {} +export function buildToolExpansionPresetDefaults(preset: ToolCallExpansionPreset): Record { + const defaults: Record = {} for (const entry of getConfigurableToolEntries()) { defaults[entry.tool] = entry.expansionPresets[preset] } return defaults } -export function resolveToolExpansionDefault(preferences: Preferences, toolName: string): boolean { +export function resolveToolVisibility(preferences: Preferences, toolName: string): VisibilityPreference { const entry = getToolRegistryEntry(toolName) const defaults = preferences.toolCallExpansionDefaults const presetMode = defaults.preset === "custom" ? undefined : entry.expansionPresets[defaults.preset] const otherPresetMode = defaults.preset === "custom" ? undefined : otherToolEntry.expansionPresets[defaults.preset] - const mode = defaults.tools[entry.tool] + return defaults.tools[entry.tool] ?? presetMode ?? defaults.tools[OTHER_TOOL_NAME] ?? otherPresetMode ?? preferences.toolOutputExpansion ?? "expanded" - return mode === "expanded" +} + +export function resolveToolExpansionDefault(preferences: Preferences, toolName: string): boolean { + return resolveToolVisibility(preferences, toolName) === "expanded" } export function resolveThinkingExpansionDefault(preferences: Preferences): boolean { diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index 734ceb4e7..e11c97650 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -1,6 +1,11 @@ import { createSignal, onMount } from "solid-js" import type { Accessor } from "solid-js" -import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences" +import type { + Preferences, + ExpansionPreference, + ToolInputsVisibilityPreference, + VisibilityPreference, +} from "../../stores/preferences" import { createCommandRegistry, type Command } from "../commands" import { activeInstanceId } from "../../stores/instances" import { selectNextAppTab, selectPreviousAppTab } from "../../stores/app-tabs" @@ -40,8 +45,8 @@ export interface UseCommandsOptions { togglePromptSubmitOnEnter: () => void toggleShowPromptVoiceInput: () => void setDiffViewMode: (mode: "split" | "unified") => void - setToolOutputExpansion: (mode: ExpansionPreference) => void - setDiagnosticsExpansion: (mode: ExpansionPreference) => void + setToolOutputExpansion: (mode: VisibilityPreference) => void + setDiagnosticsExpansion: (mode: VisibilityPreference) => void setThinkingBlocksExpansion: (mode: ExpansionPreference) => void setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void handleNewInstanceRequest: () => void diff --git a/packages/ui/src/lib/i18n/messages/de/commands.ts b/packages/ui/src/lib/i18n/messages/de/commands.ts index 8480b6484..5962a1aa5 100644 --- a/packages/ui/src/lib/i18n/messages/de/commands.ts +++ b/packages/ui/src/lib/i18n/messages/de/commands.ts @@ -123,12 +123,12 @@ export const commandMessages = { "commands.diffViewUnified.keywords": "diff, einheitlich, ansicht", "commands.toolOutputsDefault.label": "Standard für Tool-Ausgaben · {state}", - "commands.toolOutputsDefault.description": "Standardmäßige Erweiterung für Tool-Ausgaben umschalten", - "commands.toolOutputsDefault.keywords": "tool, ausgabe, erweitern, einklappen", + "commands.toolOutputsDefault.description": "Standardsichtbarkeit für Tool-Ausgaben festlegen", + "commands.toolOutputsDefault.keywords": "tool, ausgabe, ausblenden, erweitern, einklappen", "commands.diagnosticsDefault.label": "Standard für Diagnose · {state}", - "commands.diagnosticsDefault.description": "Standardmäßige Erweiterung für die Diagnoseausgabe umschalten", - "commands.diagnosticsDefault.keywords": "diagnose, erweitern, einklappen", + "commands.diagnosticsDefault.description": "Standardsichtbarkeit für Diagnoseausgaben festlegen", + "commands.diagnosticsDefault.keywords": "diagnose, ausblenden, erweitern, einklappen", "commands.toolInputsVisibility.label": "Sichtbarkeit von Tool-Eingaben · {state}", "commands.toolInputsVisibility.description": "Standardsichtbarkeit für Argumente von Tool-Aufrufen festlegen", diff --git a/packages/ui/src/lib/i18n/messages/de/messaging.ts b/packages/ui/src/lib/i18n/messages/de/messaging.ts index f458cd175..238db557f 100644 --- a/packages/ui/src/lib/i18n/messages/de/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/de/messaging.ts @@ -72,6 +72,8 @@ export const messagingMessages = { "messageBlock.usage.cacheRead": "Cache-Lesen", "messageBlock.usage.cacheWrite": "Cache-Schreiben", "messageBlock.usage.cost": "Kosten", + "messageBlock.usage.expandAriaLabel": "Token-Metriken erweitern", + "messageBlock.usage.collapseAriaLabel": "Token-Metriken einklappen", "messageBlock.step.agentLabel": "Agent: {agent}", "messageBlock.step.modelLabel": "Modell: {model}", "messageBlock.reasoning.thinkingLabel": "Denken", diff --git a/packages/ui/src/lib/i18n/messages/en/commands.ts b/packages/ui/src/lib/i18n/messages/en/commands.ts index a61bddd2c..3dabac04e 100644 --- a/packages/ui/src/lib/i18n/messages/en/commands.ts +++ b/packages/ui/src/lib/i18n/messages/en/commands.ts @@ -123,12 +123,12 @@ export const commandMessages = { "commands.diffViewUnified.keywords": "diff, unified, view", "commands.toolOutputsDefault.label": "Tool Outputs Default · {state}", - "commands.toolOutputsDefault.description": "Toggle default expansion for tool outputs", - "commands.toolOutputsDefault.keywords": "tool, output, expand, collapse", + "commands.toolOutputsDefault.description": "Set default visibility for tool outputs", + "commands.toolOutputsDefault.keywords": "tool, output, hide, expand, collapse", "commands.diagnosticsDefault.label": "Diagnostics Default · {state}", - "commands.diagnosticsDefault.description": "Toggle default expansion for diagnostics output", - "commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse", + "commands.diagnosticsDefault.description": "Set default visibility for diagnostics output", + "commands.diagnosticsDefault.keywords": "diagnostics, hide, expand, collapse", "commands.toolInputsVisibility.label": "Tool Inputs Visibility · {state}", "commands.toolInputsVisibility.description": "Set default visibility for tool call input arguments", diff --git a/packages/ui/src/lib/i18n/messages/en/messaging.ts b/packages/ui/src/lib/i18n/messages/en/messaging.ts index dd0005c92..d337618c2 100644 --- a/packages/ui/src/lib/i18n/messages/en/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/en/messaging.ts @@ -72,6 +72,8 @@ export const messagingMessages = { "messageBlock.usage.cacheRead": "Cache Read", "messageBlock.usage.cacheWrite": "Cache Write", "messageBlock.usage.cost": "Cost", + "messageBlock.usage.expandAriaLabel": "Expand token metrics", + "messageBlock.usage.collapseAriaLabel": "Collapse token metrics", "messageBlock.step.agentLabel": "Agent: {agent}", "messageBlock.step.modelLabel": "Model: {model}", "messageBlock.reasoning.thinkingLabel": "Thinking", diff --git a/packages/ui/src/lib/i18n/messages/es/commands.ts b/packages/ui/src/lib/i18n/messages/es/commands.ts index 10b42ff59..68e9c3029 100644 --- a/packages/ui/src/lib/i18n/messages/es/commands.ts +++ b/packages/ui/src/lib/i18n/messages/es/commands.ts @@ -123,12 +123,12 @@ export const commandMessages = { "commands.diffViewUnified.keywords": "diff, unificada, vista", "commands.toolOutputsDefault.label": "Salidas de herramientas por defecto · {state}", - "commands.toolOutputsDefault.description": "Alternar la expansión por defecto de las salidas de herramientas", - "commands.toolOutputsDefault.keywords": "herramienta, salida, expandir, colapsar", + "commands.toolOutputsDefault.description": "Configurar la visibilidad por defecto de las salidas de herramientas", + "commands.toolOutputsDefault.keywords": "herramienta, salida, ocultar, expandir, colapsar", "commands.diagnosticsDefault.label": "Diagnósticos por defecto · {state}", - "commands.diagnosticsDefault.description": "Alternar la expansión por defecto de la salida de diagnósticos", - "commands.diagnosticsDefault.keywords": "diagnósticos, expandir, colapsar", + "commands.diagnosticsDefault.description": "Configurar la visibilidad por defecto de la salida de diagnósticos", + "commands.diagnosticsDefault.keywords": "diagnósticos, ocultar, expandir, colapsar", "commands.toolInputsVisibility.label": "Visibilidad de entradas de herramientas · {state}", "commands.toolInputsVisibility.description": "Configurar la visibilidad por defecto de los argumentos de entrada de llamadas de herramienta", diff --git a/packages/ui/src/lib/i18n/messages/es/messaging.ts b/packages/ui/src/lib/i18n/messages/es/messaging.ts index 051d9f2ff..43d6530b1 100644 --- a/packages/ui/src/lib/i18n/messages/es/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/es/messaging.ts @@ -74,6 +74,8 @@ export const messagingMessages = { "messageBlock.usage.cacheRead": "Lectura de cache", "messageBlock.usage.cacheWrite": "Escritura de cache", "messageBlock.usage.cost": "Costo", + "messageBlock.usage.expandAriaLabel": "Expandir métricas de tokens", + "messageBlock.usage.collapseAriaLabel": "Contraer métricas de tokens", "messageBlock.step.agentLabel": "Agente: {agent}", "messageBlock.step.modelLabel": "Modelo: {model}", "messageBlock.reasoning.thinkingLabel": "Pensamiento", diff --git a/packages/ui/src/lib/i18n/messages/fr/commands.ts b/packages/ui/src/lib/i18n/messages/fr/commands.ts index 6fbd59c02..f846bc883 100644 --- a/packages/ui/src/lib/i18n/messages/fr/commands.ts +++ b/packages/ui/src/lib/i18n/messages/fr/commands.ts @@ -123,12 +123,12 @@ export const commandMessages = { "commands.diffViewUnified.keywords": "diff, unifiée, vue", "commands.toolOutputsDefault.label": "Sorties d'outil par défaut · {state}", - "commands.toolOutputsDefault.description": "Choisir l'ouverture par défaut des sorties d'outil", - "commands.toolOutputsDefault.keywords": "outil, sortie, développer, réduire", + "commands.toolOutputsDefault.description": "Choisir la visibilité par défaut des sorties d'outil", + "commands.toolOutputsDefault.keywords": "outil, sortie, masquer, développer, réduire", "commands.diagnosticsDefault.label": "Diagnostics par défaut · {state}", - "commands.diagnosticsDefault.description": "Choisir l'ouverture par défaut de la sortie des diagnostics", - "commands.diagnosticsDefault.keywords": "diagnostics, développer, réduire", + "commands.diagnosticsDefault.description": "Choisir la visibilité par défaut de la sortie des diagnostics", + "commands.diagnosticsDefault.keywords": "diagnostics, masquer, développer, réduire", "commands.toolInputsVisibility.label": "Visibilité des entrées d'outil · {state}", "commands.toolInputsVisibility.description": "Définir la visibilité par défaut des arguments d'entrée des appels d'outil", diff --git a/packages/ui/src/lib/i18n/messages/fr/messaging.ts b/packages/ui/src/lib/i18n/messages/fr/messaging.ts index d02c6835a..5ae20972a 100644 --- a/packages/ui/src/lib/i18n/messages/fr/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/fr/messaging.ts @@ -74,6 +74,8 @@ export const messagingMessages = { "messageBlock.usage.cacheRead": "Lecture du cache", "messageBlock.usage.cacheWrite": "Écriture du cache", "messageBlock.usage.cost": "Coût", + "messageBlock.usage.expandAriaLabel": "Développer les métriques de tokens", + "messageBlock.usage.collapseAriaLabel": "Réduire les métriques de tokens", "messageBlock.step.agentLabel": "Agent : {agent}", "messageBlock.step.modelLabel": "Modèle : {model}", "messageBlock.reasoning.thinkingLabel": "Réflexion", diff --git a/packages/ui/src/lib/i18n/messages/he/commands.ts b/packages/ui/src/lib/i18n/messages/he/commands.ts index 1969ebf4f..a432e042e 100644 --- a/packages/ui/src/lib/i18n/messages/he/commands.ts +++ b/packages/ui/src/lib/i18n/messages/he/commands.ts @@ -123,12 +123,12 @@ export const commandMessages = { "commands.diffViewUnified.keywords": "diff, מאוחד, תצוגה", "commands.toolOutputsDefault.label": "ברירת מחדל לפלטי כלים · {state}", - "commands.toolOutputsDefault.description": "החלף ברירת מחדל לפריסת פלטי כלים", - "commands.toolOutputsDefault.keywords": "כלי, פלט, פרוס, כווץ", + "commands.toolOutputsDefault.description": "הגדר נראות ברירת מחדל לפלטי כלים", + "commands.toolOutputsDefault.keywords": "כלי, פלט, הסתר, פרוס, כווץ", "commands.diagnosticsDefault.label": "ברירת מחדל לאבחון · {state}", - "commands.diagnosticsDefault.description": "החלף ברירת מחדל לפריסת פלט אבחון", - "commands.diagnosticsDefault.keywords": "אבחון, פרוס, כווץ", + "commands.diagnosticsDefault.description": "הגדר נראות ברירת מחדל לפלט אבחון", + "commands.diagnosticsDefault.keywords": "אבחון, הסתר, פרוס, כווץ", "commands.toolInputsVisibility.label": "נראות קלטי כלים · {state}", "commands.toolInputsVisibility.description": "הגדר נראות ברירת מחדל לארגומנטים של קריאות כלי", diff --git a/packages/ui/src/lib/i18n/messages/he/messaging.ts b/packages/ui/src/lib/i18n/messages/he/messaging.ts index f8baf246c..a342b71e2 100644 --- a/packages/ui/src/lib/i18n/messages/he/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/he/messaging.ts @@ -72,6 +72,8 @@ export const messagingMessages = { "messageBlock.usage.cacheRead": "קריאת מטמון", "messageBlock.usage.cacheWrite": "כתיבת מטמון", "messageBlock.usage.cost": "עלות", + "messageBlock.usage.expandAriaLabel": "הרחבת מדדי טוקנים", + "messageBlock.usage.collapseAriaLabel": "כיווץ מדדי טוקנים", "messageBlock.step.agentLabel": "סוכן: {agent}", "messageBlock.step.modelLabel": "מודל: {model}", "messageBlock.reasoning.thinkingLabel": "חשיבה", diff --git a/packages/ui/src/lib/i18n/messages/ja/commands.ts b/packages/ui/src/lib/i18n/messages/ja/commands.ts index 78ddd61d6..c00b13d44 100644 --- a/packages/ui/src/lib/i18n/messages/ja/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ja/commands.ts @@ -123,12 +123,12 @@ export const commandMessages = { "commands.diffViewUnified.keywords": "diff, ユニファイド, 表示, unified, view", "commands.toolOutputsDefault.label": "ツール出力の既定 · {state}", - "commands.toolOutputsDefault.description": "ツール出力を既定で展開するか切り替え", - "commands.toolOutputsDefault.keywords": "ツール, 出力, 展開, 折りたたみ, tool, output, expand, collapse", + "commands.toolOutputsDefault.description": "ツール出力の既定の表示状態を設定", + "commands.toolOutputsDefault.keywords": "ツール, 出力, 非表示, 展開, 折りたたみ, tool, output, hide, expand, collapse", "commands.diagnosticsDefault.label": "診断の既定 · {state}", - "commands.diagnosticsDefault.description": "診断出力を既定で展開するか切り替え", - "commands.diagnosticsDefault.keywords": "診断, 展開, 折りたたみ, diagnostics, expand, collapse", + "commands.diagnosticsDefault.description": "診断出力の既定の表示状態を設定", + "commands.diagnosticsDefault.keywords": "診断, 非表示, 展開, 折りたたみ, diagnostics, hide, expand, collapse", "commands.toolInputsVisibility.label": "ツール入力の表示 · {state}", "commands.toolInputsVisibility.description": "ツール呼び出しの入力引数の既定の表示状態を設定します", diff --git a/packages/ui/src/lib/i18n/messages/ja/messaging.ts b/packages/ui/src/lib/i18n/messages/ja/messaging.ts index c71aea59f..587ddc177 100644 --- a/packages/ui/src/lib/i18n/messages/ja/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ja/messaging.ts @@ -74,6 +74,8 @@ export const messagingMessages = { "messageBlock.usage.cacheRead": "キャッシュ読み込み", "messageBlock.usage.cacheWrite": "キャッシュ書き込み", "messageBlock.usage.cost": "コスト", + "messageBlock.usage.expandAriaLabel": "トークン使用量を展開", + "messageBlock.usage.collapseAriaLabel": "トークン使用量を折りたたむ", "messageBlock.step.agentLabel": "エージェント: {agent}", "messageBlock.step.modelLabel": "モデル: {model}", "messageBlock.reasoning.thinkingLabel": "思考", diff --git a/packages/ui/src/lib/i18n/messages/ne/commands.ts b/packages/ui/src/lib/i18n/messages/ne/commands.ts index 89f1d10be..f7c9eab64 100644 --- a/packages/ui/src/lib/i18n/messages/ne/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ne/commands.ts @@ -123,12 +123,12 @@ export const commandMessages = { "commands.diffViewUnified.keywords": "डिफ, एकीकृत, दृश्य", "commands.toolOutputsDefault.label": "उपकरण आउटपुट पूर्वनिर्धारित · {state}", - "commands.toolOutputsDefault.description": "उपकरण आउटपुटहरूको लागि पूर्वनिर्धारित विस्तार टगल गर्नुहोस्", - "commands.toolOutputsDefault.keywords": "उपकरण, आउटपुट, विस्तार, संकुचित", + "commands.toolOutputsDefault.description": "उपकरण आउटपुटहरूको पूर्वनिर्धारित दृश्यता सेट गर्नुहोस्", + "commands.toolOutputsDefault.keywords": "उपकरण, आउटपुट, लुकाउनुहोस्, विस्तार, संकुचित", "commands.diagnosticsDefault.label": "निदान (Diagnostics) पूर्वनिर्धारित · {state}", - "commands.diagnosticsDefault.description": "निदान आउटपुटको लागि पूर्वनिर्धारित विस्तार टगल गर्नुहोस्", - "commands.diagnosticsDefault.keywords": "निदान, विस्तार, संकुचित", + "commands.diagnosticsDefault.description": "निदान आउटपुटको पूर्वनिर्धारित दृश्यता सेट गर्नुहोस्", + "commands.diagnosticsDefault.keywords": "निदान, लुकाउनुहोस्, विस्तार, संकुचित", "commands.toolInputsVisibility.label": "उपकरण इनपुट दृश्यता · {state}", "commands.toolInputsVisibility.description": "उपकरण कल इनपुट तर्कहरूको लागि पूर्वनिर्धारित दृश्यता सेट गर्नुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ne/messaging.ts b/packages/ui/src/lib/i18n/messages/ne/messaging.ts index 023a31e93..5ba82e028 100644 --- a/packages/ui/src/lib/i18n/messages/ne/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ne/messaging.ts @@ -72,6 +72,8 @@ export const messagingMessages = { "messageBlock.usage.cacheRead": "क्यास पढाइ", "messageBlock.usage.cacheWrite": "क्यास लेखाइ", "messageBlock.usage.cost": "लागत (Cost)", + "messageBlock.usage.expandAriaLabel": "टोकन मेट्रिक्स विस्तार गर्नुहोस्", + "messageBlock.usage.collapseAriaLabel": "टोकन मेट्रिक्स संकुचित गर्नुहोस्", "messageBlock.step.agentLabel": "एजेन्ट: {agent}", "messageBlock.step.modelLabel": "मोडेल: {model}", "messageBlock.reasoning.thinkingLabel": "सोच्दै", diff --git a/packages/ui/src/lib/i18n/messages/ru/commands.ts b/packages/ui/src/lib/i18n/messages/ru/commands.ts index 3f1551a97..8d674f48a 100644 --- a/packages/ui/src/lib/i18n/messages/ru/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ru/commands.ts @@ -123,12 +123,12 @@ export const commandMessages = { "commands.diffViewUnified.keywords": "diff, unified, просмотр", "commands.toolOutputsDefault.label": "Выводы инструментов по умолчанию · {state}", - "commands.toolOutputsDefault.description": "Переключить, разворачивать ли выводы инструментов по умолчанию", - "commands.toolOutputsDefault.keywords": "tool, output, развернуть, свернуть", + "commands.toolOutputsDefault.description": "Задать видимость выводов инструментов по умолчанию", + "commands.toolOutputsDefault.keywords": "tool, output, скрыть, развернуть, свернуть", "commands.diagnosticsDefault.label": "Диагностика по умолчанию · {state}", - "commands.diagnosticsDefault.description": "Переключить, разворачивать ли вывод диагностики по умолчанию", - "commands.diagnosticsDefault.keywords": "diagnostics, развернуть, свернуть", + "commands.diagnosticsDefault.description": "Задать видимость вывода диагностики по умолчанию", + "commands.diagnosticsDefault.keywords": "diagnostics, скрыть, развернуть, свернуть", "commands.toolInputsVisibility.label": "Видимость входных данных инструмента · {state}", "commands.toolInputsVisibility.description": "Установить видимость аргументов входа вызовов инструментов по умолчанию", diff --git a/packages/ui/src/lib/i18n/messages/ru/messaging.ts b/packages/ui/src/lib/i18n/messages/ru/messaging.ts index 3cdb943eb..02aaa1c3c 100644 --- a/packages/ui/src/lib/i18n/messages/ru/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ru/messaging.ts @@ -74,6 +74,8 @@ export const messagingMessages = { "messageBlock.usage.cacheRead": "Чтение кэша", "messageBlock.usage.cacheWrite": "Запись кэша", "messageBlock.usage.cost": "Стоимость", + "messageBlock.usage.expandAriaLabel": "Развернуть метрики токенов", + "messageBlock.usage.collapseAriaLabel": "Свернуть метрики токенов", "messageBlock.step.agentLabel": "Агент: {agent}", "messageBlock.step.modelLabel": "Модель: {model}", "messageBlock.reasoning.thinkingLabel": "Размышления", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts index e3c31d41e..a8078c143 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts @@ -123,12 +123,12 @@ export const commandMessages = { "commands.diffViewUnified.keywords": "diff, unified, view, 差异, 统一, 视图", "commands.toolOutputsDefault.label": "工具输出默认 · {state}", - "commands.toolOutputsDefault.description": "切换工具输出是否默认展开", - "commands.toolOutputsDefault.keywords": "tool, output, expand, collapse, 工具, 输出, 展开, 折叠", + "commands.toolOutputsDefault.description": "设置工具输出的默认可见性", + "commands.toolOutputsDefault.keywords": "tool, output, hide, expand, collapse, 工具, 输出, 隐藏, 展开, 折叠", "commands.diagnosticsDefault.label": "诊断默认 · {state}", - "commands.diagnosticsDefault.description": "切换诊断输出是否默认展开", - "commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse, 诊断, 展开, 折叠", + "commands.diagnosticsDefault.description": "设置诊断输出的默认可见性", + "commands.diagnosticsDefault.keywords": "diagnostics, hide, expand, collapse, 诊断, 隐藏, 展开, 折叠", "commands.toolInputsVisibility.label": "工具输入可见性 · {state}", "commands.toolInputsVisibility.description": "设置工具调用输入参数的默认可见性", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts index 5b32cbaa9..bb36a3814 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts @@ -74,6 +74,8 @@ export const messagingMessages = { "messageBlock.usage.cacheRead": "缓存读取", "messageBlock.usage.cacheWrite": "缓存写入", "messageBlock.usage.cost": "费用", + "messageBlock.usage.expandAriaLabel": "展开令牌指标", + "messageBlock.usage.collapseAriaLabel": "折叠令牌指标", "messageBlock.step.agentLabel": "智能体:{agent}", "messageBlock.step.modelLabel": "模型:{model}", "messageBlock.reasoning.thinkingLabel": "思考", diff --git a/packages/ui/src/lib/settings/behavior-registry.ts b/packages/ui/src/lib/settings/behavior-registry.ts index b28229a12..0cca660f7 100644 --- a/packages/ui/src/lib/settings/behavior-registry.ts +++ b/packages/ui/src/lib/settings/behavior-registry.ts @@ -3,6 +3,7 @@ import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference, + VisibilityPreference, } from "../../stores/preferences" import type { Command } from "../commands" import { tGlobal } from "../i18n" @@ -47,8 +48,8 @@ export type BehaviorRegistryActions = { togglePromptSubmitOnEnter: () => void toggleShowPromptVoiceInput: () => void setDiffViewMode: (mode: "split" | "unified") => void - setToolOutputExpansion: (mode: ExpansionPreference) => void - setDiagnosticsExpansion: (mode: ExpansionPreference) => void + setToolOutputExpansion: (mode: VisibilityPreference) => void + setDiagnosticsExpansion: (mode: VisibilityPreference) => void setThinkingBlocksExpansion: (mode: ExpansionPreference) => void setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void } @@ -65,6 +66,14 @@ function setBooleanByToggle(getCurrent: () => boolean, toggle: () => void, next: toggle() } +function getToolOutputVisibility(preferences: Preferences): VisibilityPreference { + const defaults = preferences.toolCallExpansionDefaults + if (defaults.tools.other) return defaults.tools.other + if (defaults.preset === "minimal" || defaults.preset === "balanced") return "collapsed" + if (defaults.preset === "detailed" || defaults.preset === "everything") return "expanded" + return preferences.toolOutputExpansion ?? "expanded" +} + export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorSetting[] { const prefs = actions.preferences const updatePreferences = actions.updatePreferences @@ -184,15 +193,25 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS id: "behavior.toolOutputsDefault", titleKey: "settings.behavior.toolOutputsDefault.title", subtitleKey: "settings.behavior.toolOutputsDefault.subtitle", - get: (p) => (p.toolOutputExpansion ?? "expanded") as ExpansionPreference, + get: getToolOutputVisibility, set: (next) => { if (updatePreferences) { - updatePreferences({ toolOutputExpansion: next as ExpansionPreference }) + const mode = next as VisibilityPreference + const current = prefs() + updatePreferences({ + toolOutputExpansion: mode === "hidden" ? current.toolOutputExpansion : mode, + toolCallExpansionDefaults: { + ...current.toolCallExpansionDefaults, + preset: "custom", + tools: { ...current.toolCallExpansionDefaults.tools, other: mode }, + }, + }) return } - actions.setToolOutputExpansion(next as ExpansionPreference) + actions.setToolOutputExpansion(next as VisibilityPreference) }, options: [ + { value: "hidden", labelKey: "commands.common.hidden" }, { value: "expanded", labelKey: "commands.common.expanded" }, { value: "collapsed", labelKey: "commands.common.collapsed" }, ], @@ -202,15 +221,16 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS id: "behavior.diagnosticsDefault", titleKey: "settings.behavior.diagnosticsDefault.title", subtitleKey: "settings.behavior.diagnosticsDefault.subtitle", - get: (p) => (p.diagnosticsExpansion ?? "expanded") as ExpansionPreference, + get: (p) => (p.diagnosticsExpansion ?? "expanded") as VisibilityPreference, set: (next) => { if (updatePreferences) { - updatePreferences({ diagnosticsExpansion: next as ExpansionPreference }) + updatePreferences({ diagnosticsExpansion: next as VisibilityPreference }) return } - actions.setDiagnosticsExpansion(next as ExpansionPreference) + actions.setDiagnosticsExpansion(next as VisibilityPreference) }, options: [ + { value: "hidden", labelKey: "commands.common.hidden" }, { value: "expanded", labelKey: "commands.common.expanded" }, { value: "collapsed", labelKey: "commands.common.collapsed" }, ], @@ -434,16 +454,20 @@ export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[] { id: "tool-output-default-visibility", label: () => { - const mode = actions.preferences().toolOutputExpansion || "expanded" - const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed") + const mode = getToolOutputVisibility(actions.preferences()) + const state = mode === "expanded" + ? tGlobal("commands.common.expanded") + : mode === "collapsed" + ? tGlobal("commands.common.collapsed") + : tGlobal("commands.common.hidden") return tGlobal("commands.toolOutputsDefault.label", { state }) }, description: () => tGlobal("commands.toolOutputsDefault.description"), category: "System", keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"), action: () => { - const mode = actions.preferences().toolOutputExpansion || "expanded" - const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded" + const mode = getToolOutputVisibility(actions.preferences()) + const next: VisibilityPreference = mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden" actions.setToolOutputExpansion(next) }, }, @@ -451,7 +475,11 @@ export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[] id: "diagnostics-default-visibility", label: () => { const mode = actions.preferences().diagnosticsExpansion || "expanded" - const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed") + const state = mode === "expanded" + ? tGlobal("commands.common.expanded") + : mode === "collapsed" + ? tGlobal("commands.common.collapsed") + : tGlobal("commands.common.hidden") return tGlobal("commands.diagnosticsDefault.label", { state }) }, description: () => tGlobal("commands.diagnosticsDefault.description"), @@ -459,7 +487,7 @@ export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[] keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"), action: () => { const mode = actions.preferences().diagnosticsExpansion || "expanded" - const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded" + const next: VisibilityPreference = mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden" actions.setDiagnosticsExpansion(next) }, }, diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index ef75092b4..2f39824b2 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -32,9 +32,10 @@ export interface ModelPreference { export type DiffViewMode = "split" | "unified" export type ExpansionPreference = "expanded" | "collapsed" +export type VisibilityPreference = "hidden" | ExpansionPreference export type ToolCallExpansionPreset = "minimal" | "balanced" | "detailed" | "everything" export type ToolCallExpansionPresetSelection = ToolCallExpansionPreset | "custom" -export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded" +export type ToolInputsVisibilityPreference = VisibilityPreference export type ListeningMode = "local" | "all" export type ServerLogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" export type SpeechProviderPreference = "openai-compatible" @@ -44,7 +45,7 @@ export type SpeechTtsFormat = "mp3" | "wav" | "opus" | "aac" export interface ToolCallExpansionDefaults { preset: ToolCallExpansionPresetSelection thinking: ExpansionPreference - tools: Record + tools: Record } export interface SpeechSettings { @@ -96,9 +97,10 @@ export interface UiSettings { diffViewMode: DiffViewMode toolCallExpansionDefaults: ToolCallExpansionDefaults toolOutputExpansion: ExpansionPreference - diagnosticsExpansion: ExpansionPreference + diagnosticsExpansion: VisibilityPreference toolInputsVisibility: ToolInputsVisibilityPreference showUsageMetrics: boolean + usageMetricsExpansion: ExpansionPreference autoCleanupBlankSessions: boolean keepUnseenSubagentIdleStatus: boolean @@ -188,6 +190,7 @@ const defaultUiSettings: UiSettings = { diagnosticsExpansion: "expanded", toolInputsVisibility: "collapsed", showUsageMetrics: true, + usageMetricsExpansion: "collapsed", autoCleanupBlankSessions: true, keepUnseenSubagentIdleStatus: false, @@ -201,6 +204,10 @@ function normalizeExpansionPreference(value: unknown, fallback: ExpansionPrefere return value === "expanded" || value === "collapsed" ? value : fallback } +function normalizeVisibilityPreference(value: unknown, fallback: VisibilityPreference): VisibilityPreference { + return value === "hidden" || value === "expanded" || value === "collapsed" ? value : fallback +} + function normalizeToolCallExpansionPreset(value: unknown): ToolCallExpansionPresetSelection { if (value === "minimal" || value === "balanced" || value === "detailed" || value === "everything" || value === "custom") { return value @@ -208,12 +215,12 @@ function normalizeToolCallExpansionPreset(value: unknown): ToolCallExpansionPres return defaultToolCallExpansionDefaults.preset } -function normalizeToolCallExpansionTools(value: unknown): Record { +function normalizeToolCallExpansionTools(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) return {} - const next: Record = {} + const next: Record = {} for (const [tool, mode] of Object.entries(value as Record)) { if (!tool) continue - if (mode === "expanded" || mode === "collapsed") { + if (mode === "hidden" || mode === "expanded" || mode === "collapsed") { next[tool] = mode } } @@ -226,7 +233,7 @@ function normalizeToolCallExpansionDefaults(input: unknown, legacySettings: Part : undefined const legacyThinking = normalizeExpansionPreference( legacySettings.thinkingBlocksExpansion, - defaultToolCallExpansionDefaults.thinking, + "collapsed", ) return { @@ -257,6 +264,11 @@ const defaultSpeechSettings: SpeechSettings = { function normalizeUiSettings(input?: Partial | null): UiSettings { const sanitized = input ?? {} + const toolCallExpansionDefaults = normalizeToolCallExpansionDefaults(sanitized.toolCallExpansionDefaults, sanitized) + const usageMetricsFallback = + toolCallExpansionDefaults.preset === "minimal" || toolCallExpansionDefaults.preset === "balanced" + ? "collapsed" + : "expanded" return { showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultUiSettings.showThinkingBlocks, showKeyboardShortcutHints: @@ -269,14 +281,21 @@ function normalizeUiSettings(input?: Partial | null): UiSettings { showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput, locale: sanitized.locale ?? defaultUiSettings.locale, diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode, - toolCallExpansionDefaults: normalizeToolCallExpansionDefaults(sanitized.toolCallExpansionDefaults, sanitized), + toolCallExpansionDefaults, toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion, - diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultUiSettings.diagnosticsExpansion, + diagnosticsExpansion: normalizeVisibilityPreference( + sanitized.diagnosticsExpansion, + defaultUiSettings.diagnosticsExpansion, + ), toolInputsVisibility: sanitized.toolInputsVisibility === "hidden" || sanitized.toolInputsVisibility === "collapsed" || sanitized.toolInputsVisibility === "expanded" ? sanitized.toolInputsVisibility : defaultUiSettings.toolInputsVisibility, showUsageMetrics: sanitized.showUsageMetrics ?? defaultUiSettings.showUsageMetrics, + usageMetricsExpansion: normalizeExpansionPreference( + sanitized.usageMetricsExpansion, + usageMetricsFallback, + ), autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions, keepUnseenSubagentIdleStatus: sanitized.keepUnseenSubagentIdleStatus ?? defaultUiSettings.keepUnseenSubagentIdleStatus, @@ -822,11 +841,11 @@ function setDiffViewMode(mode: DiffViewMode): void { updateUiSettings({ diffViewMode: mode }) } -function setToolOutputExpansion(mode: ExpansionPreference): void { +function setToolOutputExpansion(mode: VisibilityPreference): void { const current = preferences() - if (current.toolOutputExpansion === mode && current.toolCallExpansionDefaults.tools.other === mode) return + if (current.toolCallExpansionDefaults.tools.other === mode) return updateUiSettings({ - toolOutputExpansion: mode, + toolOutputExpansion: mode === "hidden" ? current.toolOutputExpansion : mode, toolCallExpansionDefaults: { ...current.toolCallExpansionDefaults, preset: "custom", @@ -838,7 +857,7 @@ function setToolOutputExpansion(mode: ExpansionPreference): void { }) } -function setDiagnosticsExpansion(mode: ExpansionPreference): void { +function setDiagnosticsExpansion(mode: VisibilityPreference): void { if (preferences().diagnosticsExpansion === mode) return updateUiSettings({ diagnosticsExpansion: mode }) } diff --git a/packages/ui/src/styles/messaging/message-base.css b/packages/ui/src/styles/messaging/message-base.css index 74611cc81..986df61cc 100644 --- a/packages/ui/src/styles/messaging/message-base.css +++ b/packages/ui/src/styles/messaging/message-base.css @@ -214,6 +214,15 @@ @apply flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)]; } +.message-step-usage-toggle { + @apply flex min-w-0 items-center gap-1 text-left; +} + +.message-step-usage-disclosure { + @apply shrink-0 text-[9px]; + color: var(--text-muted); +} + .message-step-usage-chip { @apply inline-flex items-center rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]; color: var(--text-primary); From a1e57f0884f1ef814eb469d3305397903d22eb0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 26 Jul 2026 17:39:23 +0200 Subject: [PATCH 5/7] feat(ui): finalize settings and cache session history Complete the settings repartition with dedicated OpenCode and Providers sections, and harden the shared provider manager against stale instance and authentication operations. Add a bounded IndexedDB session-message cache tied to Restore State. Restore recent messages first in pages, preserve scroll position while older history is prepended, and keep HTTP and SSE revisions authoritative over cached data. Invalidate cache entries on destructive mutations, reject corrupt snapshots, serialize writes, and enforce per-session and global storage budgets. Cover authoritative hydration, prepend ordering, stale request races, and cache selection behavior. Validated with 383 UI tests, the full UI and Electron typecheck, the production UI build, diff checks, and two independent gatekeeper reviews. --- packages/ui/src/App.tsx | 2 +- .../src/components/folder-selection-view.tsx | 4 +- packages/ui/src/components/instance-tabs.tsx | 2 +- .../ui/src/components/message-section.tsx | 15 +- .../provider-auth/provider-manager-modal.tsx | 179 +++++-- .../src/components/session/session-view.tsx | 2 + .../ui/src/components/settings-screen.tsx | 50 +- .../settings/advanced-settings-section.tsx | 90 +--- .../config-files-settings-section.tsx | 1 + .../settings/connections-settings-section.tsx | 65 --- .../settings/general-settings-section.tsx | 2 - .../settings/opencode-settings-section.tsx | 97 ++++ .../settings/providers-settings-section.tsx | 7 + .../settings/runtime-settings-section.tsx | 45 -- .../ui/src/components/virtual-follow-list.tsx | 2 + packages/ui/src/lib/hooks/use-commands.ts | 2 + .../ui/src/lib/i18n/messages/de/settings.ts | 12 +- .../ui/src/lib/i18n/messages/en/settings.ts | 12 +- .../ui/src/lib/i18n/messages/es/settings.ts | 12 +- .../ui/src/lib/i18n/messages/fr/settings.ts | 12 +- .../ui/src/lib/i18n/messages/he/settings.ts | 12 +- .../ui/src/lib/i18n/messages/ja/settings.ts | 12 +- .../ui/src/lib/i18n/messages/ne/settings.ts | 12 +- .../ui/src/lib/i18n/messages/ru/settings.ts | 12 +- .../src/lib/i18n/messages/zh-Hans/settings.ts | 12 +- .../ui/src/lib/session-message-cache.test.ts | 34 ++ packages/ui/src/lib/session-message-cache.ts | 441 ++++++++++++++++++ packages/ui/src/stores/client-state.ts | 9 + packages/ui/src/stores/message-v2/bridge.ts | 42 +- .../stores/message-v2/instance-store.test.ts | 38 ++ .../src/stores/message-v2/instance-store.ts | 89 +++- packages/ui/src/stores/session-actions.ts | 4 + packages/ui/src/stores/session-api.ts | 179 ++++--- packages/ui/src/stores/session-events.ts | 38 +- .../ui/src/stores/session-message-cache.ts | 259 ++++++++++ .../stores/session-request-authority.test.ts | 16 + packages/ui/src/stores/settings-screen.ts | 11 +- .../src/styles/components/provider-auth.css | 19 + .../src/styles/components/settings-screen.css | 8 +- 39 files changed, 1445 insertions(+), 415 deletions(-) delete mode 100644 packages/ui/src/components/settings/connections-settings-section.tsx create mode 100644 packages/ui/src/components/settings/opencode-settings-section.tsx create mode 100644 packages/ui/src/components/settings/providers-settings-section.tsx delete mode 100644 packages/ui/src/components/settings/runtime-settings-section.tsx create mode 100644 packages/ui/src/lib/session-message-cache.test.ts create mode 100644 packages/ui/src/lib/session-message-cache.ts create mode 100644 packages/ui/src/stores/session-message-cache.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index cea0b2917..189957c7f 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -327,7 +327,7 @@ const App: Component = () => { function handleLaunchErrorAdvanced() { clearLaunchError() - openSettings("runtime") + openSettings("opencode") } function handleNewInstanceRequest() { diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index e8905090f..af499dcfe 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -539,7 +539,7 @@ const FolderSelectionView: Component = (props) => { + + +
-
{t("settings.providers.empty.noInstance")}
+
{t("settings.providers.empty.noInstance")}
@@ -524,7 +585,10 @@ export const ProviderManagerModal: Component = (props
-
{loadError()}
+ +
+ + @@ -563,6 +627,7 @@ export const ProviderManagerModal: Component = (props options={methodOptions()} optionValue="value" optionTextValue="label" + disabled={stage() !== "prompts" && stage() !== "error"} itemComponent={(itemProps) => {itemProps.item.rawValue.label}} > @@ -581,9 +646,9 @@ export const ProviderManagerModal: Component = (props
-
{t("settings.providers.oauth.promptsUnsupported")}
+
-
{t("settings.providers.oauth.noPrompts")}
+
{t("settings.providers.oauth.noPrompts")}
{(prompt) => (
@@ -598,8 +663,8 @@ export const ProviderManagerModal: Component = (props
-
{authorization()?.instructions || t("settings.providers.oauth.enterCode")}
-
{t("settings.providers.oauth.waitingTitle")}
{authorization()?.instructions}
+
{authorization()?.instructions || t("settings.providers.oauth.enterCode")}
+
{t("settings.providers.oauth.waitingTitle")}
{authorization()?.instructions}
-
{t("settings.providers.oauth.popupBlocked")}
+
-
{t("settings.providers.success")}
-
{actionError()}
+
{t("settings.providers.success")}
void submitAuth()}>{t("settings.providers.actions.working")}}> @@ -630,19 +694,32 @@ export const ProviderManagerModal: Component = (props

{t("settings.providers.configured.title")}

-
{t("settings.providers.loading")}
-
{t("settings.providers.empty.noConfiguredProviders")}
+
{t("settings.providers.loading")}
+
{t("settings.providers.empty.noConfiguredProviders")}
{(provider) => (

{provider.name || provider.id}

{provider.id}

{methodSummary(provider.id)}

{describeProviderSource(provider)}

- +
)}
+ + ) + + if (props.embedded) { + return
{content()}
+ } + + return ( + + + + + {content()} diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index af11017a2..496d488c8 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -23,6 +23,7 @@ import { SessionPreviewView } from "../session-preview-view" import { isSnapshotAutoFollowing } from "../virtual-follow-behavior" import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent, type SessionBottomPinIntent } from "./session-bottom-pin-intent" import { focusConversationStream } from "../focus-conversation" +import { invalidateSessionMessageCache } from "../../stores/session-message-cache" const log = getLogger("session") @@ -431,6 +432,7 @@ export const SessionView: Component = (props) => { }), "session.revert", ) + invalidateSessionMessageCache(props.instanceId, props.sessionId) const restoredText = getUserMessageText(messageId) if (restoredText) { diff --git a/packages/ui/src/components/settings-screen.tsx b/packages/ui/src/components/settings-screen.tsx index ce12b0d48..adc07b773 100644 --- a/packages/ui/src/components/settings-screen.tsx +++ b/packages/ui/src/components/settings-screen.tsx @@ -1,6 +1,6 @@ import { Dialog } from "@kobalte/core/dialog" import { Select } from "@kobalte/core/select" -import { Settings, Bell, ChevronDown, FileCog, Info, MessageSquare, SlidersHorizontal, Terminal, Volume2, Wifi, X } from "lucide-solid" +import { Settings, Bell, ChevronDown, FileCog, Globe, Info, MessageSquare, MonitorUp, PlugZap, SlidersHorizontal, Terminal, Volume2, X } from "lucide-solid" import { createMemo, For, type Component } from "solid-js" import { useI18n } from "../lib/i18n" import { @@ -15,9 +15,14 @@ import { ChatSettingsSection } from "./settings/chat-settings-section" import { InfoSettingsSection } from "./settings/info-settings-section" import { NotificationsSettingsSection } from "./settings/notifications-settings-section" import { SpeechSettingsSection } from "./settings/speech-settings-section" -import { ConnectionsSettingsSection } from "./settings/connections-settings-section" -import { RuntimeSettingsSection } from "./settings/runtime-settings-section" +import { ProvidersSettingsSection } from "./settings/providers-settings-section" +import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" import { AdvancedSettingsSection } from "./settings/advanced-settings-section" +import { ConfigFilesSettingsSection } from "./settings/config-files-settings-section" +import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" +import { SavedRemoteServersCard } from "./settings/saved-remote-servers-card" +import { SideCarsSettingsSection } from "./settings/sidecars-settings-section" +import { canOpenRemoteWindows } from "../lib/runtime-env" import { confirmSettingsDiscard } from "../stores/settings-dirty-guard" type SettingsSectionOption = { @@ -34,12 +39,17 @@ export const SettingsScreen: Component = () => { { id: "general", icon: SlidersHorizontal, label: t("settings.nav.general") }, { id: "chat", icon: MessageSquare, label: t("settings.nav.chat") }, { id: "notifications", icon: Bell, label: t("settings.nav.notifications") }, - { id: "voice", icon: Volume2, label: t("settings.nav.voice") }, - { id: "connections", icon: Wifi, label: t("settings.nav.connections") }, - { id: "runtime", icon: Terminal, label: t("settings.nav.runtime") }, - { id: "advanced", icon: FileCog, label: t("settings.nav.advanced") }, - { id: "about", icon: Info, label: t("settings.nav.about") }, + { id: "speech", icon: Volume2, label: t("settings.nav.speech") }, + { id: "opencode", icon: Terminal, label: t("settings.nav.opencode") }, + { id: "providers", icon: PlugZap, label: t("settings.nav.providers") }, + { id: "sidecars", icon: Globe, label: t("settings.nav.sidecars") }, + { id: "config-files", icon: FileCog, label: t("settings.nav.configFiles") }, + { id: "advanced", icon: Settings, label: t("settings.nav.advanced") }, + { id: "info", icon: Info, label: t("settings.nav.info") }, ] + if (canOpenRemoteWindows()) { + items.splice(4, 0, { id: "remote", icon: MonitorUp, label: t("settings.nav.remote") }) + } return items }) @@ -51,15 +61,26 @@ export const SettingsScreen: Component = () => { return case "notifications": return - case "voice": + case "speech": return - case "connections": - return - case "runtime": - return + case "remote": + return canOpenRemoteWindows() ? ( +
+ + +
+ ) : + case "opencode": + return + case "providers": + return + case "sidecars": + return + case "config-files": + return case "advanced": return - case "about": + case "info": return case "general": default: @@ -166,6 +187,7 @@ export const SettingsScreen: Component = () => { type="button" class="settings-nav-button" data-selected={activeSettingsSection() === section.id ? "true" : "false"} + aria-current={activeSettingsSection() === section.id ? "page" : undefined} onClick={() => void handleSectionChange(section.id)} > diff --git a/packages/ui/src/components/settings/advanced-settings-section.tsx b/packages/ui/src/components/settings/advanced-settings-section.tsx index 1c601ade6..6de2da029 100644 --- a/packages/ui/src/components/settings/advanced-settings-section.tsx +++ b/packages/ui/src/components/settings/advanced-settings-section.tsx @@ -1,77 +1,24 @@ -import { Select } from "@kobalte/core/select" -import { ChevronDown } from "lucide-solid" -import { createMemo, Show, type Component } from "solid-js" +import { createMemo, type Component } from "solid-js" import { useI18n } from "../../lib/i18n" import { getBehaviorSettings } from "../../lib/settings/behavior-registry" -import { isTauriHost } from "../../lib/runtime-env" -import { useConfig, type ServerLogLevel } from "../../stores/preferences" +import { useConfig } from "../../stores/preferences" import EnvironmentVariablesEditor from "../environment-variables-editor" import { BehaviorSettingRows } from "./behavior-setting-rows" -import { ConfigFilesSettingsSection } from "./config-files-settings-section" - -type LogLevelOption = { value: ServerLogLevel; label: string } export const AdvancedSettingsSection: Component = () => { const { t } = useI18n() const config = useConfig() - const { serverSettings, updateLogLevel } = config - const logLevelOptions = createMemo(() => [ - { value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") }, - { value: "INFO", label: t("settings.opencode.logLevel.option.info") }, - { value: "WARN", label: t("settings.opencode.logLevel.option.warn") }, - { value: "ERROR", label: t("settings.opencode.logLevel.option.error") }, - ]) - const selectedLogLevel = createMemo( - () => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0], - ) - const transportSettings = createMemo(() => - getBehaviorSettings(config).filter((setting) => setting.id === "behavior.tauriNativeEventTransport"), + const advancedSettings = createMemo(() => + getBehaviorSettings(config).filter( + (setting) => + setting.id === "behavior.autoCleanupBlankSessions" || + setting.id === "behavior.keepUnseenSubagentIdleStatus" || + setting.id === "behavior.tauriNativeEventTransport", + ), ) return (
-
-
-
-

{t("settings.opencode.logLevel.title")}

-

{t("settings.opencode.logLevel.subtitle")}

-
- {t("settings.scope.server")} -
-
-
-
-
{t("settings.opencode.logLevel.selector.title")}
-
{t("settings.opencode.logLevel.selector.subtitle")}
-
- - value={selectedLogLevel()} - onChange={(option) => option && updateLogLevel(option.value)} - options={logLevelOptions()} - optionValue="value" - optionTextValue="label" - itemComponent={(itemProps) => ( - - {itemProps.item.rawValue.label} - - )} - > - -
- > - {(state) => {state.selectedOption()?.label}} - -
- -
- - - - -
-
-
-
@@ -83,24 +30,9 @@ export const AdvancedSettingsSection: Component = () => {
- -
-
- -
-
-
-
-
-
-

{t("settings.configFiles.title")}

-

{t("settings.configFiles.subtitle")}

-
- {t("settings.scope.server")} -
-
- +
+
diff --git a/packages/ui/src/components/settings/config-files-settings-section.tsx b/packages/ui/src/components/settings/config-files-settings-section.tsx index e1f3f9867..9c08312e2 100644 --- a/packages/ui/src/components/settings/config-files-settings-section.tsx +++ b/packages/ui/src/components/settings/config-files-settings-section.tsx @@ -122,6 +122,7 @@ export const ConfigFilesSettingsSection: Component = () => { return (
+

{t("settings.configFiles.title")}

diff --git a/packages/ui/src/components/settings/connections-settings-section.tsx b/packages/ui/src/components/settings/connections-settings-section.tsx deleted file mode 100644 index 504810c1b..000000000 --- a/packages/ui/src/components/settings/connections-settings-section.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { PlugZap } from "lucide-solid" -import { createMemo, createSignal, Show, type Component } from "solid-js" -import { useI18n } from "../../lib/i18n" -import { canOpenRemoteWindows } from "../../lib/runtime-env" -import { activeInstanceId, instances } from "../../stores/instances" -import { ProviderManagerModal } from "../provider-auth/provider-manager-modal" -import { RemoteAccessSettingsSection } from "./remote-access-settings-section" -import { SavedRemoteServersCard } from "./saved-remote-servers-card" - -export const ConnectionsSettingsSection: Component = () => { - const { t } = useI18n() - const [providerManagerOpen, setProviderManagerOpen] = createSignal(false) - const providerInstance = createMemo(() => { - const id = activeInstanceId() - return id ? instances().get(id) ?? null : null - }) - const providerInstanceLabel = createMemo(() => { - const instance = providerInstance() - if (!instance) return "" - return `${instance.projectName || instance.folder} (${instance.id})` - }) - - return ( - <> -
- - - - - -
-
-
- -
-

{t("settings.providers.title")}

-

{t("settings.providers.subtitle")}

-

- {providerInstance() - ? t("settings.providers.target", { workspace: providerInstanceLabel() }) - : t("settings.providers.empty.noInstance")} -

-
-
- -
-
-
- - - - ) -} diff --git a/packages/ui/src/components/settings/general-settings-section.tsx b/packages/ui/src/components/settings/general-settings-section.tsx index 4d71318c7..f55502c02 100644 --- a/packages/ui/src/components/settings/general-settings-section.tsx +++ b/packages/ui/src/components/settings/general-settings-section.tsx @@ -26,8 +26,6 @@ export const GeneralSettingsSection: Component = () => { setting.id === "behavior.messageTimeline" || setting.id === "behavior.timelineToolCalls" || setting.id === "behavior.diffViewMode" || - setting.id === "behavior.autoCleanupBlankSessions" || - setting.id === "behavior.keepUnseenSubagentIdleStatus" || setting.id === "behavior.promptSubmitOnEnter", ), { diff --git a/packages/ui/src/components/settings/opencode-settings-section.tsx b/packages/ui/src/components/settings/opencode-settings-section.tsx new file mode 100644 index 000000000..f919dbf01 --- /dev/null +++ b/packages/ui/src/components/settings/opencode-settings-section.tsx @@ -0,0 +1,97 @@ +import { Select } from "@kobalte/core/select" +import { createEffect, createMemo, createSignal, type Component } from "solid-js" +import { ChevronDown, Terminal } from "lucide-solid" +import OpenCodeBinarySelector from "../opencode-binary-selector" +import { useConfig, type ServerLogLevel } from "../../stores/preferences" +import { useI18n } from "../../lib/i18n" +import { OpenCodeUpdateCard } from "./opencode-update-card" + +type LogLevelOption = { value: ServerLogLevel; label: string } + +export const OpenCodeSettingsSection: Component = () => { + const { t } = useI18n() + const { serverSettings, updateLastUsedBinary, updateLogLevel } = useConfig() + const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") + const logLevelOptions = createMemo(() => [ + { value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") }, + { value: "INFO", label: t("settings.opencode.logLevel.option.info") }, + { value: "WARN", label: t("settings.opencode.logLevel.option.warn") }, + { value: "ERROR", label: t("settings.opencode.logLevel.option.error") }, + ]) + const selectedLogLevel = createMemo( + () => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0], + ) + + createEffect(() => { + const binary = serverSettings().opencodeBinary || "opencode" + setSelectedBinary((current) => (current === binary ? current : binary)) + }) + + const handleBinaryChange = (binary: string) => { + setSelectedBinary(binary) + updateLastUsedBinary(binary) + } + + return ( +
+
+
+
+ +
+

{t("settings.opencode.runtime.title")}

+

{t("settings.opencode.runtime.subtitle")}

+
+
+ {t("settings.scope.server")} +
+ + +
+ + + +
+
+
+

{t("settings.opencode.logLevel.title")}

+

{t("settings.opencode.logLevel.subtitle")}

+
+ {t("settings.scope.server")} +
+
+
+
+
{t("settings.opencode.logLevel.selector.title")}
+
{t("settings.opencode.logLevel.selector.subtitle")}
+
+ + value={selectedLogLevel()} + onChange={(option) => option && updateLogLevel(option.value)} + options={logLevelOptions()} + optionValue="value" + optionTextValue="label" + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + > + +
+ > + {(state) => {state.selectedOption()?.label}} + +
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/ui/src/components/settings/providers-settings-section.tsx b/packages/ui/src/components/settings/providers-settings-section.tsx new file mode 100644 index 000000000..50988162c --- /dev/null +++ b/packages/ui/src/components/settings/providers-settings-section.tsx @@ -0,0 +1,7 @@ +import { type Component } from "solid-js" +import { activeInstanceId } from "../../stores/instances" +import { ProviderManagerModal } from "../provider-auth/provider-manager-modal" + +export const ProvidersSettingsSection: Component = () => { + return +} diff --git a/packages/ui/src/components/settings/runtime-settings-section.tsx b/packages/ui/src/components/settings/runtime-settings-section.tsx deleted file mode 100644 index ef56c26e1..000000000 --- a/packages/ui/src/components/settings/runtime-settings-section.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { createEffect, createSignal, type Component } from "solid-js" -import { Terminal } from "lucide-solid" -import OpenCodeBinarySelector from "../opencode-binary-selector" -import { useConfig } from "../../stores/preferences" -import { useI18n } from "../../lib/i18n" -import { OpenCodeUpdateCard } from "./opencode-update-card" -import { SideCarsSettingsSection } from "./sidecars-settings-section" - -export const RuntimeSettingsSection: Component = () => { - const { t } = useI18n() - const { serverSettings, updateLastUsedBinary } = useConfig() - const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") - - createEffect(() => { - const binary = serverSettings().opencodeBinary || "opencode" - setSelectedBinary((current) => (current === binary ? current : binary)) - }) - - const handleBinaryChange = (binary: string) => { - setSelectedBinary(binary) - updateLastUsedBinary(binary) - } - - return ( -
-
-
-
- -
-

{t("settings.opencode.runtime.title")}

-

{t("settings.opencode.runtime.subtitle")}

-
-
- {t("settings.scope.server")} -
- - -
- - - -
- ) -} diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 8a7f17ad4..86982c83d 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -76,6 +76,7 @@ export interface VirtualFollowListProps { resolveAutoPinHoldElement?: HoldTargetElementResolver autoPinHoldTopThresholdPx?: number suspendAutoPinToBottom?: Accessor + shift?: Accessor renderBeforeItems?: Accessor renderOverlay?: Accessor scrollToTopAriaLabel?: Accessor @@ -755,6 +756,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { scrollRef={scrollElement()} data={props.items()} bufferSize={props.overscanPx ?? 400} + shift={props.shift?.() ?? false} onScroll={handleScroll} > {(item, index) => { diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index e11c97650..c05f2af9a 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -21,6 +21,7 @@ import { requestData } from "../opencode-api" import { emitSessionSidebarRequest } from "../session-sidebar-events" import { tGlobal } from "../i18n" import { registerBehaviorCommands } from "../settings/behavior-registry" +import { invalidateSessionMessageCache } from "../../stores/session-message-cache" const log = getLogger("actions") @@ -344,6 +345,7 @@ export function useCommands(options: UseCommandsOptions) { }), "session.revert", ) + invalidateSessionMessageCache(instance.id, sessionId) if (!restoredText) { const fallbackRecord = store.getMessage(messageID) diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 49794fa81..6ea19295b 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -100,11 +100,7 @@ export const settingsMessages = { "settings.nav.appearance": "Aussehen", "settings.nav.general": "Allgemein", "settings.nav.chat": "Chat", - "settings.nav.voice": "Sprache", - "settings.nav.connections": "Verbindungen", - "settings.nav.runtime": "Laufzeit", "settings.nav.advanced": "Erweitert", - "settings.nav.about": "Über", "settings.general.language.subtitle": "Wähle die Sprache für die gesamte Anwendung.", "settings.behavior.holdLongAssistantReplies.title": "Lange Assistentenantworten anhalten", "settings.behavior.holdLongAssistantReplies.subtitle": "Automatisches Folgen beenden, wenn eine laufende Antwort über das Ansichtsfenster hinausgeht.", @@ -129,9 +125,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Startverhalten", "settings.appearance.startup.subtitle": "Lege fest, was dieses Gerät beim Start von CodeNomad wiederherstellt.", "settings.appearance.startup.restore.title": "Vorherigen Zustand wiederherstellen", - "settings.appearance.startup.restore.subtitle": "Arbeitsbereichs- und Sidecar-Tabs, aktive Sitzungen und nicht gesendete Nachrichten erneut öffnen sowie Scrollpositionen, Panel-Layout, Fensterposition und Zoom wiederherstellen.", + "settings.appearance.startup.restore.subtitle": "Tabs, aktive Sitzungen und nicht gesendete Nachrichten erneut öffnen, das Layout wiederherstellen und aktuelle Sitzungsnachrichten für schnelleres Öffnen auf diesem Gerät zwischenspeichern.", "settings.appearance.startup.clear.title": "Gespeicherter Startzustand", - "settings.appearance.startup.clear.subtitle": "Gespeicherte Tabs, Entwürfe, Scrollpositionen und das Panel-Layout entfernen. CodeNomad- und OpenCode-Daten werden nicht gelöscht.", + "settings.appearance.startup.clear.subtitle": "Gespeicherte Tabs, Entwürfe, Layouts und zwischengespeicherte Sitzungsnachrichten von diesem Gerät entfernen. OpenCode-Unterhaltungen werden nicht gelöscht.", "settings.appearance.startup.clear.action": "Gespeicherten Zustand löschen", "settings.appearance.startup.clearSuccess": "Gespeicherter Startzustand wurde gelöscht.", "settings.appearance.startup.clearError": "Der gespeicherte Startzustand konnte nicht gelöscht werden.", @@ -305,9 +301,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "Wählen Sie, ob Tool-Ausgaben standardmäßig aus- oder eingeklappt sind.", "settings.behavior.diagnosticsDefault.title": "Diagnose-Standard", "settings.behavior.diagnosticsDefault.subtitle": "Wählen Sie, ob Diagnoseausgaben standardmäßig aus- oder eingeklappt sind.", - "settings.behavior.toolInputsVisibility.title": "Sichtbarkeit von Tool-Eingaben", + "settings.behavior.toolInputsVisibility.title": "Tool-Eingaben", "settings.behavior.toolInputsVisibility.subtitle": "Standardsichtbarkeit für Argumente von Tool-Aufrufen festlegen.", - "settings.behavior.usageMetrics.title": "Token-Verbrauchsmetriken", + "settings.behavior.usageMetrics.title": "Token-Verbrauch", "settings.behavior.usageMetrics.subtitle": "Token- und Kostenstatistiken für Assistenten-Nachrichten anzeigen oder ausblenden.", "settings.behavior.autoCleanup.title": "Automatische Bereinigung leerer Sitzungen", "settings.behavior.autoCleanup.subtitle": "Leere Sitzungen beim Erstellen neuer Sitzungen automatisch bereinigen.", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 7e79f5e06..b88501d67 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -100,11 +100,7 @@ export const settingsMessages = { "settings.nav.appearance": "Appearance", "settings.nav.general": "General", "settings.nav.chat": "Chat", - "settings.nav.voice": "Voice", - "settings.nav.connections": "Connections", - "settings.nav.runtime": "Runtime", "settings.nav.advanced": "Advanced", - "settings.nav.about": "About", "settings.general.language.subtitle": "Choose the language used throughout the application.", "settings.behavior.holdLongAssistantReplies.title": "Hold long assistant replies", "settings.behavior.holdLongAssistantReplies.subtitle": "Stop following automatically when a streaming reply grows beyond the viewport.", @@ -129,9 +125,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Startup", "settings.appearance.startup.subtitle": "Choose what this device restores when CodeNomad starts.", "settings.appearance.startup.restore.title": "Restore previous state", - "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, and unsent messages, and restore scroll positions, panel layout, window position, and zoom.", + "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, and unsent messages; restore layout; and cache recent session messages on this device for faster reopening.", "settings.appearance.startup.clear.title": "Saved startup state", - "settings.appearance.startup.clear.subtitle": "Remove saved tabs, drafts, scroll positions, and panel layout. CodeNomad and OpenCode data are not deleted.", + "settings.appearance.startup.clear.subtitle": "Remove saved tabs, drafts, layout, and cached session messages from this device. OpenCode conversation data is not deleted.", "settings.appearance.startup.clear.action": "Clear saved state", "settings.appearance.startup.clearSuccess": "Saved startup state cleared.", "settings.appearance.startup.clearError": "Could not clear the saved startup state.", @@ -305,9 +301,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "Choose whether tool outputs start expanded or collapsed.", "settings.behavior.diagnosticsDefault.title": "Diagnostics default", "settings.behavior.diagnosticsDefault.subtitle": "Choose whether diagnostics output starts expanded or collapsed.", - "settings.behavior.toolInputsVisibility.title": "Tool inputs visibility", + "settings.behavior.toolInputsVisibility.title": "Tool inputs", "settings.behavior.toolInputsVisibility.subtitle": "Set default visibility for tool call input arguments.", - "settings.behavior.usageMetrics.title": "Token usage metrics", + "settings.behavior.usageMetrics.title": "Token usage", "settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.", "settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions", "settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 268c30623..4e148e574 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -100,11 +100,7 @@ export const settingsMessages = { "settings.nav.appearance": "Apariencia", "settings.nav.general": "General", "settings.nav.chat": "Chat", - "settings.nav.voice": "Voz", - "settings.nav.connections": "Conexiones", - "settings.nav.runtime": "Entorno de ejecución", "settings.nav.advanced": "Avanzado", - "settings.nav.about": "Acerca de", "settings.general.language.subtitle": "Elige el idioma utilizado en toda la aplicación.", "settings.behavior.holdLongAssistantReplies.title": "Retener respuestas largas del asistente", "settings.behavior.holdLongAssistantReplies.subtitle": "Deja de seguir automáticamente cuando una respuesta en curso supera la ventana.", @@ -129,9 +125,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Inicio", "settings.appearance.startup.subtitle": "Elige qué restaura este dispositivo al iniciar CodeNomad.", "settings.appearance.startup.restore.title": "Restaurar el estado anterior", - "settings.appearance.startup.restore.subtitle": "Volver a abrir las pestañas de espacios de trabajo y sidecars, las sesiones activas y los mensajes no enviados, y restaurar las posiciones de desplazamiento, el diseño de paneles, la posición de la ventana y el zoom.", + "settings.appearance.startup.restore.subtitle": "Volver a abrir pestañas, sesiones activas y mensajes no enviados, restaurar el diseño y guardar en caché los mensajes recientes en este dispositivo para abrirlos más rápido.", "settings.appearance.startup.clear.title": "Estado de inicio guardado", - "settings.appearance.startup.clear.subtitle": "Elimina pestañas, borradores, posiciones de desplazamiento y el diseño de paneles guardados. No se eliminan datos de CodeNomad ni de OpenCode.", + "settings.appearance.startup.clear.subtitle": "Elimina de este dispositivo las pestañas, borradores, diseños y mensajes de sesión en caché. No se eliminan las conversaciones de OpenCode.", "settings.appearance.startup.clear.action": "Borrar estado guardado", "settings.appearance.startup.clearSuccess": "Se borró el estado de inicio guardado.", "settings.appearance.startup.clearError": "No se pudo borrar el estado de inicio guardado.", @@ -304,9 +300,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "Elige si las salidas de herramientas comienzan expandidas o contraídas.", "settings.behavior.diagnosticsDefault.title": "Diagnósticos por defecto", "settings.behavior.diagnosticsDefault.subtitle": "Elige si la salida de diagnósticos comienza expandida o contraída.", - "settings.behavior.toolInputsVisibility.title": "Visibilidad de entradas de herramientas", + "settings.behavior.toolInputsVisibility.title": "Entradas de herramientas", "settings.behavior.toolInputsVisibility.subtitle": "Establece la visibilidad por defecto de los argumentos de entrada de las llamadas de herramientas.", - "settings.behavior.usageMetrics.title": "Métricas de uso de tokens", + "settings.behavior.usageMetrics.title": "Uso de tokens", "settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadísticas de tokens y costo en mensajes del asistente.", "settings.behavior.autoCleanup.title": "Limpieza automática de sesiones en blanco", "settings.behavior.autoCleanup.subtitle": "Limpia automáticamente las sesiones en blanco al crear nuevas.", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 65c924f73..975edcc98 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -100,11 +100,7 @@ export const settingsMessages = { "settings.nav.appearance": "Apparence", "settings.nav.general": "Général", "settings.nav.chat": "Chat", - "settings.nav.voice": "Voix", - "settings.nav.connections": "Connexions", - "settings.nav.runtime": "Exécution", "settings.nav.advanced": "Avancé", - "settings.nav.about": "À propos", "settings.general.language.subtitle": "Choisissez la langue utilisée dans toute l'application.", "settings.behavior.holdLongAssistantReplies.title": "Suspendre le suivi des longues réponses", "settings.behavior.holdLongAssistantReplies.subtitle": "Arrêter le suivi automatique lorsqu'une réponse en cours dépasse la fenêtre.", @@ -129,9 +125,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Démarrage", "settings.appearance.startup.subtitle": "Choisissez ce que cet appareil restaure au démarrage de CodeNomad.", "settings.appearance.startup.restore.title": "Restaurer l’état précédent", - "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets d’espaces de travail et de sidecars, les sessions actives et les messages non envoyés, et restaurer les positions de défilement, la disposition des panneaux, la position de la fenêtre et le zoom.", + "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets, les sessions actives et les messages non envoyés, restaurer la disposition et mettre en cache les messages récents sur cet appareil pour accélérer leur réouverture.", "settings.appearance.startup.clear.title": "État de démarrage enregistré", - "settings.appearance.startup.clear.subtitle": "Supprime les onglets, brouillons, positions de défilement et la disposition des panneaux enregistrés. Les données CodeNomad et OpenCode ne sont pas supprimées.", + "settings.appearance.startup.clear.subtitle": "Supprime de cet appareil les onglets, brouillons, dispositions et messages de session mis en cache. Les conversations OpenCode ne sont pas supprimées.", "settings.appearance.startup.clear.action": "Effacer l’état enregistré", "settings.appearance.startup.clearSuccess": "L’état de démarrage enregistré a été effacé.", "settings.appearance.startup.clearError": "Impossible d’effacer l’état de démarrage enregistré.", @@ -304,9 +300,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "Choisir si les sorties d'outils commencent developpees ou reduites.", "settings.behavior.diagnosticsDefault.title": "Etat initial des diagnostics", "settings.behavior.diagnosticsDefault.subtitle": "Choisir si la sortie des diagnostics commence developpee ou reduite.", - "settings.behavior.toolInputsVisibility.title": "Visibilite des entrees d'outils", + "settings.behavior.toolInputsVisibility.title": "Entrees d'outils", "settings.behavior.toolInputsVisibility.subtitle": "Definir la visibilite par defaut des arguments d'entree des appels d'outils.", - "settings.behavior.usageMetrics.title": "Metriques d'utilisation des tokens", + "settings.behavior.usageMetrics.title": "Utilisation des tokens", "settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.", "settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides", "settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index a7f9c7fb6..a8b9b8487 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -100,11 +100,7 @@ export const settingsMessages = { "settings.nav.appearance": "מראה", "settings.nav.general": "כללי", "settings.nav.chat": "צ׳אט", - "settings.nav.voice": "קול", - "settings.nav.connections": "חיבורים", - "settings.nav.runtime": "זמן ריצה", "settings.nav.advanced": "מתקדם", - "settings.nav.about": "אודות", "settings.general.language.subtitle": "בחר את השפה שתשמש בכל היישום.", "settings.behavior.holdLongAssistantReplies.title": "השהיית מעקב אחר תשובות ארוכות", "settings.behavior.holdLongAssistantReplies.subtitle": "הפסק מעקב אוטומטי כאשר תשובה זורמת חורגת מחלון התצוגה.", @@ -129,9 +125,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "הפעלה", "settings.appearance.startup.subtitle": "בחר מה ישוחזר במכשיר זה בעת הפעלת CodeNomad.", "settings.appearance.startup.restore.title": "שחזור המצב הקודם", - "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות סביבות עבודה ו-sidecar, הפעלות פעילות והודעות שלא נשלחו, ושחזור מיקומי גלילה, פריסת חלוניות, מיקום החלון ורמת התקריב.", + "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות, הפעלות פעילות והודעות שלא נשלחו, שחזור הפריסה ושמירת הודעות אחרונות במטמון במכשיר זה לפתיחה מהירה יותר.", "settings.appearance.startup.clear.title": "מצב הפעלה שמור", - "settings.appearance.startup.clear.subtitle": "הסרת כרטיסיות, טיוטות, מיקומי גלילה ופריסת חלוניות שנשמרו. נתוני CodeNomad ו-OpenCode לא יימחקו.", + "settings.appearance.startup.clear.subtitle": "הסרת כרטיסיות, טיוטות, פריסות והודעות הפעלה שמורות במטמון ממכשיר זה. שיחות OpenCode לא יימחקו.", "settings.appearance.startup.clear.action": "נקה מצב שמור", "settings.appearance.startup.clearSuccess": "מצב ההפעלה השמור נוקה.", "settings.appearance.startup.clearError": "לא ניתן לנקות את מצב ההפעלה השמור.", @@ -304,9 +300,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "בחר האם פלטי כלים מתחילים פרוסים או מכווצים.", "settings.behavior.diagnosticsDefault.title": "ברירת מחדל לאבחון", "settings.behavior.diagnosticsDefault.subtitle": "בחר האם פלט אבחון מתחיל פרוס או מכווץ.", - "settings.behavior.toolInputsVisibility.title": "נראות קלטי כלים", + "settings.behavior.toolInputsVisibility.title": "קלטי כלים", "settings.behavior.toolInputsVisibility.subtitle": "הגדר נראות ברירת מחדל לארגומנטים של קריאות כלי.", - "settings.behavior.usageMetrics.title": "מדדי שימוש בטוקנים", + "settings.behavior.usageMetrics.title": "שימוש בטוקנים", "settings.behavior.usageMetrics.subtitle": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן.", "settings.behavior.autoCleanup.title": "ניקוי אוטומטי של סשנים ריקים", "settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 6b266a108..e152181ec 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -100,11 +100,7 @@ export const settingsMessages = { "settings.nav.appearance": "外観", "settings.nav.general": "一般", "settings.nav.chat": "チャット", - "settings.nav.voice": "音声", - "settings.nav.connections": "接続", - "settings.nav.runtime": "ランタイム", "settings.nav.advanced": "詳細設定", - "settings.nav.about": "情報", "settings.general.language.subtitle": "アプリケーション全体で使用する言語を選択します。", "settings.behavior.holdLongAssistantReplies.title": "長いアシスタント応答を保持", "settings.behavior.holdLongAssistantReplies.subtitle": "ストリーミング応答が画面を超えたときに自動追従を停止します。", @@ -129,9 +125,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "起動", "settings.appearance.startup.subtitle": "CodeNomad の起動時にこのデバイスで復元する内容を選択します。", "settings.appearance.startup.restore.title": "前回の状態を復元", - "settings.appearance.startup.restore.subtitle": "ワークスペースとサイドカーのタブ、アクティブなセッション、未送信メッセージを再度開き、スクロール位置、パネル配置、ウィンドウ位置、ズームを復元します。", + "settings.appearance.startup.restore.subtitle": "タブ、アクティブなセッション、未送信メッセージを再度開き、配置を復元し、最近のセッションメッセージをこのデバイスにキャッシュして再表示を高速化します。", "settings.appearance.startup.clear.title": "保存された起動状態", - "settings.appearance.startup.clear.subtitle": "保存されたタブ、下書き、スクロール位置、パネル配置を削除します。CodeNomad と OpenCode のデータは削除されません。", + "settings.appearance.startup.clear.subtitle": "保存されたタブ、下書き、配置、キャッシュ済みセッションメッセージをこのデバイスから削除します。OpenCode の会話は削除されません。", "settings.appearance.startup.clear.action": "保存状態を消去", "settings.appearance.startup.clearSuccess": "保存された起動状態を消去しました。", "settings.appearance.startup.clearError": "保存された起動状態を消去できませんでした。", @@ -304,9 +300,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "ツール出力を最初に展開/折りたたみのどちらで表示するかを選びます。", "settings.behavior.diagnosticsDefault.title": "診断の既定", "settings.behavior.diagnosticsDefault.subtitle": "診断出力を最初に展開/折りたたみのどちらで表示するかを選びます。", - "settings.behavior.toolInputsVisibility.title": "ツール入力の表示", + "settings.behavior.toolInputsVisibility.title": "ツール入力", "settings.behavior.toolInputsVisibility.subtitle": "ツール呼び出しの入力引数の既定の表示状態を設定します。", - "settings.behavior.usageMetrics.title": "トークン使用量メトリクス", + "settings.behavior.usageMetrics.title": "トークン使用量", "settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。", "settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ", "settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index cbfbb0953..51a7c21ff 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -100,11 +100,7 @@ export const settingsMessages = { "settings.nav.appearance": "रूप (Appearance)", "settings.nav.general": "सामान्य", "settings.nav.chat": "च्याट", - "settings.nav.voice": "आवाज", - "settings.nav.connections": "जडानहरू", - "settings.nav.runtime": "रनटाइम", "settings.nav.advanced": "उन्नत", - "settings.nav.about": "बारेमा", "settings.general.language.subtitle": "सम्पूर्ण अनुप्रयोगमा प्रयोग हुने भाषा छान्नुहोस्।", "settings.behavior.holdLongAssistantReplies.title": "लामो सहायक जवाफहरू रोक्नुहोस्", "settings.behavior.holdLongAssistantReplies.subtitle": "स्ट्रिमिङ जवाफ दृश्यभन्दा बाहिर जाँदा स्वचालित पछ्याइ रोक्नुहोस्।", @@ -129,9 +125,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "सुरुआत", "settings.appearance.startup.subtitle": "CodeNomad सुरु हुँदा यस यन्त्रमा के पुनर्स्थापना गर्ने छान्नुहोस्।", "settings.appearance.startup.restore.title": "अघिल्लो अवस्था पुनर्स्थापना गर्नुहोस्", - "settings.appearance.startup.restore.subtitle": "कार्यस्थान र साइडकार ट्याब, सक्रिय सत्र र नपठाइएका सन्देशहरू फेरि खोल्नुहोस्, र स्क्रोल स्थिति, प्यानल लेआउट, विन्डोको स्थान र जुम पुनर्स्थापना गर्नुहोस्।", + "settings.appearance.startup.restore.subtitle": "ट्याब, सक्रिय सत्र र नपठाइएका सन्देशहरू फेरि खोल्नुहोस्, लेआउट पुनर्स्थापना गर्नुहोस् र छिटो खोल्न हालका सत्र सन्देशहरू यस यन्त्रमा क्यास गर्नुहोस्।", "settings.appearance.startup.clear.title": "सुरक्षित सुरुआत अवस्था", - "settings.appearance.startup.clear.subtitle": "सुरक्षित ट्याब, मस्यौदा, स्क्रोल स्थिति र प्यानल लेआउट हटाउनुहोस्। CodeNomad र OpenCode का डेटा मेटिँदैनन्।", + "settings.appearance.startup.clear.subtitle": "यस यन्त्रबाट सुरक्षित ट्याब, मस्यौदा, लेआउट र क्यास गरिएका सत्र सन्देशहरू हटाउनुहोस्। OpenCode कुराकानीहरू मेटिँदैनन्।", "settings.appearance.startup.clear.action": "सुरक्षित अवस्था खाली गर्नुहोस्", "settings.appearance.startup.clearSuccess": "सुरक्षित सुरुआत अवस्था खाली गरियो।", "settings.appearance.startup.clearError": "सुरक्षित सुरुआत अवस्था खाली गर्न सकिएन।", @@ -305,9 +301,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "उपकरण आउटपुटहरू विस्तार वा संकुचित रूपमा सुरु हुने छनौट गर्नुहोस्।", "settings.behavior.diagnosticsDefault.title": "निदान (Diagnostics) पूर्वनिर्धारित", "settings.behavior.diagnosticsDefault.subtitle": "निदान आउटपुट विस्तार वा संकुचित रूपमा सुरु हुने छनौट गर्नुहोस्।", - "settings.behavior.toolInputsVisibility.title": "उपकरण इनपुट दृश्यता", + "settings.behavior.toolInputsVisibility.title": "उपकरण इनपुट", "settings.behavior.toolInputsVisibility.subtitle": "उपकरण कल इनपुट तर्कहरूको लागि पूर्वनिर्धारित दृश्यता सेट गर्नुहोस्।", - "settings.behavior.usageMetrics.title": "टोकन प्रयोग मेट्रिक्स", + "settings.behavior.usageMetrics.title": "टोकन प्रयोग", "settings.behavior.usageMetrics.subtitle": "सन्देशहरूको लागि टोकन र लागत तथ्याङ्क देखाउनुहोस् वा लुकाउनुहोस्।", "settings.behavior.autoCleanup.title": "खाली सत्रहरू स्वतः सफा गर्नुहोस्", "settings.behavior.autoCleanup.subtitle": "नयाँ सत्रहरू सिर्जना गर्दा खाली सत्रहरू स्वतः सफा गर्नुहोस्।", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index d364ab5a6..00f42b887 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -100,11 +100,7 @@ export const settingsMessages = { "settings.nav.appearance": "Внешний вид", "settings.nav.general": "Общие", "settings.nav.chat": "Чат", - "settings.nav.voice": "Голос", - "settings.nav.connections": "Подключения", - "settings.nav.runtime": "Среда выполнения", "settings.nav.advanced": "Расширенные", - "settings.nav.about": "О программе", "settings.general.language.subtitle": "Выберите язык, используемый во всём приложении.", "settings.behavior.holdLongAssistantReplies.title": "Удерживать длинные ответы ассистента", "settings.behavior.holdLongAssistantReplies.subtitle": "Прекращать автоматическое следование, когда потоковый ответ выходит за пределы окна.", @@ -129,9 +125,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Запуск", "settings.appearance.startup.subtitle": "Выберите, что восстанавливать на этом устройстве при запуске CodeNomad.", "settings.appearance.startup.restore.title": "Восстанавливать предыдущее состояние", - "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки рабочих пространств и sidecar, активные сессии и неотправленные сообщения, а также восстанавливать позиции прокрутки, расположение панелей, положение окна и масштаб.", + "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки, активные сессии и неотправленные сообщения, восстанавливать макет и кэшировать недавние сообщения сессий на этом устройстве для быстрого открытия.", "settings.appearance.startup.clear.title": "Сохранённое состояние запуска", - "settings.appearance.startup.clear.subtitle": "Удалить сохранённые вкладки, черновики, позиции прокрутки и расположение панелей. Данные CodeNomad и OpenCode не удаляются.", + "settings.appearance.startup.clear.subtitle": "Удалить с этого устройства вкладки, черновики, макеты и кэшированные сообщения сессий. Диалоги OpenCode не удаляются.", "settings.appearance.startup.clear.action": "Очистить сохранённое состояние", "settings.appearance.startup.clearSuccess": "Сохранённое состояние запуска очищено.", "settings.appearance.startup.clearError": "Не удалось очистить сохранённое состояние запуска.", @@ -304,9 +300,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "Выберите, начинать ли выводы инструментов развернутыми или свернутыми.", "settings.behavior.diagnosticsDefault.title": "Диагностика по умолчанию", "settings.behavior.diagnosticsDefault.subtitle": "Выберите, начинать ли вывод диагностики развернутым или свернутым.", - "settings.behavior.toolInputsVisibility.title": "Видимость входных данных инструмента", + "settings.behavior.toolInputsVisibility.title": "Входные данные инструмента", "settings.behavior.toolInputsVisibility.subtitle": "Задайте видимость по умолчанию для входных аргументов вызовов инструментов.", - "settings.behavior.usageMetrics.title": "Метрики использования токенов", + "settings.behavior.usageMetrics.title": "Использование токенов", "settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.", "settings.behavior.autoCleanup.title": "Автоочистка пустых сессий", "settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.", 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 1eed86ce0..af887059a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -100,11 +100,7 @@ export const settingsMessages = { "settings.nav.appearance": "外观", "settings.nav.general": "常规", "settings.nav.chat": "聊天", - "settings.nav.voice": "语音", - "settings.nav.connections": "连接", - "settings.nav.runtime": "运行时", "settings.nav.advanced": "高级", - "settings.nav.about": "关于", "settings.general.language.subtitle": "选择整个应用程序使用的语言。", "settings.behavior.holdLongAssistantReplies.title": "暂停跟随较长的助手回复", "settings.behavior.holdLongAssistantReplies.subtitle": "当流式回复超出视口时停止自动跟随。", @@ -129,9 +125,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "启动", "settings.appearance.startup.subtitle": "选择 CodeNomad 启动时在此设备上恢复的内容。", "settings.appearance.startup.restore.title": "恢复上次状态", - "settings.appearance.startup.restore.subtitle": "重新打开工作区和边栏标签页、活动会话和未发送的消息,并恢复滚动位置、面板布局、窗口位置和缩放级别。", + "settings.appearance.startup.restore.subtitle": "重新打开标签页、活动会话和未发送的消息,恢复布局,并在此设备上缓存最近的会话消息以便更快重新打开。", "settings.appearance.startup.clear.title": "已保存的启动状态", - "settings.appearance.startup.clear.subtitle": "移除已保存的标签页、草稿、滚动位置和面板布局。不会删除 CodeNomad 或 OpenCode 数据。", + "settings.appearance.startup.clear.subtitle": "从此设备移除已保存的标签页、草稿、布局和缓存的会话消息。不会删除 OpenCode 对话。", "settings.appearance.startup.clear.action": "清除已保存状态", "settings.appearance.startup.clearSuccess": "已清除保存的启动状态。", "settings.appearance.startup.clearError": "无法清除保存的启动状态。", @@ -304,9 +300,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "选择工具输出默认是展开还是折叠。", "settings.behavior.diagnosticsDefault.title": "诊断默认状态", "settings.behavior.diagnosticsDefault.subtitle": "选择诊断输出默认是展开还是折叠。", - "settings.behavior.toolInputsVisibility.title": "工具输入可见性", + "settings.behavior.toolInputsVisibility.title": "工具输入", "settings.behavior.toolInputsVisibility.subtitle": "设置工具调用输入参数的默认可见性。", - "settings.behavior.usageMetrics.title": "令牌用量指标", + "settings.behavior.usageMetrics.title": "令牌用量", "settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。", "settings.behavior.autoCleanup.title": "自动清理空会话", "settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。", diff --git a/packages/ui/src/lib/session-message-cache.test.ts b/packages/ui/src/lib/session-message-cache.test.ts new file mode 100644 index 000000000..a62c79c6f --- /dev/null +++ b/packages/ui/src/lib/session-message-cache.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createSessionMessageCacheKey, prepareSessionMessageCache, selectSessionMessageCacheEvictions } from "./session-message-cache.ts" + +test("session message cache keys normalize path separators", () => { + assert.equal(createSessionMessageCacheKey("C:\\work\\repo\\", "session-1"), "C:/work/repo\u0000session-1") +}) + +test("session message cache evicts the oldest entries to satisfy byte and count limits", () => { + const entries = [ + { key: "old", byteSize: 5, savedAt: 1, snapshotId: "1", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, + { key: "middle", byteSize: 4, savedAt: 2, snapshotId: "2", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, + { key: "new", byteSize: 3, savedAt: 3, snapshotId: "3", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, + ] + assert.deepEqual(selectSessionMessageCacheEvictions(entries, 7, 3), ["old"]) + assert.deepEqual(selectSessionMessageCacheEvictions(entries, 20, 2), ["old"]) +}) + +test("session message cache retains the newest contiguous messages within its budget", () => { + const messages = ["one", "two", "three"].map((id) => ({ info: { id }, parts: [{ type: "text", text: id.repeat(10) }] })) + const byteLimit = messages.slice(1).reduce((total, message) => total + JSON.stringify(message).length * 2, 0) + const prepared = prepareSessionMessageCache("session", messages, "snapshot", byteLimit, 1) + assert.deepEqual(prepared?.manifest.messageIds, ["two", "three"]) + assert.equal(prepared?.manifest.startIndex, 1) + assert.equal(prepared?.manifest.complete, false) + assert.equal(prepared?.records.length, 2) +}) + +test("session message cache stores an authoritative empty manifest", () => { + const prepared = prepareSessionMessageCache("session", [], "snapshot", 100, 1) + assert.deepEqual(prepared?.manifest.messageIds, []) + assert.equal(prepared?.manifest.complete, true) + assert.equal(prepared?.manifest.totalCount, 0) +}) diff --git a/packages/ui/src/lib/session-message-cache.ts b/packages/ui/src/lib/session-message-cache.ts new file mode 100644 index 000000000..414f87cbe --- /dev/null +++ b/packages/ui/src/lib/session-message-cache.ts @@ -0,0 +1,441 @@ +const DATABASE_NAME = "codenomad-session-messages" +const DATABASE_VERSION = 2 +const MANIFEST_STORE = "session-manifests" +const MESSAGE_STORE = "session-messages" +const SESSION_INDEX = "by-session" + +export const DEFAULT_SESSION_MESSAGE_CACHE_PAGE_SIZE = 50 +export const MAX_SESSION_MESSAGE_CACHE_BYTES = 16 * 1024 * 1024 +export const MAX_TOTAL_MESSAGE_CACHE_BYTES = 64 * 1024 * 1024 +export const MAX_SESSION_MESSAGE_CACHE_ENTRIES = 64 + +export interface SessionMessageCacheManifest { + key: string + snapshotId: string + messageIds: string[] + startIndex: number + totalCount: number + complete: boolean + byteSize: number + savedAt: number +} + +interface SessionMessageCacheRecord { + sessionKey: string + snapshotId: string + messageId: string + ordinal: number + payload: string +} + +export interface SessionMessageCacheCursor { + key: string + snapshotId: string + messageIds: readonly string[] + beforeIndex: number + startIndex: number + totalCount: number + complete: boolean +} + +export interface SessionMessageCachePage { + messages: unknown[] + startIndex: number + totalCount: number + done: boolean + complete: boolean +} + +interface PreparedSessionMessageCache { + manifest: SessionMessageCacheManifest + records: SessionMessageCacheRecord[] +} + +let databasePromise: Promise | null = null +let clearGeneration = 0 +let cacheEnabled = false +const resetListeners = new Set<() => void>() +let mutationQueue: Promise = Promise.resolve() + +export function isSessionMessageCacheEnabled(): boolean { + return cacheEnabled +} + +export function setSessionMessageCacheEnabled(enabled: boolean): void { + cacheEnabled = enabled + if (!enabled) resetListeners.forEach((listener) => listener()) +} + +export function onSessionMessageCacheReset(listener: () => void): () => void { + resetListeners.add(listener) + return () => resetListeners.delete(listener) +} + +function requestResult(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")) + }) +} + +function transactionDone(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted")) + transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed")) + }) +} + +function openDatabase(): Promise { + if (typeof indexedDB === "undefined") return Promise.resolve(null) + if (databasePromise) return databasePromise + + const opening = new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION) + request.onupgradeneeded = () => { + const database = request.result + for (const legacy of ["payloads", "metadata"]) { + if (database.objectStoreNames.contains(legacy)) database.deleteObjectStore(legacy) + } + if (!database.objectStoreNames.contains(MANIFEST_STORE)) { + database.createObjectStore(MANIFEST_STORE, { keyPath: "key" }) + } + if (!database.objectStoreNames.contains(MESSAGE_STORE)) { + const store = database.createObjectStore(MESSAGE_STORE, { + keyPath: ["sessionKey", "snapshotId", "messageId"], + }) + store.createIndex(SESSION_INDEX, "sessionKey") + } + } + request.onsuccess = () => { + const database = request.result + database.onversionchange = () => { + database.close() + databasePromise = null + } + resolve(database) + } + request.onerror = () => reject(request.error ?? new Error("Failed to open session message cache")) + }) + databasePromise = opening.catch((error) => { + databasePromise = null + throw error + }) + return databasePromise +} + +function createSnapshotId(): string { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export function createSessionMessageCacheKey(workspace: string, sessionId: string): string { + return `${workspace.replace(/\\/g, "/").replace(/\/+$/, "")}\u0000${sessionId}` +} + +function messageIdOf(value: unknown): string | null { + if (!value || typeof value !== "object") return null + const candidate = value as { info?: { id?: unknown }; id?: unknown } + const id = candidate.info?.id ?? candidate.id + return typeof id === "string" && id.length > 0 ? id : null +} + +function messageSessionIdOf(value: unknown): string | null { + if (!value || typeof value !== "object") return null + const candidate = value as { info?: { sessionID?: unknown }; sessionId?: unknown } + const id = candidate.info?.sessionID ?? candidate.sessionId + return typeof id === "string" && id.length > 0 ? id : null +} + +function sessionIdFromKey(key: string): string { + return key.slice(key.lastIndexOf("\u0000") + 1) +} + +function isValidManifest(value: unknown, expectedKey: string): value is SessionMessageCacheManifest { + if (!value || typeof value !== "object") return false + const manifest = value as SessionMessageCacheManifest + return manifest.key === expectedKey && + typeof manifest.snapshotId === "string" && manifest.snapshotId.length > 0 && + Array.isArray(manifest.messageIds) && manifest.messageIds.every((id) => typeof id === "string" && id.length > 0) && + new Set(manifest.messageIds).size === manifest.messageIds.length && + Number.isSafeInteger(manifest.startIndex) && manifest.startIndex >= 0 && + Number.isSafeInteger(manifest.totalCount) && manifest.totalCount === manifest.startIndex + manifest.messageIds.length && + manifest.complete === (manifest.startIndex === 0) && + Number.isFinite(manifest.byteSize) && manifest.byteSize >= 0 && + Number.isFinite(manifest.savedAt) +} + +export function prepareSessionMessageCache( + key: string, + messages: readonly unknown[], + snapshotId = createSnapshotId(), + byteLimit = MAX_SESSION_MESSAGE_CACHE_BYTES, + savedAt = Date.now(), +): PreparedSessionMessageCache | null { + const serialized = messages.map((message, ordinal) => { + const messageId = messageIdOf(message) + if (!messageId) return null + const payload = JSON.stringify(message) + return { messageId, ordinal, payload, byteSize: payload.length * 2 } + }) + if (serialized.some((entry) => entry === null)) return null + + const seen = new Set() + for (const entry of serialized) { + if (!entry || seen.has(entry.messageId)) return null + seen.add(entry.messageId) + } + + let byteSize = 0 + let startIndex = serialized.length + for (let index = serialized.length - 1; index >= 0; index -= 1) { + const entry = serialized[index]! + if (entry.byteSize > byteLimit || byteSize + entry.byteSize > byteLimit) break + byteSize += entry.byteSize + startIndex = index + } + if (messages.length > 0 && startIndex === serialized.length) return null + + const selected = serialized.slice(startIndex) as Array> + const records = selected.map((entry) => ({ + sessionKey: key, + snapshotId, + messageId: entry.messageId, + ordinal: entry.ordinal, + payload: entry.payload, + })) + return { + manifest: { + key, + snapshotId, + messageIds: selected.map((entry) => entry.messageId), + startIndex, + totalCount: messages.length, + complete: startIndex === 0, + byteSize, + savedAt, + }, + records, + } +} + +export function selectSessionMessageCacheEvictions( + entries: readonly SessionMessageCacheManifest[], + byteLimit = MAX_TOTAL_MESSAGE_CACHE_BYTES, + entryLimit = MAX_SESSION_MESSAGE_CACHE_ENTRIES, +): string[] { + const oldestFirst = [...entries].sort((left, right) => left.savedAt - right.savedAt || left.key.localeCompare(right.key)) + let bytes = oldestFirst.reduce((total, entry) => total + entry.byteSize, 0) + let count = oldestFirst.length + const evictions: string[] = [] + for (const entry of oldestFirst) { + if (bytes <= byteLimit && count <= entryLimit) break + evictions.push(entry.key) + bytes -= entry.byteSize + count -= 1 + } + return evictions +} + +function enqueueMutation(operation: () => Promise): Promise { + const result = mutationQueue.then(operation) + mutationQueue = result.then(() => undefined, () => undefined) + return result +} + +async function readSessionRecordKeys(database: IDBDatabase, sessionKeys: readonly string[]): Promise> { + if (sessionKeys.length === 0) return new Map() + const transaction = database.transaction(MESSAGE_STORE, "readonly") + const completion = transactionDone(transaction) + const index = transaction.objectStore(MESSAGE_STORE).index(SESSION_INDEX) + const requests = sessionKeys.map(async (sessionKey) => [ + sessionKey, + await requestResult(index.getAllKeys(IDBKeyRange.only(sessionKey))), + ] as const) + const entries = await Promise.all(requests) + await completion + return new Map(entries) +} + +export async function openSessionMessageCache( + workspace: string, + sessionId: string, +): Promise { + const generation = clearGeneration + const database = await openDatabase() + if (!database || generation !== clearGeneration) return null + const transaction = database.transaction(MANIFEST_STORE, "readonly") + const completion = transactionDone(transaction) + const key = createSessionMessageCacheKey(workspace, sessionId) + const manifest = await requestResult(transaction.objectStore(MANIFEST_STORE).get(key)) as unknown + await completion + if (generation !== clearGeneration) return null + if (!manifest) return null + if (!isValidManifest(manifest, key)) { + await deleteCorruptSessionMessageCacheKey(key, manifest) + return null + } + return { + key: manifest.key, + snapshotId: manifest.snapshotId, + messageIds: manifest.messageIds, + beforeIndex: manifest.messageIds.length, + startIndex: manifest.startIndex, + totalCount: manifest.totalCount, + complete: manifest.complete, + } +} + +export async function readSessionMessageCachePage( + cursor: SessionMessageCacheCursor, + limit = DEFAULT_SESSION_MESSAGE_CACHE_PAGE_SIZE, +): Promise<{ page: SessionMessageCachePage; cursor: SessionMessageCacheCursor } | null> { + if (cursor.beforeIndex <= 0) return null + const generation = clearGeneration + const database = await openDatabase() + if (!database || generation !== clearGeneration) return null + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readonly") + const completion = transactionDone(transaction) + const manifestRequest = transaction.objectStore(MANIFEST_STORE).get(cursor.key) + const start = Math.max(0, cursor.beforeIndex - Math.max(1, limit)) + const ids = cursor.messageIds.slice(start, cursor.beforeIndex) + const messageStore = transaction.objectStore(MESSAGE_STORE) + const requests = ids.map((messageId) => requestResult(messageStore.get([cursor.key, cursor.snapshotId, messageId]))) + const manifest = await requestResult(manifestRequest) as unknown + const records = await Promise.all(requests) as Array + await completion + if (generation !== clearGeneration) return null + if (!isValidManifest(manifest, cursor.key) || manifest.snapshotId !== cursor.snapshotId) return null + if (records.some((record) => !record)) { + await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) + return null + } + + try { + const messages = records.map((record) => JSON.parse(record!.payload)) + const sessionId = sessionIdFromKey(cursor.key) + if (messages.some((message, index) => messageIdOf(message) !== ids[index] || messageSessionIdOf(message) !== sessionId)) { + await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) + return null + } + const nextCursor = { ...cursor, beforeIndex: start } + return { + page: { + messages, + startIndex: cursor.startIndex + start, + totalCount: cursor.totalCount, + done: start === 0, + complete: cursor.complete, + }, + cursor: nextCursor, + } + } catch { + await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) + return null + } +} + +export async function writeSessionMessageCache(workspace: string, sessionId: string, messages: unknown[]): Promise { + const generation = clearGeneration + const key = createSessionMessageCacheKey(workspace, sessionId) + const prepared = prepareSessionMessageCache(key, messages) + if (!prepared) { + await deleteSessionMessageCache(workspace, sessionId) + return false + } + return enqueueMutation(async () => { + const database = await openDatabase() + if (!database || generation !== clearGeneration) return false + const manifestRead = database.transaction(MANIFEST_STORE, "readonly") + const manifestReadDone = transactionDone(manifestRead) + const existing = await requestResult(manifestRead.objectStore(MANIFEST_STORE).getAll()) as unknown[] + await manifestReadDone + const validExisting = existing.filter((manifest): manifest is SessionMessageCacheManifest => { + const manifestKey = (manifest as { key?: unknown })?.key + return typeof manifestKey === "string" && isValidManifest(manifest, manifestKey) + }) + const invalidKeys = existing + .filter((manifest) => !validExisting.includes(manifest as SessionMessageCacheManifest)) + .map((manifest) => (manifest as { key?: unknown })?.key) + .filter((manifestKey): manifestKey is string => typeof manifestKey === "string" && manifestKey !== key) + prepared.manifest.savedAt = Math.max(prepared.manifest.savedAt, ...validExisting.map((manifest) => manifest.savedAt + 1)) + const projected = [...validExisting.filter((manifest) => manifest.key !== key), prepared.manifest] + const evictions = selectSessionMessageCacheEvictions(projected) + const recordKeys = await readSessionRecordKeys(database, [key, ...evictions, ...invalidKeys]) + if (generation !== clearGeneration) return false + + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + const manifestStore = transaction.objectStore(MANIFEST_STORE) + const messageStore = transaction.objectStore(MESSAGE_STORE) + for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) + for (const record of prepared.records) messageStore.put(record) + manifestStore.put(prepared.manifest) + for (const evictedKey of evictions) { + for (const recordKey of recordKeys.get(evictedKey) ?? []) messageStore.delete(recordKey) + manifestStore.delete(evictedKey) + } + for (const invalidKey of invalidKeys) { + for (const recordKey of recordKeys.get(invalidKey) ?? []) messageStore.delete(recordKey) + manifestStore.delete(invalidKey) + } + await completion + return true + }) +} + +export async function deleteSessionMessageCache(workspace: string, sessionId: string): Promise { + const key = createSessionMessageCacheKey(workspace, sessionId) + await deleteSessionMessageCacheKey(key) +} + +async function deleteSessionMessageCacheKey(key: string): Promise { + await enqueueMutation(async () => { + const database = await openDatabase() + if (!database) return + const recordKeys = await readSessionRecordKeys(database, [key]) + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + const messageStore = transaction.objectStore(MESSAGE_STORE) + for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) + transaction.objectStore(MANIFEST_STORE).delete(key) + await completion + }) +} + +async function deleteCorruptSessionMessageCacheKey(key: string, observed: unknown): Promise { + const observedSnapshotId = (observed as { snapshotId?: unknown })?.snapshotId + await enqueueMutation(async () => { + const database = await openDatabase() + if (!database) return + const manifestRead = database.transaction(MANIFEST_STORE, "readonly") + const manifestReadDone = transactionDone(manifestRead) + const current = await requestResult(manifestRead.objectStore(MANIFEST_STORE).get(key)) as unknown + await manifestReadDone + if (!current) return + if (typeof observedSnapshotId === "string") { + if ((current as { snapshotId?: unknown }).snapshotId !== observedSnapshotId) return + } else if (isValidManifest(current, key)) { + return + } + const recordKeys = await readSessionRecordKeys(database, [key]) + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + const messageStore = transaction.objectStore(MESSAGE_STORE) + for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) + transaction.objectStore(MANIFEST_STORE).delete(key) + await completion + }) +} + +export async function clearSessionMessageCache(): Promise { + clearGeneration += 1 + await enqueueMutation(async () => { + const database = await openDatabase() + if (!database) return + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + transaction.objectStore(MANIFEST_STORE).clear() + transaction.objectStore(MESSAGE_STORE).clear() + await completion + }) +} diff --git a/packages/ui/src/stores/client-state.ts b/packages/ui/src/stores/client-state.ts index b13376e95..fb29dca9a 100644 --- a/packages/ui/src/stores/client-state.ts +++ b/packages/ui/src/stores/client-state.ts @@ -2,6 +2,7 @@ import { createSignal } from "solid-js" import { clearNativeClientState, loadNativeClientState, saveNativeClientState, setNativeRestoreEnabled } from "../lib/native/client-state" import { decodeClientSnapshot, isFutureClientSnapshot, normalizeRestorableSession } from "./client-state-codec" import type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } from "./client-state-codec" +import { clearSessionMessageCache, setSessionMessageCacheEnabled } from "../lib/session-message-cache" export type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } const SAVE_DEBOUNCE_MS = 250 const FLUSH_MAX_ATTEMPTS = 3 @@ -233,6 +234,8 @@ export async function clearRestoredClientState(): Promise { await runDestructiveTransition(async () => { if (!clientStateIsPrimary()) throw new Error("Client state is not owned by this window") await executeDestructiveTransaction(clearNativeClientState, "Native client state clear was rejected", true) + setSessionMessageCacheEnabled(false) + await clearSessionMessageCache() }) } @@ -244,15 +247,19 @@ export async function setRestorePreviousStateEnabled(enabled: boolean): Promise< if (!await setNativeRestoreEnabled(true)) throw new Error("Native restore preference update was rejected") writeBlock = false setRestorePreviousStateEnabledSignal(true) + setSessionMessageCacheEnabled(true) return } setRestorePreviousStateEnabledSignal(false) + setSessionMessageCacheEnabled(false) try { await executeDestructiveTransaction(() => setNativeRestoreEnabled(false), "Native restore preference update was rejected") } catch (error) { setRestorePreviousStateEnabledSignal(true) + setSessionMessageCacheEnabled(true) throw error } + await clearSessionMessageCache() }) } @@ -273,10 +280,12 @@ export function initializeClientState(): Promise { writeBlock = isFutureClientSnapshot(loaded.snapshot) ? "snapshot" : false const snapshot = decodeClientSnapshot(loaded.snapshot) resetLoadedState(snapshot, true) + setSessionMessageCacheEnabled(!writeBlock) if (!writeBlock && migrateLegacyLayoutValues()) scheduleSave() } catch (error) { initialized = true setClientStateIsPrimary(false) + setSessionMessageCacheEnabled(false) resetLoadedState() console.warn("[client-state] failed to initialize client state", error) } diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index 44c3872a1..b2163779c 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -14,7 +14,7 @@ interface SessionMetadata { parentId?: string | null } -function resolveSessionMetadata(session?: Session | null): SessionMetadata | undefined { +function resolveSessionMetadata(session?: Session | SessionMetadata | null): SessionMetadata | undefined { if (!session) return undefined return { id: session.id, @@ -71,6 +71,38 @@ export function seedSessionMessagesV2( return true } +export function mergeCachedSessionMessagePageV2( + instanceId: string, + session: Session | SessionMetadata, + messages: Message[], + messageInfos: Map, + expectedRevision: number, +): number | null { + if (!session || !Array.isArray(messages)) return null + const store = messageStoreBus.getOrCreate(instanceId) + if (!canHydrateMessages(expectedRevision, store.getSessionRevision(session.id))) return null + const metadata = resolveSessionMetadata(session) + if (!metadata) return null + store.addOrUpdateSession({ + id: metadata.id, + title: metadata.title, + parentId: metadata.parentId ?? null, + revert: (session as Session)?.revert ?? undefined, + }) + store.mergeCachedMessages(metadata.id, messages.map((message) => ({ + id: message.id, + sessionId: message.sessionId, + role: message.type, + status: normalizeStatus(message.status), + createdAt: message.timestamp, + updatedAt: message.timestamp, + parts: message.parts, + isEphemeral: false, + bumpRevision: false, + })), messageInfos.values()) + return store.getSessionRevision(metadata.id) +} + interface MessageInfoOptions { status?: MessageStatus bumpRevision?: boolean @@ -81,17 +113,19 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null return } const store = messageStoreBus.getOrCreate(instanceId) - const timeInfo = (info.time ?? {}) as { created?: number; end?: number } + const timeInfo = (info.time ?? {}) as { created?: number; end?: number; completed?: number } const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now() - const endAt = typeof timeInfo.end === "number" ? timeInfo.end : undefined + const endAt = typeof timeInfo.end === "number" ? timeInfo.end : timeInfo.completed + const status = options?.status ?? "complete" store.upsertMessage({ id: info.id, sessionId: info.sessionID, role: info.role === "user" ? "user" : "assistant", - status: options?.status ?? "complete", + status, createdAt, updatedAt: endAt ?? createdAt, + isEphemeral: status === "sending" || status === "streaming", bumpRevision: Boolean(options?.bumpRevision), }) store.setMessageInfo(info.id, info) diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts index 975b4b9f6..c406cbe8e 100644 --- a/packages/ui/src/stores/message-v2/instance-store.test.ts +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -42,3 +42,41 @@ describe("message-v2 permission state", () => { }) }) + +describe("message-v2 authoritative hydration", () => { + const message = (id: string) => ({ + id, + sessionId: "session-1", + role: "assistant" as const, + status: "complete" as const, + parts: [{ id: `part-${id}`, type: "text", text: id, messageID: id, sessionID: "session-1" }] as any, + }) + const info = (id: string) => ({ id, sessionID: "session-1", role: "assistant", time: { created: 1 } }) as any + + it("replaces stale messages and accepts an authoritative empty session", () => { + const store = createInstanceMessageStore("instance-1") + store.hydrateMessages("session-1", [message("message-1"), message("message-2")], [info("message-1"), info("message-2")]) + + store.hydrateMessages("session-1", [message("message-2")], [info("message-2")]) + assert.equal(store.getMessage("message-1"), undefined) + assert.equal(store.getMessageInfo("message-1"), undefined) + assert.deepEqual(store.getSessionMessageIds("session-1"), ["message-2"]) + + store.hydrateMessages("session-1", [], []) + assert.equal(store.getMessage("message-2"), undefined) + assert.deepEqual(store.getSessionMessageIds("session-1"), []) + }) + + it("prepends older cache pages without overwriting live messages", () => { + const store = createInstanceMessageStore("instance-1") + store.mergeCachedMessages("session-1", [message("message-3"), message("message-4")]) + store.upsertMessage({ + ...message("message-4"), + parts: [{ id: "part-message-4", type: "text", text: "live", messageID: "message-4", sessionID: "session-1" }] as any, + }) + store.mergeCachedMessages("session-1", [message("message-1"), message("message-2"), message("message-4")]) + + assert.deepEqual(store.getSessionMessageIds("session-1"), ["message-1", "message-2", "message-3", "message-4"]) + assert.equal((store.getMessage("message-4")?.parts["part-message-4"]?.data as any).text, "live") + }) +}) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 437bea3d4..d1c8c44e4 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -216,6 +216,7 @@ export interface InstanceMessageStore { setState: SetStoreFunction addOrUpdateSession: (input: SessionUpsertInput) => void hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void + mergeCachedMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void upsertMessage: (input: MessageUpsertInput) => void applyPartUpdate: (input: PartUpdateInput) => void applyPartDelta: (input: { @@ -432,11 +433,15 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } function hydrateMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) { - if (!Array.isArray(inputs) || inputs.length === 0) return + if (!Array.isArray(inputs)) return ensureSessionEntry(sessionId) const incomingIds = inputs.map((item) => item.id) + const incomingIdSet = new Set(incomingIds) + const staleIds = Object.values(state.messages) + .filter((record) => record.sessionId === sessionId && !incomingIdSet.has(record.id)) + .map((record) => record.id) const normalizedRecords: Record = {} const now = Date.now() @@ -471,6 +476,22 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const nextPermissionsByMessage: Record> = { ...state.permissions.byMessage, } + const nextQuestionsByMessage: Record> = { + ...state.questions.byMessage, + } + + if (staleIds.length > 0) { + clearRecordDisplayCacheForMessages(instanceId, staleIds) + for (const id of staleIds) { + delete nextMessages[id] + delete nextMessageInfoVersion[id] + delete nextPendingParts[id] + delete nextPermissionsByMessage[id] + delete nextQuestionsByMessage[id] + messageInfoCache.delete(id) + clearPromptDisplayOverride(instanceId, sessionId, id) + } + } Object.entries(normalizedRecords).forEach(([id, record]) => { nextMessages[id] = record @@ -486,10 +507,11 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } batch(() => { - setState("messages", () => nextMessages) - setState("messageInfoVersion", () => nextMessageInfoVersion) - setState("pendingParts", () => nextPendingParts) - setState("permissions", "byMessage", () => nextPermissionsByMessage) + setState("messages", reconcile(nextMessages)) + setState("messageInfoVersion", reconcile(nextMessageInfoVersion)) + setState("pendingParts", reconcile(nextPendingParts)) + setState("permissions", "byMessage", reconcile(nextPermissionsByMessage)) + setState("questions", "byMessage", reconcile(nextQuestionsByMessage)) if (usageState) { setState("usage", sessionId, usageState) @@ -502,6 +524,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt })) recomputeLastAssistantMessageId(sessionId, incomingIds) + clearLatestTodoSnapshot(sessionId) Object.values(normalizedRecords).forEach((record) => { maybeUpdateLatestTodoFromRecord(record) }) @@ -510,6 +533,61 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) } + function mergeCachedMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) { + if (!Array.isArray(inputs) || inputs.length === 0) return + ensureSessionEntry(sessionId) + const existingIds = new Set(state.sessions[sessionId]?.messageIds ?? []) + const additions = inputs.filter((input) => !existingIds.has(input.id) && !state.messages[input.id]) + if (additions.length === 0) return + + const infoById = new Map() + if (infos) { + for (const info of infos) { + if (typeof info.id === "string") infoById.set(info.id, info) + } + } + const additionIds = additions.map((input) => input.id) + const now = Date.now() + const normalizedRecords: Record = {} + for (const input of additions) { + const normalizedParts = normalizeParts(input.id, input.parts) + const clientPromptDisplayMetadata = resolveClientPromptDisplayText(instanceId, input) + normalizedRecords[input.id] = { + id: input.id, + sessionId: input.sessionId, + role: input.role, + status: input.status, + createdAt: input.createdAt ?? now, + updatedAt: input.updatedAt ?? now, + isEphemeral: input.isEphemeral ?? false, + clientPromptDisplayMetadata, + revision: 0, + partIds: normalizedParts?.ids ?? [], + parts: normalizedParts?.map ?? {}, + } + setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayMetadata) + } + + batch(() => { + setState("messages", (current) => ({ ...current, ...normalizedRecords })) + for (const messageId of additionIds) { + const info = infoById.get(messageId) + if (!info) continue + messageInfoCache.set(messageId, info) + setState("messageInfoVersion", messageId, (version = 0) => version + 1) + updateUsageWithInfo(info) + } + setState("sessions", sessionId, (session) => ({ + ...session, + messageIds: [...additionIds, ...(session?.messageIds ?? [])], + updatedAt: Date.now(), + })) + recomputeLastAssistantMessageId(sessionId) + Object.values(normalizedRecords).forEach((record) => maybeUpdateLatestTodoFromRecord(record)) + bumpSessionRevision(sessionId) + }) + } + function insertMessageIntoSession(sessionId: string, messageId: string) { ensureSessionEntry(sessionId) setState("sessions", sessionId, "messageIds", (ids = []) => { @@ -1344,6 +1422,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState, addOrUpdateSession, hydrateMessages, + mergeCachedMessages, upsertMessage, applyPartUpdate, applyPartDelta, diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 6c0db47c7..d46eca63b 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -12,6 +12,7 @@ import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { clearConversationPlaybackForSession } from "./conversation-speech" +import { cancelCachedSessionMessageRestore, invalidateSessionMessageCache } from "./session-message-cache" const log = getLogger("actions") @@ -98,6 +99,7 @@ async function sendMessage( if (!session) { throw new Error("Session not found") } + cancelCachedSessionMessageRestore(instanceId, sessionId) const messageId = createId("msg") const textPartId = createId("prt") @@ -465,6 +467,7 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI ) // Optimistic removal; SSE will also broadcast a part-removed event. + invalidateSessionMessageCache(instanceId, sessionId) removeMessagePartV2(instanceId, messageId, partId) updateSessionInfo(instanceId, sessionId) } @@ -489,6 +492,7 @@ async function deleteMessage(instanceId: string, sessionId: string, messageId: s ) // Optimistic removal; SSE will also broadcast a message-removed event. + invalidateSessionMessageCache(instanceId, sessionId) removeMessageV2(instanceId, messageId) updateSessionInfo(instanceId, sessionId) } diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 28b84ce64..b8f5602c5 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -6,7 +6,7 @@ import { type Session, type SessionStatus, } from "../types/session" -import type { Message } from "../types/message" +import type { Message, MessageInfo } from "../types/message" import type { Session as SDKSession, SessionListResponse } from "@opencode-ai/sdk/v2/client" import { instances, reconcilePendingSessionIndicators } from "./instances" @@ -57,7 +57,7 @@ import { deleteSessionAttachments } from "./attachments" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" -import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge" +import { mergeCachedSessionMessagePageV2, seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge" import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" @@ -84,6 +84,14 @@ import { isProjectSessionListComplete, } from "./session-list-options" import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" +import { + cacheAuthoritativeSessionMessages, + cancelCachedSessionMessageRestore, + clearCachedSessionMessageShift, + invalidateSessionMessageCache, + restoreCachedSessionMessagePages, +} from "./session-message-cache" +import { restorePreviousStateEnabled } from "./client-state" const log = getLogger("api") const sessionListRequestIds = new Map() @@ -92,6 +100,25 @@ const pendingMetadataHydrations = new Map>() const sessionWorkspaceHints = new Map>() messageStoreBus.onInstanceDestroyed((instanceId) => sessionWorkspaceHints.delete(instanceId)) +function adaptApiMessages(sessionId: string, apiMessages: any[]): { messages: Message[]; infos: Map } { + const infos = new Map() + const messages = apiMessages.map((apiMessage: any) => { + const info = (apiMessage.info || apiMessage) as MessageInfo + const messageId = info.id || String(Date.now()) + infos.set(messageId, info) + return { + id: messageId, + sessionId, + type: info.role === "user" ? "user" as const : "assistant" as const, + parts: (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)), + timestamp: info.time?.created || Date.now(), + status: (info as any).error ? "error" as const : "complete" as const, + version: 0, + } + }) + return { messages, infos } +} + function beginSessionListRequest(instanceId: string): number { const requestId = ++nextSessionListRequestId sessionListRequestIds.set(instanceId, requestId) @@ -905,6 +932,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { const force = options?.force ?? false const skipChildren = options?.skipChildren ?? false - if (force) { setMessagesLoaded((prev) => { const next = new Map(prev) @@ -1077,9 +1104,12 @@ async function loadMessages( throw new Error("Session not found") } + cancelCachedSessionMessageRestore(instanceId, sessionId) const loadEpoch = advanceMessageLoadEpoch(instanceId, sessionId) - const messageRevision = messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) + const store = messageStoreBus.getOrCreate(instanceId) + let expectedRevision = store.getSessionRevision(sessionId) let retryAfterRevisionConflict = false + const sessionForV2 = session setLoading((prev) => { const next = { ...prev } @@ -1092,10 +1122,66 @@ async function loadMessages( try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) - const apiMessages = await requestData( - client.session.messages({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }), - "session.messages", + const apiMessagesRequest = getSessionWorkspacePayload(instanceId, sessionId).then((workspacePayload) => + requestData(client.session.messages({ sessionID: sessionId, ...workspacePayload }), "session.messages"), ) + const apiOutcome = apiMessagesRequest.then( + (messages) => ({ ok: true as const, messages }), + (error) => ({ ok: false as const, error }), + ) + let outcome: Awaited | undefined + + if (!force && restorePreviousStateEnabled() && store.getSessionMessageIds(sessionId).length === 0) { + const pages = restoreCachedSessionMessagePages(instanceId, sessionId) + const iterator = pages[Symbol.asyncIterator]() + try { + while (!outcome) { + const winner = await Promise.race([ + apiOutcome.then((value) => ({ kind: "http" as const, value })), + iterator.next().then((value) => ({ kind: "cache" as const, value })), + ]) + if (winner.kind === "http") { + outcome = winner.value + cancelCachedSessionMessageRestore(instanceId, sessionId, { preserveShift: true }) + void iterator.return?.(undefined).catch(() => undefined) + break + } + if (winner.value.done) { + outcome = await apiOutcome + break + } + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) { + cancelCachedSessionMessageRestore(instanceId, sessionId) + return + } + const cached = adaptApiMessages(sessionId, winner.value.value.messages) + const revision = mergeCachedSessionMessagePageV2( + instanceId, + sessionForV2, + cached.messages, + cached.infos, + expectedRevision, + ) + if (revision === null) { + cancelCachedSessionMessageRestore(instanceId, sessionId) + void iterator.return?.(undefined).catch(() => undefined) + outcome = await apiOutcome + break + } + expectedRevision = revision + reconcilePendingPermissionsV2(instanceId, sessionId) + reconcilePendingQuestionsV2(instanceId, sessionId) + } + } catch (error) { + log.warn("Failed to restore cached session messages", { instanceId, sessionId, error }) + invalidateSessionMessageCache(instanceId, sessionId) + } + } + + outcome ??= await apiOutcome + cancelCachedSessionMessageRestore(instanceId, sessionId, { preserveShift: true }) + if (!outcome.ok) throw outcome.error + const apiMessages = outcome.messages if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return @@ -1105,41 +1191,9 @@ async function loadMessages( setSessionMessagesLoadError(instanceId, sessionId, null) - if (apiMessages.length === 0) { - if (messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) !== messageRevision) { - retryAfterRevisionConflict = true - } else { - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) || new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - } - } else { - const messagesInfo = new Map() - const messages: Message[] = apiMessages.map((apiMessage: any) => { - const info = apiMessage.info || apiMessage - const role = info.role || "assistant" - const messageId = info.id || String(Date.now()) - - messagesInfo.set(messageId, info) - - const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)) + const adapted = adaptApiMessages(sessionId, apiMessages) - const message: Message = { - id: messageId, - sessionId, - type: role === "user" ? "user" : "assistant", - parts, - timestamp: info.time?.created || Date.now(), - status: "complete" as const, - version: 0, - } - - return message - }) + if (apiMessages.length > 0) { let agentName = "" let providerID = "" @@ -1181,22 +1235,26 @@ async function loadMessages( return next }) - const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? { - id: sessionId, title: session?.title, parentId: session?.parentId ?? null, revert: session?.revert, - } - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return - if (!seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo, messageRevision)) { - retryAfterRevisionConflict = true - } else { - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) || new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - reconcilePendingPermissionsV2(instanceId, sessionId) - reconcilePendingQuestionsV2(instanceId, sessionId) + } + + const latestSession = sessions().get(instanceId)?.get(sessionId) ?? sessionForV2 + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return + if (!seedSessionMessagesV2(instanceId, latestSession, adapted.messages, adapted.infos, expectedRevision)) { + retryAfterRevisionConflict = true + } else { + setMessagesLoaded((prev) => { + const next = new Map(prev) + const loadedSet = next.get(instanceId) || new Set() + loadedSet.add(sessionId) + next.set(instanceId, loadedSet) + return next + }) + reconcilePendingPermissionsV2(instanceId, sessionId) + reconcilePendingQuestionsV2(instanceId, sessionId) + if (restorePreviousStateEnabled()) { + void cacheAuthoritativeSessionMessages(instanceId, sessionId, apiMessages, store.getSessionRevision(sessionId)).catch((error) => + log.warn("Failed to persist authoritative session messages", { instanceId, sessionId, error }), + ) } } @@ -1209,6 +1267,13 @@ async function loadMessages( } throw error } finally { + if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { + const clearShift = () => { + if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) clearCachedSessionMessageShift(instanceId, sessionId) + } + if (typeof requestAnimationFrame === "function") requestAnimationFrame(clearShift) + else setTimeout(clearShift, 0) + } if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { setLoading((prev) => { const next = { ...prev } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index a5f7d1c56..d461cd601 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -62,7 +62,7 @@ import { type SessionRetryState, type SessionStatus, } from "../types/session" -import { ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, prependSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" +import { ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, messagesLoaded, prependSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { mergeFetchedSessionRuntimeState } from "./session-generation-recovery" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" @@ -90,6 +90,8 @@ import { import { messageStoreBus } from "./message-v2/bus" import type { InstanceMessageStore } from "./message-v2/instance-store" import { handleConversationAssistantPartUpdated } from "./conversation-speech" +import { cancelCachedSessionMessageRestore, invalidateSessionMessageCache, scheduleSessionMessageCacheWrite } from "./session-message-cache" +import { restorePreviousStateEnabled } from "./client-state" const log = getLogger("sse") const pendingSessionFetches = new Map>() @@ -305,6 +307,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId if (!sessionId || !messageId) return + cancelCachedSessionMessageRestore(instanceId, sessionId) if (part.type === "compaction") { ensureSessionStatus(instanceId, sessionId, "compacting", (event as any)?.directory) } @@ -362,6 +365,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const sessionId = typeof info.sessionID === "string" ? info.sessionID : undefined const messageId = typeof info.id === "string" ? info.id : undefined if (!sessionId || !messageId) return + cancelCachedSessionMessageRestore(instanceId, sessionId) // Flush any pending deltas for this message before applying the update. // Deltas are buffered for up to 50ms; if message.updated arrives before @@ -371,15 +375,17 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes // message status/metadata update runs on the complete content. flushPendingDeltasForMessage(instanceId, messageId, applyPartDeltaV2) - const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number } + const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number; completed?: number } const nextUpdated = typeof timeInfo.end === "number" && timeInfo.end > 0 ? timeInfo.end - : typeof timeInfo.updated === "number" && timeInfo.updated > 0 - ? timeInfo.updated - : typeof timeInfo.created === "number" && timeInfo.created > 0 - ? timeInfo.created - : Date.now() + : typeof timeInfo.completed === "number" && timeInfo.completed > 0 + ? timeInfo.completed + : typeof timeInfo.updated === "number" && timeInfo.updated > 0 + ? timeInfo.updated + : typeof timeInfo.created === "number" && timeInfo.created > 0 + ? timeInfo.created + : Date.now() withSession(instanceId, sessionId, (session) => { const currentUpdated = session.time?.updated ?? 0 @@ -391,7 +397,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const role: MessageRole = info.role === "user" ? "user" : "assistant" const hasError = Boolean((info as any).error) - const hasEnded = typeof timeInfo.end === "number" && timeInfo.end > 0 + const hasEnded = + (typeof timeInfo.end === "number" && timeInfo.end > 0) || + (typeof timeInfo.completed === "number" && timeInfo.completed > 0) const status: MessageStatus = hasError ? "error" : hasEnded ? "complete" : "streaming" let record = store.getMessage(messageId) @@ -405,7 +413,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes if (!record) { const createdAt = info.time?.created ?? Date.now() - const endAt = (info.time as { end?: number } | undefined)?.end + const endAt = timeInfo.end ?? timeInfo.completed store.upsertMessage({ id: messageId, sessionId, @@ -419,6 +427,13 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true }) updateSessionInfo(instanceId, sessionId) + if ( + restorePreviousStateEnabled() && + messagesLoaded().get(instanceId)?.has(sessionId) && + (status === "complete" || status === "error") + ) { + scheduleSessionMessageCacheWrite(instanceId, sessionId) + } } } @@ -434,6 +449,7 @@ function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent if (!props) return const { messageID, partID, field, delta } = props if (!messageID || !partID || !field || typeof delta !== "string") return + cancelCachedSessionMessageRestore(instanceId, props.sessionID) enqueueDelta(instanceId, messageID, partID, field, delta) } @@ -442,6 +458,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo if (!info) return if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(info.id)) return + if ((info as any).revert !== undefined) invalidateSessionMessageCache(instanceId, info.id) const instanceSessions = sessions().get(instanceId) ?? new Map() @@ -595,6 +612,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted if (!sessionID) return log.info(`[SSE] Session compacted: ${sessionID}`) + invalidateSessionMessageCache(instanceId, sessionID) const existing = sessions().get(instanceId)?.get(sessionID) if (existing) setSessionStatus(instanceId, sessionID, "working", { force: true }) @@ -642,6 +660,7 @@ function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): v if (!sessionID || !messageID) return log.info(`[SSE] Message removed from session ${sessionID}`, { messageID }) + invalidateSessionMessageCache(instanceId, sessionID) removeMessageV2(instanceId, messageID, sessionID) updateSessionInfo(instanceId, sessionID) } @@ -651,6 +670,7 @@ function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedE if (!sessionID || !messageID || !partID) return log.info(`[SSE] Message part removed from session ${sessionID}`, { messageID, partID }) + invalidateSessionMessageCache(instanceId, sessionID) removeMessagePartV2(instanceId, messageID, partID, sessionID) updateSessionInfo(instanceId, sessionID) } diff --git a/packages/ui/src/stores/session-message-cache.ts b/packages/ui/src/stores/session-message-cache.ts new file mode 100644 index 000000000..17e1c97ef --- /dev/null +++ b/packages/ui/src/stores/session-message-cache.ts @@ -0,0 +1,259 @@ +import { + deleteSessionMessageCache, + createSessionMessageCacheKey, + type SessionMessageCachePage, + isSessionMessageCacheEnabled, + onSessionMessageCacheReset, + openSessionMessageCache, + readSessionMessageCachePage, + writeSessionMessageCache, +} from "../lib/session-message-cache" +import { createSignal } from "solid-js" +import { getLogger } from "../lib/logger" +import { instances } from "./instances" +import { messageStoreBus } from "./message-v2/bus" + +const log = getLogger("session") +const WRITE_DEBOUNCE_MS = 500 +const pendingWrites = new Map>() +const cacheGenerations = new Map() +const pendingInvalidations = new Map>() +const invalidatedEntries = new Set() +const activeRestores = new Map() +const [restoringSessions, setRestoringSessions] = createSignal>(new Set()) +let resetGeneration = 0 + +function pendingKey(instanceId: string, sessionId: string): string { + return `${instanceId}:${sessionId}` +} + +function cacheGeneration(key: string): number { + return cacheGenerations.get(key) ?? 0 +} + +function setRestoreActive(key: string, active: boolean): void { + setRestoringSessions((current) => { + const next = new Set(current) + if (active) next.add(key) + else next.delete(key) + return next + }) +} + +export function isRestoringCachedSessionMessages(instanceId: string, sessionId: string): boolean { + return restoringSessions().has(pendingKey(instanceId, sessionId)) +} + +export function cancelCachedSessionMessageRestore( + instanceId: string, + sessionId: string, + options?: { preserveShift?: boolean }, +): void { + const key = pendingKey(instanceId, sessionId) + activeRestores.delete(key) + if (!options?.preserveShift) setRestoreActive(key, false) +} + +export function clearCachedSessionMessageShift(instanceId: string, sessionId: string): void { + setRestoreActive(pendingKey(instanceId, sessionId), false) +} + +function workspaceForInstance(instanceId: string): string | null { + return instances().get(instanceId)?.folder ?? null +} + +function waitForIdle(): Promise { + return new Promise((resolve) => { + if (typeof requestIdleCallback === "function") { + requestIdleCallback(() => resolve(), { timeout: 2_000 }) + } else { + setTimeout(resolve, 0) + } + }) +} + +function snapshotSession(instanceId: string, sessionId: string): unknown[] | null { + const store = messageStoreBus.getInstance(instanceId) + if (!store) return null + + const result: unknown[] = [] + for (const messageId of store.getSessionMessageIds(sessionId)) { + const record = store.getMessage(messageId) + const info = store.getMessageInfo(messageId) + if (!record || !info || record.isEphemeral || record.status === "sending" || record.status === "streaming") return null + + const parts = record.partIds.map((partId) => record.parts[partId]?.data).filter(Boolean).map((part) => { + const { renderCache: _renderCache, pendingPermission: _pendingPermission, ...cacheable } = part as any + return cacheable + }) + result.push({ info, parts }) + } + return result +} + +export async function* restoreCachedSessionMessagePages( + instanceId: string, + sessionId: string, +): AsyncGenerator { + if (!isSessionMessageCacheEnabled()) return + const workspace = workspaceForInstance(instanceId) + if (!workspace) return + const entryKey = createSessionMessageCacheKey(workspace, sessionId) + if (invalidatedEntries.has(entryKey)) return + const key = pendingKey(instanceId, sessionId) + const generation = cacheGeneration(key) + const reset = resetGeneration + const token = Symbol(key) + let restoredPage = false + activeRestores.set(key, token) + setRestoreActive(key, true) + try { + await pendingInvalidations.get(key) + let cursor = await openSessionMessageCache(workspace, sessionId) + const scroll = messageStoreBus.getInstance(instanceId)?.getScrollSnapshot(sessionId, "message-stream") + const deferredAnchor = scroll && !scroll.atBottom ? scroll.anchorKey : undefined + let buffered: unknown[] = [] + while ( + cursor && + cursor.beforeIndex > 0 && + activeRestores.get(key) === token && + cacheGeneration(key) === generation && + resetGeneration === reset && + !invalidatedEntries.has(entryKey) + ) { + const result = await readSessionMessageCachePage(cursor) + if (!result) return + if ( + activeRestores.get(key) !== token || + cacheGeneration(key) !== generation || + resetGeneration !== reset || + invalidatedEntries.has(entryKey) + ) return + cursor = result.cursor + const page = result.page + if (deferredAnchor && !page.messages.some((message) => (message as any)?.info?.id === deferredAnchor) && cursor.beforeIndex > 0) { + buffered = [...page.messages, ...buffered] + continue + } + if (buffered.length > 0) { + restoredPage = true + yield { ...page, messages: [...page.messages, ...buffered] } + buffered = [] + } else { + restoredPage = true + yield page + } + } + if (buffered.length > 0) { + restoredPage = true + yield { messages: buffered, startIndex: cursor?.startIndex ?? 0, totalCount: cursor?.totalCount ?? buffered.length, done: true, complete: cursor?.complete ?? true } + } + } finally { + if (activeRestores.get(key) === token) { + activeRestores.delete(key) + if (!restoredPage) setRestoreActive(key, false) + } + } +} + +export async function cacheAuthoritativeSessionMessages( + instanceId: string, + sessionId: string, + messages: unknown[], + expectedRevision: number, +): Promise { + if (!isSessionMessageCacheEnabled()) return + const store = messageStoreBus.getInstance(instanceId) + if (!store) return + const workspace = workspaceForInstance(instanceId) + if (!workspace) return + const key = pendingKey(instanceId, sessionId) + const entryKey = createSessionMessageCacheKey(workspace, sessionId) + const generation = cacheGeneration(key) + const reset = resetGeneration + await waitForIdle() + if ( + cacheGeneration(key) !== generation || + resetGeneration !== reset || + messageStoreBus.getInstance(instanceId) !== store || + store.getSessionRevision(sessionId) !== expectedRevision + ) return + const written = await writeSessionMessageCache(workspace, sessionId, messages) + if (written && cacheGeneration(key) === generation && resetGeneration === reset) { + invalidatedEntries.delete(entryKey) + } +} + +export function scheduleSessionMessageCacheWrite(instanceId: string, sessionId: string): void { + if (!isSessionMessageCacheEnabled()) return + const key = pendingKey(instanceId, sessionId) + const existing = pendingWrites.get(key) + if (existing) clearTimeout(existing) + pendingWrites.set(key, setTimeout(() => { + pendingWrites.delete(key) + const workspace = workspaceForInstance(instanceId) + const messages = snapshotSession(instanceId, sessionId) + if (!workspace || !messages) return + const generation = cacheGeneration(key) + const reset = resetGeneration + const entryKey = createSessionMessageCacheKey(workspace, sessionId) + void writeSessionMessageCache(workspace, sessionId, messages) + .then((written) => { + if (written && cacheGeneration(key) === generation && resetGeneration === reset) { + invalidatedEntries.delete(entryKey) + } + }) + .catch((error) => log.warn("Failed to cache completed session messages", { instanceId, sessionId, error })) + }, WRITE_DEBOUNCE_MS)) +} + +export function invalidateSessionMessageCache(instanceId: string, sessionId: string): void { + const key = pendingKey(instanceId, sessionId) + cancelCachedSessionMessageRestore(instanceId, sessionId) + cacheGenerations.set(key, cacheGeneration(key) + 1) + const pending = pendingWrites.get(key) + if (pending) { + clearTimeout(pending) + pendingWrites.delete(key) + } + const workspace = workspaceForInstance(instanceId) + if (!workspace) return + const entryKey = createSessionMessageCacheKey(workspace, sessionId) + invalidatedEntries.add(entryKey) + const invalidation = deleteSessionMessageCache(workspace, sessionId) + .then(() => { invalidatedEntries.delete(entryKey) }) + .catch((error) => log.warn("Failed to invalidate cached session messages", { instanceId, sessionId, error })) + .finally(() => { + if (pendingInvalidations.get(key) === invalidation) pendingInvalidations.delete(key) + }) + pendingInvalidations.set(key, invalidation) +} + +function resetPendingSessionMessageCacheWork(): void { + resetGeneration += 1 + activeRestores.clear() + setRestoringSessions(new Set()) + for (const timer of pendingWrites.values()) clearTimeout(timer) + pendingWrites.clear() +} + +onSessionMessageCacheReset(resetPendingSessionMessageCacheWork) + +messageStoreBus.onInstanceDestroyed((instanceId) => { + const prefix = `${instanceId}:` + for (const [key, timer] of pendingWrites) { + if (!key.startsWith(prefix)) continue + clearTimeout(timer) + pendingWrites.delete(key) + } + for (const key of cacheGenerations.keys()) { + if (key.startsWith(prefix)) cacheGenerations.set(key, cacheGeneration(key) + 1) + } + for (const key of activeRestores.keys()) { + if (key.startsWith(prefix)) activeRestores.delete(key) + } + setRestoringSessions((current) => new Set([...current].filter((key) => !key.startsWith(prefix)))) + for (const key of pendingInvalidations.keys()) { + if (key.startsWith(prefix)) pendingInvalidations.delete(key) + } +}) diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 99bfeb78f..114484a4f 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -176,4 +176,20 @@ describe("session request authority", () => { cleanup() } }) + + it("preserves historical assistant error status during hydration", async () => { + const instanceId = "errored-message-load", sessionId = "session" + const { client, cleanup } = setup(instanceId) + ;(client.session as any).messages = async () => ({ + data: [{ ...apiMessage("errored-message", sessionId), info: { ...apiMessage("errored-message", sessionId).info, error: { name: "ProviderError" } } }], + }) + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + + try { + await loadMessages(instanceId, sessionId) + assert.equal(messageStoreBus.getOrCreate(instanceId).getMessage("errored-message")?.status, "error") + } finally { + cleanup() + } + }) }) diff --git a/packages/ui/src/stores/settings-screen.ts b/packages/ui/src/stores/settings-screen.ts index f083324a8..f83f009a6 100644 --- a/packages/ui/src/stores/settings-screen.ts +++ b/packages/ui/src/stores/settings-screen.ts @@ -4,11 +4,14 @@ export type SettingsSectionId = | "general" | "chat" | "notifications" - | "voice" - | "connections" - | "runtime" + | "speech" + | "remote" + | "opencode" + | "providers" + | "sidecars" + | "config-files" | "advanced" - | "about" + | "info" const [settingsOpen, setSettingsOpen] = createSignal(false) const [activeSettingsSection, setActiveSettingsSection] = createSignal("general") diff --git a/packages/ui/src/styles/components/provider-auth.css b/packages/ui/src/styles/components/provider-auth.css index 1acc258ca..3132b842a 100644 --- a/packages/ui/src/styles/components/provider-auth.css +++ b/packages/ui/src/styles/components/provider-auth.css @@ -12,6 +12,19 @@ border-radius: 0; } +.providers-manager-embedded { + position: static; + transform: none; + width: 100%; + max-height: none; + border: 1px solid var(--border-base); + background: var(--surface-base); +} + +.providers-manager-embedded .providers-manager-body { + overflow: visible; +} + .providers-manager-header { display: flex; align-items: flex-start; @@ -338,6 +351,12 @@ height: 100dvh; } + .providers-manager-embedded { + width: 100%; + max-height: none; + height: auto; + } + .providers-connect-bar, .providers-panel-header { grid-template-columns: 1fr; diff --git a/packages/ui/src/styles/components/settings-screen.css b/packages/ui/src/styles/components/settings-screen.css index e6c943388..466fd32fc 100644 --- a/packages/ui/src/styles/components/settings-screen.css +++ b/packages/ui/src/styles/components/settings-screen.css @@ -41,6 +41,8 @@ display: flex; flex-direction: column; gap: 1rem; + min-height: 0; + overflow: hidden; padding: 1.25rem; background: linear-gradient(180deg, color-mix(in oklab, var(--surface-secondary) 92%, var(--accent-primary) 8%), var(--surface-secondary)); @@ -94,6 +96,9 @@ display: flex; flex-direction: column; gap: 0.35rem; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; } .settings-nav-button { @@ -112,7 +117,7 @@ .settings-nav-button:focus-visible { outline: 2px solid var(--accent-primary); - outline-offset: 2px; + outline-offset: -2px; } .settings-nav-button:hover { @@ -550,6 +555,7 @@ .settings-expansion-table { margin-bottom: 1.25rem; + border: 1px solid var(--border-base); } .settings-expansion-table-header, From 5b3bbcd6bba804c8b2b288de95175e82900e3472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 26 Jul 2026 18:20:13 +0200 Subject: [PATCH 6/7] Revert "feat(ui): finalize settings and cache session history" This reverts commit a1e57f0884f1ef814eb469d3305397903d22eb0c. --- packages/ui/src/App.tsx | 2 +- .../src/components/folder-selection-view.tsx | 4 +- packages/ui/src/components/instance-tabs.tsx | 2 +- .../ui/src/components/message-section.tsx | 15 +- .../provider-auth/provider-manager-modal.tsx | 179 ++----- .../src/components/session/session-view.tsx | 2 - .../ui/src/components/settings-screen.tsx | 50 +- .../settings/advanced-settings-section.tsx | 90 +++- .../config-files-settings-section.tsx | 1 - .../settings/connections-settings-section.tsx | 65 +++ .../settings/general-settings-section.tsx | 2 + .../settings/opencode-settings-section.tsx | 97 ---- .../settings/providers-settings-section.tsx | 7 - .../settings/runtime-settings-section.tsx | 45 ++ .../ui/src/components/virtual-follow-list.tsx | 2 - packages/ui/src/lib/hooks/use-commands.ts | 2 - .../ui/src/lib/i18n/messages/de/settings.ts | 12 +- .../ui/src/lib/i18n/messages/en/settings.ts | 12 +- .../ui/src/lib/i18n/messages/es/settings.ts | 12 +- .../ui/src/lib/i18n/messages/fr/settings.ts | 12 +- .../ui/src/lib/i18n/messages/he/settings.ts | 12 +- .../ui/src/lib/i18n/messages/ja/settings.ts | 12 +- .../ui/src/lib/i18n/messages/ne/settings.ts | 12 +- .../ui/src/lib/i18n/messages/ru/settings.ts | 12 +- .../src/lib/i18n/messages/zh-Hans/settings.ts | 12 +- .../ui/src/lib/session-message-cache.test.ts | 34 -- packages/ui/src/lib/session-message-cache.ts | 441 ------------------ packages/ui/src/stores/client-state.ts | 9 - packages/ui/src/stores/message-v2/bridge.ts | 42 +- .../stores/message-v2/instance-store.test.ts | 38 -- .../src/stores/message-v2/instance-store.ts | 89 +--- packages/ui/src/stores/session-actions.ts | 4 - packages/ui/src/stores/session-api.ts | 179 +++---- packages/ui/src/stores/session-events.ts | 38 +- .../ui/src/stores/session-message-cache.ts | 259 ---------- .../stores/session-request-authority.test.ts | 16 - packages/ui/src/stores/settings-screen.ts | 11 +- .../src/styles/components/provider-auth.css | 19 - .../src/styles/components/settings-screen.css | 8 +- 39 files changed, 415 insertions(+), 1445 deletions(-) create mode 100644 packages/ui/src/components/settings/connections-settings-section.tsx delete mode 100644 packages/ui/src/components/settings/opencode-settings-section.tsx delete mode 100644 packages/ui/src/components/settings/providers-settings-section.tsx create mode 100644 packages/ui/src/components/settings/runtime-settings-section.tsx delete mode 100644 packages/ui/src/lib/session-message-cache.test.ts delete mode 100644 packages/ui/src/lib/session-message-cache.ts delete mode 100644 packages/ui/src/stores/session-message-cache.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 189957c7f..cea0b2917 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -327,7 +327,7 @@ const App: Component = () => { function handleLaunchErrorAdvanced() { clearLaunchError() - openSettings("opencode") + openSettings("runtime") } function handleNewInstanceRequest() { diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index af499dcfe..e8905090f 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -539,7 +539,7 @@ const FolderSelectionView: Component = (props) => { - +
-
{t("settings.providers.empty.noInstance")}
+
{t("settings.providers.empty.noInstance")}
@@ -585,10 +524,7 @@ export const ProviderManagerModal: Component = (props
- - - - +
{loadError()}
@@ -627,7 +563,6 @@ export const ProviderManagerModal: Component = (props options={methodOptions()} optionValue="value" optionTextValue="label" - disabled={stage() !== "prompts" && stage() !== "error"} itemComponent={(itemProps) => {itemProps.item.rawValue.label}} > @@ -646,9 +581,9 @@ export const ProviderManagerModal: Component = (props
- +
{t("settings.providers.oauth.promptsUnsupported")}
-
{t("settings.providers.oauth.noPrompts")}
+
{t("settings.providers.oauth.noPrompts")}
{(prompt) => (
@@ -663,8 +598,8 @@ export const ProviderManagerModal: Component = (props
-
{authorization()?.instructions || t("settings.providers.oauth.enterCode")}
-
{t("settings.providers.oauth.waitingTitle")}
{authorization()?.instructions}
+
{authorization()?.instructions || t("settings.providers.oauth.enterCode")}
+
{t("settings.providers.oauth.waitingTitle")}
{authorization()?.instructions}
- +
{t("settings.providers.oauth.popupBlocked")}
-
{t("settings.providers.success")}
+
{t("settings.providers.success")}
+
{actionError()}
void submitAuth()}>{t("settings.providers.actions.working")}}> @@ -694,32 +630,19 @@ export const ProviderManagerModal: Component = (props

{t("settings.providers.configured.title")}

-
{t("settings.providers.loading")}
-
{t("settings.providers.empty.noConfiguredProviders")}
+
{t("settings.providers.loading")}
+
{t("settings.providers.empty.noConfiguredProviders")}
{(provider) => (

{provider.name || provider.id}

{provider.id}

{methodSummary(provider.id)}

{describeProviderSource(provider)}

- +
)}
- - ) - - if (props.embedded) { - return
{content()}
- } - - return ( - - - - - {content()} diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 496d488c8..af11017a2 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -23,7 +23,6 @@ import { SessionPreviewView } from "../session-preview-view" import { isSnapshotAutoFollowing } from "../virtual-follow-behavior" import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent, type SessionBottomPinIntent } from "./session-bottom-pin-intent" import { focusConversationStream } from "../focus-conversation" -import { invalidateSessionMessageCache } from "../../stores/session-message-cache" const log = getLogger("session") @@ -432,7 +431,6 @@ export const SessionView: Component = (props) => { }), "session.revert", ) - invalidateSessionMessageCache(props.instanceId, props.sessionId) const restoredText = getUserMessageText(messageId) if (restoredText) { diff --git a/packages/ui/src/components/settings-screen.tsx b/packages/ui/src/components/settings-screen.tsx index adc07b773..ce12b0d48 100644 --- a/packages/ui/src/components/settings-screen.tsx +++ b/packages/ui/src/components/settings-screen.tsx @@ -1,6 +1,6 @@ import { Dialog } from "@kobalte/core/dialog" import { Select } from "@kobalte/core/select" -import { Settings, Bell, ChevronDown, FileCog, Globe, Info, MessageSquare, MonitorUp, PlugZap, SlidersHorizontal, Terminal, Volume2, X } from "lucide-solid" +import { Settings, Bell, ChevronDown, FileCog, Info, MessageSquare, SlidersHorizontal, Terminal, Volume2, Wifi, X } from "lucide-solid" import { createMemo, For, type Component } from "solid-js" import { useI18n } from "../lib/i18n" import { @@ -15,14 +15,9 @@ import { ChatSettingsSection } from "./settings/chat-settings-section" import { InfoSettingsSection } from "./settings/info-settings-section" import { NotificationsSettingsSection } from "./settings/notifications-settings-section" import { SpeechSettingsSection } from "./settings/speech-settings-section" -import { ProvidersSettingsSection } from "./settings/providers-settings-section" -import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" +import { ConnectionsSettingsSection } from "./settings/connections-settings-section" +import { RuntimeSettingsSection } from "./settings/runtime-settings-section" import { AdvancedSettingsSection } from "./settings/advanced-settings-section" -import { ConfigFilesSettingsSection } from "./settings/config-files-settings-section" -import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" -import { SavedRemoteServersCard } from "./settings/saved-remote-servers-card" -import { SideCarsSettingsSection } from "./settings/sidecars-settings-section" -import { canOpenRemoteWindows } from "../lib/runtime-env" import { confirmSettingsDiscard } from "../stores/settings-dirty-guard" type SettingsSectionOption = { @@ -39,17 +34,12 @@ export const SettingsScreen: Component = () => { { id: "general", icon: SlidersHorizontal, label: t("settings.nav.general") }, { id: "chat", icon: MessageSquare, label: t("settings.nav.chat") }, { id: "notifications", icon: Bell, label: t("settings.nav.notifications") }, - { id: "speech", icon: Volume2, label: t("settings.nav.speech") }, - { id: "opencode", icon: Terminal, label: t("settings.nav.opencode") }, - { id: "providers", icon: PlugZap, label: t("settings.nav.providers") }, - { id: "sidecars", icon: Globe, label: t("settings.nav.sidecars") }, - { id: "config-files", icon: FileCog, label: t("settings.nav.configFiles") }, - { id: "advanced", icon: Settings, label: t("settings.nav.advanced") }, - { id: "info", icon: Info, label: t("settings.nav.info") }, + { id: "voice", icon: Volume2, label: t("settings.nav.voice") }, + { id: "connections", icon: Wifi, label: t("settings.nav.connections") }, + { id: "runtime", icon: Terminal, label: t("settings.nav.runtime") }, + { id: "advanced", icon: FileCog, label: t("settings.nav.advanced") }, + { id: "about", icon: Info, label: t("settings.nav.about") }, ] - if (canOpenRemoteWindows()) { - items.splice(4, 0, { id: "remote", icon: MonitorUp, label: t("settings.nav.remote") }) - } return items }) @@ -61,26 +51,15 @@ export const SettingsScreen: Component = () => { return case "notifications": return - case "speech": + case "voice": return - case "remote": - return canOpenRemoteWindows() ? ( -
- - -
- ) : - case "opencode": - return - case "providers": - return - case "sidecars": - return - case "config-files": - return + case "connections": + return + case "runtime": + return case "advanced": return - case "info": + case "about": return case "general": default: @@ -187,7 +166,6 @@ export const SettingsScreen: Component = () => { type="button" class="settings-nav-button" data-selected={activeSettingsSection() === section.id ? "true" : "false"} - aria-current={activeSettingsSection() === section.id ? "page" : undefined} onClick={() => void handleSectionChange(section.id)} > diff --git a/packages/ui/src/components/settings/advanced-settings-section.tsx b/packages/ui/src/components/settings/advanced-settings-section.tsx index 6de2da029..1c601ade6 100644 --- a/packages/ui/src/components/settings/advanced-settings-section.tsx +++ b/packages/ui/src/components/settings/advanced-settings-section.tsx @@ -1,24 +1,77 @@ -import { createMemo, type Component } from "solid-js" +import { Select } from "@kobalte/core/select" +import { ChevronDown } from "lucide-solid" +import { createMemo, Show, type Component } from "solid-js" import { useI18n } from "../../lib/i18n" import { getBehaviorSettings } from "../../lib/settings/behavior-registry" -import { useConfig } from "../../stores/preferences" +import { isTauriHost } from "../../lib/runtime-env" +import { useConfig, type ServerLogLevel } from "../../stores/preferences" import EnvironmentVariablesEditor from "../environment-variables-editor" import { BehaviorSettingRows } from "./behavior-setting-rows" +import { ConfigFilesSettingsSection } from "./config-files-settings-section" + +type LogLevelOption = { value: ServerLogLevel; label: string } export const AdvancedSettingsSection: Component = () => { const { t } = useI18n() const config = useConfig() - const advancedSettings = createMemo(() => - getBehaviorSettings(config).filter( - (setting) => - setting.id === "behavior.autoCleanupBlankSessions" || - setting.id === "behavior.keepUnseenSubagentIdleStatus" || - setting.id === "behavior.tauriNativeEventTransport", - ), + const { serverSettings, updateLogLevel } = config + const logLevelOptions = createMemo(() => [ + { value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") }, + { value: "INFO", label: t("settings.opencode.logLevel.option.info") }, + { value: "WARN", label: t("settings.opencode.logLevel.option.warn") }, + { value: "ERROR", label: t("settings.opencode.logLevel.option.error") }, + ]) + const selectedLogLevel = createMemo( + () => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0], + ) + const transportSettings = createMemo(() => + getBehaviorSettings(config).filter((setting) => setting.id === "behavior.tauriNativeEventTransport"), ) return (
+
+
+
+

{t("settings.opencode.logLevel.title")}

+

{t("settings.opencode.logLevel.subtitle")}

+
+ {t("settings.scope.server")} +
+
+
+
+
{t("settings.opencode.logLevel.selector.title")}
+
{t("settings.opencode.logLevel.selector.subtitle")}
+
+ + value={selectedLogLevel()} + onChange={(option) => option && updateLogLevel(option.value)} + options={logLevelOptions()} + optionValue="value" + optionTextValue="label" + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + > + +
+ > + {(state) => {state.selectedOption()?.label}} + +
+ +
+ + + + +
+
+
+
@@ -30,9 +83,24 @@ export const AdvancedSettingsSection: Component = () => {
+ +
+
+ +
+
+
+
-
- +
+
+

{t("settings.configFiles.title")}

+

{t("settings.configFiles.subtitle")}

+
+ {t("settings.scope.server")} +
+
+
diff --git a/packages/ui/src/components/settings/config-files-settings-section.tsx b/packages/ui/src/components/settings/config-files-settings-section.tsx index 9c08312e2..e1f3f9867 100644 --- a/packages/ui/src/components/settings/config-files-settings-section.tsx +++ b/packages/ui/src/components/settings/config-files-settings-section.tsx @@ -122,7 +122,6 @@ export const ConfigFilesSettingsSection: Component = () => { return (
-

{t("settings.configFiles.title")}

diff --git a/packages/ui/src/components/settings/connections-settings-section.tsx b/packages/ui/src/components/settings/connections-settings-section.tsx new file mode 100644 index 000000000..504810c1b --- /dev/null +++ b/packages/ui/src/components/settings/connections-settings-section.tsx @@ -0,0 +1,65 @@ +import { PlugZap } from "lucide-solid" +import { createMemo, createSignal, Show, type Component } from "solid-js" +import { useI18n } from "../../lib/i18n" +import { canOpenRemoteWindows } from "../../lib/runtime-env" +import { activeInstanceId, instances } from "../../stores/instances" +import { ProviderManagerModal } from "../provider-auth/provider-manager-modal" +import { RemoteAccessSettingsSection } from "./remote-access-settings-section" +import { SavedRemoteServersCard } from "./saved-remote-servers-card" + +export const ConnectionsSettingsSection: Component = () => { + const { t } = useI18n() + const [providerManagerOpen, setProviderManagerOpen] = createSignal(false) + const providerInstance = createMemo(() => { + const id = activeInstanceId() + return id ? instances().get(id) ?? null : null + }) + const providerInstanceLabel = createMemo(() => { + const instance = providerInstance() + if (!instance) return "" + return `${instance.projectName || instance.folder} (${instance.id})` + }) + + return ( + <> +
+ + + + + +
+
+
+ +
+

{t("settings.providers.title")}

+

{t("settings.providers.subtitle")}

+

+ {providerInstance() + ? t("settings.providers.target", { workspace: providerInstanceLabel() }) + : t("settings.providers.empty.noInstance")} +

+
+
+ +
+
+
+ + + + ) +} diff --git a/packages/ui/src/components/settings/general-settings-section.tsx b/packages/ui/src/components/settings/general-settings-section.tsx index f55502c02..4d71318c7 100644 --- a/packages/ui/src/components/settings/general-settings-section.tsx +++ b/packages/ui/src/components/settings/general-settings-section.tsx @@ -26,6 +26,8 @@ export const GeneralSettingsSection: Component = () => { setting.id === "behavior.messageTimeline" || setting.id === "behavior.timelineToolCalls" || setting.id === "behavior.diffViewMode" || + setting.id === "behavior.autoCleanupBlankSessions" || + setting.id === "behavior.keepUnseenSubagentIdleStatus" || setting.id === "behavior.promptSubmitOnEnter", ), { diff --git a/packages/ui/src/components/settings/opencode-settings-section.tsx b/packages/ui/src/components/settings/opencode-settings-section.tsx deleted file mode 100644 index f919dbf01..000000000 --- a/packages/ui/src/components/settings/opencode-settings-section.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Select } from "@kobalte/core/select" -import { createEffect, createMemo, createSignal, type Component } from "solid-js" -import { ChevronDown, Terminal } from "lucide-solid" -import OpenCodeBinarySelector from "../opencode-binary-selector" -import { useConfig, type ServerLogLevel } from "../../stores/preferences" -import { useI18n } from "../../lib/i18n" -import { OpenCodeUpdateCard } from "./opencode-update-card" - -type LogLevelOption = { value: ServerLogLevel; label: string } - -export const OpenCodeSettingsSection: Component = () => { - const { t } = useI18n() - const { serverSettings, updateLastUsedBinary, updateLogLevel } = useConfig() - const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") - const logLevelOptions = createMemo(() => [ - { value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") }, - { value: "INFO", label: t("settings.opencode.logLevel.option.info") }, - { value: "WARN", label: t("settings.opencode.logLevel.option.warn") }, - { value: "ERROR", label: t("settings.opencode.logLevel.option.error") }, - ]) - const selectedLogLevel = createMemo( - () => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0], - ) - - createEffect(() => { - const binary = serverSettings().opencodeBinary || "opencode" - setSelectedBinary((current) => (current === binary ? current : binary)) - }) - - const handleBinaryChange = (binary: string) => { - setSelectedBinary(binary) - updateLastUsedBinary(binary) - } - - return ( -
-
-
-
- -
-

{t("settings.opencode.runtime.title")}

-

{t("settings.opencode.runtime.subtitle")}

-
-
- {t("settings.scope.server")} -
- - -
- - - -
-
-
-

{t("settings.opencode.logLevel.title")}

-

{t("settings.opencode.logLevel.subtitle")}

-
- {t("settings.scope.server")} -
-
-
-
-
{t("settings.opencode.logLevel.selector.title")}
-
{t("settings.opencode.logLevel.selector.subtitle")}
-
- - value={selectedLogLevel()} - onChange={(option) => option && updateLogLevel(option.value)} - options={logLevelOptions()} - optionValue="value" - optionTextValue="label" - itemComponent={(itemProps) => ( - - {itemProps.item.rawValue.label} - - )} - > - -
- > - {(state) => {state.selectedOption()?.label}} - -
- -
- - - - -
-
-
-
- ) -} diff --git a/packages/ui/src/components/settings/providers-settings-section.tsx b/packages/ui/src/components/settings/providers-settings-section.tsx deleted file mode 100644 index 50988162c..000000000 --- a/packages/ui/src/components/settings/providers-settings-section.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { type Component } from "solid-js" -import { activeInstanceId } from "../../stores/instances" -import { ProviderManagerModal } from "../provider-auth/provider-manager-modal" - -export const ProvidersSettingsSection: Component = () => { - return -} diff --git a/packages/ui/src/components/settings/runtime-settings-section.tsx b/packages/ui/src/components/settings/runtime-settings-section.tsx new file mode 100644 index 000000000..ef56c26e1 --- /dev/null +++ b/packages/ui/src/components/settings/runtime-settings-section.tsx @@ -0,0 +1,45 @@ +import { createEffect, createSignal, type Component } from "solid-js" +import { Terminal } from "lucide-solid" +import OpenCodeBinarySelector from "../opencode-binary-selector" +import { useConfig } from "../../stores/preferences" +import { useI18n } from "../../lib/i18n" +import { OpenCodeUpdateCard } from "./opencode-update-card" +import { SideCarsSettingsSection } from "./sidecars-settings-section" + +export const RuntimeSettingsSection: Component = () => { + const { t } = useI18n() + const { serverSettings, updateLastUsedBinary } = useConfig() + const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") + + createEffect(() => { + const binary = serverSettings().opencodeBinary || "opencode" + setSelectedBinary((current) => (current === binary ? current : binary)) + }) + + const handleBinaryChange = (binary: string) => { + setSelectedBinary(binary) + updateLastUsedBinary(binary) + } + + return ( +
+
+
+
+ +
+

{t("settings.opencode.runtime.title")}

+

{t("settings.opencode.runtime.subtitle")}

+
+
+ {t("settings.scope.server")} +
+ + +
+ + + +
+ ) +} diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 86982c83d..8a7f17ad4 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -76,7 +76,6 @@ export interface VirtualFollowListProps { resolveAutoPinHoldElement?: HoldTargetElementResolver autoPinHoldTopThresholdPx?: number suspendAutoPinToBottom?: Accessor - shift?: Accessor renderBeforeItems?: Accessor renderOverlay?: Accessor scrollToTopAriaLabel?: Accessor @@ -756,7 +755,6 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { scrollRef={scrollElement()} data={props.items()} bufferSize={props.overscanPx ?? 400} - shift={props.shift?.() ?? false} onScroll={handleScroll} > {(item, index) => { diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index c05f2af9a..e11c97650 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -21,7 +21,6 @@ import { requestData } from "../opencode-api" import { emitSessionSidebarRequest } from "../session-sidebar-events" import { tGlobal } from "../i18n" import { registerBehaviorCommands } from "../settings/behavior-registry" -import { invalidateSessionMessageCache } from "../../stores/session-message-cache" const log = getLogger("actions") @@ -345,7 +344,6 @@ export function useCommands(options: UseCommandsOptions) { }), "session.revert", ) - invalidateSessionMessageCache(instance.id, sessionId) if (!restoredText) { const fallbackRecord = store.getMessage(messageID) diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 6ea19295b..49794fa81 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -100,7 +100,11 @@ export const settingsMessages = { "settings.nav.appearance": "Aussehen", "settings.nav.general": "Allgemein", "settings.nav.chat": "Chat", + "settings.nav.voice": "Sprache", + "settings.nav.connections": "Verbindungen", + "settings.nav.runtime": "Laufzeit", "settings.nav.advanced": "Erweitert", + "settings.nav.about": "Über", "settings.general.language.subtitle": "Wähle die Sprache für die gesamte Anwendung.", "settings.behavior.holdLongAssistantReplies.title": "Lange Assistentenantworten anhalten", "settings.behavior.holdLongAssistantReplies.subtitle": "Automatisches Folgen beenden, wenn eine laufende Antwort über das Ansichtsfenster hinausgeht.", @@ -125,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Startverhalten", "settings.appearance.startup.subtitle": "Lege fest, was dieses Gerät beim Start von CodeNomad wiederherstellt.", "settings.appearance.startup.restore.title": "Vorherigen Zustand wiederherstellen", - "settings.appearance.startup.restore.subtitle": "Tabs, aktive Sitzungen und nicht gesendete Nachrichten erneut öffnen, das Layout wiederherstellen und aktuelle Sitzungsnachrichten für schnelleres Öffnen auf diesem Gerät zwischenspeichern.", + "settings.appearance.startup.restore.subtitle": "Arbeitsbereichs- und Sidecar-Tabs, aktive Sitzungen und nicht gesendete Nachrichten erneut öffnen sowie Scrollpositionen, Panel-Layout, Fensterposition und Zoom wiederherstellen.", "settings.appearance.startup.clear.title": "Gespeicherter Startzustand", - "settings.appearance.startup.clear.subtitle": "Gespeicherte Tabs, Entwürfe, Layouts und zwischengespeicherte Sitzungsnachrichten von diesem Gerät entfernen. OpenCode-Unterhaltungen werden nicht gelöscht.", + "settings.appearance.startup.clear.subtitle": "Gespeicherte Tabs, Entwürfe, Scrollpositionen und das Panel-Layout entfernen. CodeNomad- und OpenCode-Daten werden nicht gelöscht.", "settings.appearance.startup.clear.action": "Gespeicherten Zustand löschen", "settings.appearance.startup.clearSuccess": "Gespeicherter Startzustand wurde gelöscht.", "settings.appearance.startup.clearError": "Der gespeicherte Startzustand konnte nicht gelöscht werden.", @@ -301,9 +305,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "Wählen Sie, ob Tool-Ausgaben standardmäßig aus- oder eingeklappt sind.", "settings.behavior.diagnosticsDefault.title": "Diagnose-Standard", "settings.behavior.diagnosticsDefault.subtitle": "Wählen Sie, ob Diagnoseausgaben standardmäßig aus- oder eingeklappt sind.", - "settings.behavior.toolInputsVisibility.title": "Tool-Eingaben", + "settings.behavior.toolInputsVisibility.title": "Sichtbarkeit von Tool-Eingaben", "settings.behavior.toolInputsVisibility.subtitle": "Standardsichtbarkeit für Argumente von Tool-Aufrufen festlegen.", - "settings.behavior.usageMetrics.title": "Token-Verbrauch", + "settings.behavior.usageMetrics.title": "Token-Verbrauchsmetriken", "settings.behavior.usageMetrics.subtitle": "Token- und Kostenstatistiken für Assistenten-Nachrichten anzeigen oder ausblenden.", "settings.behavior.autoCleanup.title": "Automatische Bereinigung leerer Sitzungen", "settings.behavior.autoCleanup.subtitle": "Leere Sitzungen beim Erstellen neuer Sitzungen automatisch bereinigen.", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index b88501d67..7e79f5e06 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -100,7 +100,11 @@ export const settingsMessages = { "settings.nav.appearance": "Appearance", "settings.nav.general": "General", "settings.nav.chat": "Chat", + "settings.nav.voice": "Voice", + "settings.nav.connections": "Connections", + "settings.nav.runtime": "Runtime", "settings.nav.advanced": "Advanced", + "settings.nav.about": "About", "settings.general.language.subtitle": "Choose the language used throughout the application.", "settings.behavior.holdLongAssistantReplies.title": "Hold long assistant replies", "settings.behavior.holdLongAssistantReplies.subtitle": "Stop following automatically when a streaming reply grows beyond the viewport.", @@ -125,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Startup", "settings.appearance.startup.subtitle": "Choose what this device restores when CodeNomad starts.", "settings.appearance.startup.restore.title": "Restore previous state", - "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, and unsent messages; restore layout; and cache recent session messages on this device for faster reopening.", + "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, and unsent messages, and restore scroll positions, panel layout, window position, and zoom.", "settings.appearance.startup.clear.title": "Saved startup state", - "settings.appearance.startup.clear.subtitle": "Remove saved tabs, drafts, layout, and cached session messages from this device. OpenCode conversation data is not deleted.", + "settings.appearance.startup.clear.subtitle": "Remove saved tabs, drafts, scroll positions, and panel layout. CodeNomad and OpenCode data are not deleted.", "settings.appearance.startup.clear.action": "Clear saved state", "settings.appearance.startup.clearSuccess": "Saved startup state cleared.", "settings.appearance.startup.clearError": "Could not clear the saved startup state.", @@ -301,9 +305,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "Choose whether tool outputs start expanded or collapsed.", "settings.behavior.diagnosticsDefault.title": "Diagnostics default", "settings.behavior.diagnosticsDefault.subtitle": "Choose whether diagnostics output starts expanded or collapsed.", - "settings.behavior.toolInputsVisibility.title": "Tool inputs", + "settings.behavior.toolInputsVisibility.title": "Tool inputs visibility", "settings.behavior.toolInputsVisibility.subtitle": "Set default visibility for tool call input arguments.", - "settings.behavior.usageMetrics.title": "Token usage", + "settings.behavior.usageMetrics.title": "Token usage metrics", "settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.", "settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions", "settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 4e148e574..268c30623 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -100,7 +100,11 @@ export const settingsMessages = { "settings.nav.appearance": "Apariencia", "settings.nav.general": "General", "settings.nav.chat": "Chat", + "settings.nav.voice": "Voz", + "settings.nav.connections": "Conexiones", + "settings.nav.runtime": "Entorno de ejecución", "settings.nav.advanced": "Avanzado", + "settings.nav.about": "Acerca de", "settings.general.language.subtitle": "Elige el idioma utilizado en toda la aplicación.", "settings.behavior.holdLongAssistantReplies.title": "Retener respuestas largas del asistente", "settings.behavior.holdLongAssistantReplies.subtitle": "Deja de seguir automáticamente cuando una respuesta en curso supera la ventana.", @@ -125,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Inicio", "settings.appearance.startup.subtitle": "Elige qué restaura este dispositivo al iniciar CodeNomad.", "settings.appearance.startup.restore.title": "Restaurar el estado anterior", - "settings.appearance.startup.restore.subtitle": "Volver a abrir pestañas, sesiones activas y mensajes no enviados, restaurar el diseño y guardar en caché los mensajes recientes en este dispositivo para abrirlos más rápido.", + "settings.appearance.startup.restore.subtitle": "Volver a abrir las pestañas de espacios de trabajo y sidecars, las sesiones activas y los mensajes no enviados, y restaurar las posiciones de desplazamiento, el diseño de paneles, la posición de la ventana y el zoom.", "settings.appearance.startup.clear.title": "Estado de inicio guardado", - "settings.appearance.startup.clear.subtitle": "Elimina de este dispositivo las pestañas, borradores, diseños y mensajes de sesión en caché. No se eliminan las conversaciones de OpenCode.", + "settings.appearance.startup.clear.subtitle": "Elimina pestañas, borradores, posiciones de desplazamiento y el diseño de paneles guardados. No se eliminan datos de CodeNomad ni de OpenCode.", "settings.appearance.startup.clear.action": "Borrar estado guardado", "settings.appearance.startup.clearSuccess": "Se borró el estado de inicio guardado.", "settings.appearance.startup.clearError": "No se pudo borrar el estado de inicio guardado.", @@ -300,9 +304,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "Elige si las salidas de herramientas comienzan expandidas o contraídas.", "settings.behavior.diagnosticsDefault.title": "Diagnósticos por defecto", "settings.behavior.diagnosticsDefault.subtitle": "Elige si la salida de diagnósticos comienza expandida o contraída.", - "settings.behavior.toolInputsVisibility.title": "Entradas de herramientas", + "settings.behavior.toolInputsVisibility.title": "Visibilidad de entradas de herramientas", "settings.behavior.toolInputsVisibility.subtitle": "Establece la visibilidad por defecto de los argumentos de entrada de las llamadas de herramientas.", - "settings.behavior.usageMetrics.title": "Uso de tokens", + "settings.behavior.usageMetrics.title": "Métricas de uso de tokens", "settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadísticas de tokens y costo en mensajes del asistente.", "settings.behavior.autoCleanup.title": "Limpieza automática de sesiones en blanco", "settings.behavior.autoCleanup.subtitle": "Limpia automáticamente las sesiones en blanco al crear nuevas.", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 975edcc98..65c924f73 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -100,7 +100,11 @@ export const settingsMessages = { "settings.nav.appearance": "Apparence", "settings.nav.general": "Général", "settings.nav.chat": "Chat", + "settings.nav.voice": "Voix", + "settings.nav.connections": "Connexions", + "settings.nav.runtime": "Exécution", "settings.nav.advanced": "Avancé", + "settings.nav.about": "À propos", "settings.general.language.subtitle": "Choisissez la langue utilisée dans toute l'application.", "settings.behavior.holdLongAssistantReplies.title": "Suspendre le suivi des longues réponses", "settings.behavior.holdLongAssistantReplies.subtitle": "Arrêter le suivi automatique lorsqu'une réponse en cours dépasse la fenêtre.", @@ -125,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Démarrage", "settings.appearance.startup.subtitle": "Choisissez ce que cet appareil restaure au démarrage de CodeNomad.", "settings.appearance.startup.restore.title": "Restaurer l’état précédent", - "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets, les sessions actives et les messages non envoyés, restaurer la disposition et mettre en cache les messages récents sur cet appareil pour accélérer leur réouverture.", + "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets d’espaces de travail et de sidecars, les sessions actives et les messages non envoyés, et restaurer les positions de défilement, la disposition des panneaux, la position de la fenêtre et le zoom.", "settings.appearance.startup.clear.title": "État de démarrage enregistré", - "settings.appearance.startup.clear.subtitle": "Supprime de cet appareil les onglets, brouillons, dispositions et messages de session mis en cache. Les conversations OpenCode ne sont pas supprimées.", + "settings.appearance.startup.clear.subtitle": "Supprime les onglets, brouillons, positions de défilement et la disposition des panneaux enregistrés. Les données CodeNomad et OpenCode ne sont pas supprimées.", "settings.appearance.startup.clear.action": "Effacer l’état enregistré", "settings.appearance.startup.clearSuccess": "L’état de démarrage enregistré a été effacé.", "settings.appearance.startup.clearError": "Impossible d’effacer l’état de démarrage enregistré.", @@ -300,9 +304,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "Choisir si les sorties d'outils commencent developpees ou reduites.", "settings.behavior.diagnosticsDefault.title": "Etat initial des diagnostics", "settings.behavior.diagnosticsDefault.subtitle": "Choisir si la sortie des diagnostics commence developpee ou reduite.", - "settings.behavior.toolInputsVisibility.title": "Entrees d'outils", + "settings.behavior.toolInputsVisibility.title": "Visibilite des entrees d'outils", "settings.behavior.toolInputsVisibility.subtitle": "Definir la visibilite par defaut des arguments d'entree des appels d'outils.", - "settings.behavior.usageMetrics.title": "Utilisation des tokens", + "settings.behavior.usageMetrics.title": "Metriques d'utilisation des tokens", "settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.", "settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides", "settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index a8b9b8487..a7f9c7fb6 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -100,7 +100,11 @@ export const settingsMessages = { "settings.nav.appearance": "מראה", "settings.nav.general": "כללי", "settings.nav.chat": "צ׳אט", + "settings.nav.voice": "קול", + "settings.nav.connections": "חיבורים", + "settings.nav.runtime": "זמן ריצה", "settings.nav.advanced": "מתקדם", + "settings.nav.about": "אודות", "settings.general.language.subtitle": "בחר את השפה שתשמש בכל היישום.", "settings.behavior.holdLongAssistantReplies.title": "השהיית מעקב אחר תשובות ארוכות", "settings.behavior.holdLongAssistantReplies.subtitle": "הפסק מעקב אוטומטי כאשר תשובה זורמת חורגת מחלון התצוגה.", @@ -125,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "הפעלה", "settings.appearance.startup.subtitle": "בחר מה ישוחזר במכשיר זה בעת הפעלת CodeNomad.", "settings.appearance.startup.restore.title": "שחזור המצב הקודם", - "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות, הפעלות פעילות והודעות שלא נשלחו, שחזור הפריסה ושמירת הודעות אחרונות במטמון במכשיר זה לפתיחה מהירה יותר.", + "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות סביבות עבודה ו-sidecar, הפעלות פעילות והודעות שלא נשלחו, ושחזור מיקומי גלילה, פריסת חלוניות, מיקום החלון ורמת התקריב.", "settings.appearance.startup.clear.title": "מצב הפעלה שמור", - "settings.appearance.startup.clear.subtitle": "הסרת כרטיסיות, טיוטות, פריסות והודעות הפעלה שמורות במטמון ממכשיר זה. שיחות OpenCode לא יימחקו.", + "settings.appearance.startup.clear.subtitle": "הסרת כרטיסיות, טיוטות, מיקומי גלילה ופריסת חלוניות שנשמרו. נתוני CodeNomad ו-OpenCode לא יימחקו.", "settings.appearance.startup.clear.action": "נקה מצב שמור", "settings.appearance.startup.clearSuccess": "מצב ההפעלה השמור נוקה.", "settings.appearance.startup.clearError": "לא ניתן לנקות את מצב ההפעלה השמור.", @@ -300,9 +304,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "בחר האם פלטי כלים מתחילים פרוסים או מכווצים.", "settings.behavior.diagnosticsDefault.title": "ברירת מחדל לאבחון", "settings.behavior.diagnosticsDefault.subtitle": "בחר האם פלט אבחון מתחיל פרוס או מכווץ.", - "settings.behavior.toolInputsVisibility.title": "קלטי כלים", + "settings.behavior.toolInputsVisibility.title": "נראות קלטי כלים", "settings.behavior.toolInputsVisibility.subtitle": "הגדר נראות ברירת מחדל לארגומנטים של קריאות כלי.", - "settings.behavior.usageMetrics.title": "שימוש בטוקנים", + "settings.behavior.usageMetrics.title": "מדדי שימוש בטוקנים", "settings.behavior.usageMetrics.subtitle": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן.", "settings.behavior.autoCleanup.title": "ניקוי אוטומטי של סשנים ריקים", "settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index e152181ec..6b266a108 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -100,7 +100,11 @@ export const settingsMessages = { "settings.nav.appearance": "外観", "settings.nav.general": "一般", "settings.nav.chat": "チャット", + "settings.nav.voice": "音声", + "settings.nav.connections": "接続", + "settings.nav.runtime": "ランタイム", "settings.nav.advanced": "詳細設定", + "settings.nav.about": "情報", "settings.general.language.subtitle": "アプリケーション全体で使用する言語を選択します。", "settings.behavior.holdLongAssistantReplies.title": "長いアシスタント応答を保持", "settings.behavior.holdLongAssistantReplies.subtitle": "ストリーミング応答が画面を超えたときに自動追従を停止します。", @@ -125,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "起動", "settings.appearance.startup.subtitle": "CodeNomad の起動時にこのデバイスで復元する内容を選択します。", "settings.appearance.startup.restore.title": "前回の状態を復元", - "settings.appearance.startup.restore.subtitle": "タブ、アクティブなセッション、未送信メッセージを再度開き、配置を復元し、最近のセッションメッセージをこのデバイスにキャッシュして再表示を高速化します。", + "settings.appearance.startup.restore.subtitle": "ワークスペースとサイドカーのタブ、アクティブなセッション、未送信メッセージを再度開き、スクロール位置、パネル配置、ウィンドウ位置、ズームを復元します。", "settings.appearance.startup.clear.title": "保存された起動状態", - "settings.appearance.startup.clear.subtitle": "保存されたタブ、下書き、配置、キャッシュ済みセッションメッセージをこのデバイスから削除します。OpenCode の会話は削除されません。", + "settings.appearance.startup.clear.subtitle": "保存されたタブ、下書き、スクロール位置、パネル配置を削除します。CodeNomad と OpenCode のデータは削除されません。", "settings.appearance.startup.clear.action": "保存状態を消去", "settings.appearance.startup.clearSuccess": "保存された起動状態を消去しました。", "settings.appearance.startup.clearError": "保存された起動状態を消去できませんでした。", @@ -300,9 +304,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "ツール出力を最初に展開/折りたたみのどちらで表示するかを選びます。", "settings.behavior.diagnosticsDefault.title": "診断の既定", "settings.behavior.diagnosticsDefault.subtitle": "診断出力を最初に展開/折りたたみのどちらで表示するかを選びます。", - "settings.behavior.toolInputsVisibility.title": "ツール入力", + "settings.behavior.toolInputsVisibility.title": "ツール入力の表示", "settings.behavior.toolInputsVisibility.subtitle": "ツール呼び出しの入力引数の既定の表示状態を設定します。", - "settings.behavior.usageMetrics.title": "トークン使用量", + "settings.behavior.usageMetrics.title": "トークン使用量メトリクス", "settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。", "settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ", "settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 51a7c21ff..cbfbb0953 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -100,7 +100,11 @@ export const settingsMessages = { "settings.nav.appearance": "रूप (Appearance)", "settings.nav.general": "सामान्य", "settings.nav.chat": "च्याट", + "settings.nav.voice": "आवाज", + "settings.nav.connections": "जडानहरू", + "settings.nav.runtime": "रनटाइम", "settings.nav.advanced": "उन्नत", + "settings.nav.about": "बारेमा", "settings.general.language.subtitle": "सम्पूर्ण अनुप्रयोगमा प्रयोग हुने भाषा छान्नुहोस्।", "settings.behavior.holdLongAssistantReplies.title": "लामो सहायक जवाफहरू रोक्नुहोस्", "settings.behavior.holdLongAssistantReplies.subtitle": "स्ट्रिमिङ जवाफ दृश्यभन्दा बाहिर जाँदा स्वचालित पछ्याइ रोक्नुहोस्।", @@ -125,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "सुरुआत", "settings.appearance.startup.subtitle": "CodeNomad सुरु हुँदा यस यन्त्रमा के पुनर्स्थापना गर्ने छान्नुहोस्।", "settings.appearance.startup.restore.title": "अघिल्लो अवस्था पुनर्स्थापना गर्नुहोस्", - "settings.appearance.startup.restore.subtitle": "ट्याब, सक्रिय सत्र र नपठाइएका सन्देशहरू फेरि खोल्नुहोस्, लेआउट पुनर्स्थापना गर्नुहोस् र छिटो खोल्न हालका सत्र सन्देशहरू यस यन्त्रमा क्यास गर्नुहोस्।", + "settings.appearance.startup.restore.subtitle": "कार्यस्थान र साइडकार ट्याब, सक्रिय सत्र र नपठाइएका सन्देशहरू फेरि खोल्नुहोस्, र स्क्रोल स्थिति, प्यानल लेआउट, विन्डोको स्थान र जुम पुनर्स्थापना गर्नुहोस्।", "settings.appearance.startup.clear.title": "सुरक्षित सुरुआत अवस्था", - "settings.appearance.startup.clear.subtitle": "यस यन्त्रबाट सुरक्षित ट्याब, मस्यौदा, लेआउट र क्यास गरिएका सत्र सन्देशहरू हटाउनुहोस्। OpenCode कुराकानीहरू मेटिँदैनन्।", + "settings.appearance.startup.clear.subtitle": "सुरक्षित ट्याब, मस्यौदा, स्क्रोल स्थिति र प्यानल लेआउट हटाउनुहोस्। CodeNomad र OpenCode का डेटा मेटिँदैनन्।", "settings.appearance.startup.clear.action": "सुरक्षित अवस्था खाली गर्नुहोस्", "settings.appearance.startup.clearSuccess": "सुरक्षित सुरुआत अवस्था खाली गरियो।", "settings.appearance.startup.clearError": "सुरक्षित सुरुआत अवस्था खाली गर्न सकिएन।", @@ -301,9 +305,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "उपकरण आउटपुटहरू विस्तार वा संकुचित रूपमा सुरु हुने छनौट गर्नुहोस्।", "settings.behavior.diagnosticsDefault.title": "निदान (Diagnostics) पूर्वनिर्धारित", "settings.behavior.diagnosticsDefault.subtitle": "निदान आउटपुट विस्तार वा संकुचित रूपमा सुरु हुने छनौट गर्नुहोस्।", - "settings.behavior.toolInputsVisibility.title": "उपकरण इनपुट", + "settings.behavior.toolInputsVisibility.title": "उपकरण इनपुट दृश्यता", "settings.behavior.toolInputsVisibility.subtitle": "उपकरण कल इनपुट तर्कहरूको लागि पूर्वनिर्धारित दृश्यता सेट गर्नुहोस्।", - "settings.behavior.usageMetrics.title": "टोकन प्रयोग", + "settings.behavior.usageMetrics.title": "टोकन प्रयोग मेट्रिक्स", "settings.behavior.usageMetrics.subtitle": "सन्देशहरूको लागि टोकन र लागत तथ्याङ्क देखाउनुहोस् वा लुकाउनुहोस्।", "settings.behavior.autoCleanup.title": "खाली सत्रहरू स्वतः सफा गर्नुहोस्", "settings.behavior.autoCleanup.subtitle": "नयाँ सत्रहरू सिर्जना गर्दा खाली सत्रहरू स्वतः सफा गर्नुहोस्।", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 00f42b887..d364ab5a6 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -100,7 +100,11 @@ export const settingsMessages = { "settings.nav.appearance": "Внешний вид", "settings.nav.general": "Общие", "settings.nav.chat": "Чат", + "settings.nav.voice": "Голос", + "settings.nav.connections": "Подключения", + "settings.nav.runtime": "Среда выполнения", "settings.nav.advanced": "Расширенные", + "settings.nav.about": "О программе", "settings.general.language.subtitle": "Выберите язык, используемый во всём приложении.", "settings.behavior.holdLongAssistantReplies.title": "Удерживать длинные ответы ассистента", "settings.behavior.holdLongAssistantReplies.subtitle": "Прекращать автоматическое следование, когда потоковый ответ выходит за пределы окна.", @@ -125,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Запуск", "settings.appearance.startup.subtitle": "Выберите, что восстанавливать на этом устройстве при запуске CodeNomad.", "settings.appearance.startup.restore.title": "Восстанавливать предыдущее состояние", - "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки, активные сессии и неотправленные сообщения, восстанавливать макет и кэшировать недавние сообщения сессий на этом устройстве для быстрого открытия.", + "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки рабочих пространств и sidecar, активные сессии и неотправленные сообщения, а также восстанавливать позиции прокрутки, расположение панелей, положение окна и масштаб.", "settings.appearance.startup.clear.title": "Сохранённое состояние запуска", - "settings.appearance.startup.clear.subtitle": "Удалить с этого устройства вкладки, черновики, макеты и кэшированные сообщения сессий. Диалоги OpenCode не удаляются.", + "settings.appearance.startup.clear.subtitle": "Удалить сохранённые вкладки, черновики, позиции прокрутки и расположение панелей. Данные CodeNomad и OpenCode не удаляются.", "settings.appearance.startup.clear.action": "Очистить сохранённое состояние", "settings.appearance.startup.clearSuccess": "Сохранённое состояние запуска очищено.", "settings.appearance.startup.clearError": "Не удалось очистить сохранённое состояние запуска.", @@ -300,9 +304,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "Выберите, начинать ли выводы инструментов развернутыми или свернутыми.", "settings.behavior.diagnosticsDefault.title": "Диагностика по умолчанию", "settings.behavior.diagnosticsDefault.subtitle": "Выберите, начинать ли вывод диагностики развернутым или свернутым.", - "settings.behavior.toolInputsVisibility.title": "Входные данные инструмента", + "settings.behavior.toolInputsVisibility.title": "Видимость входных данных инструмента", "settings.behavior.toolInputsVisibility.subtitle": "Задайте видимость по умолчанию для входных аргументов вызовов инструментов.", - "settings.behavior.usageMetrics.title": "Использование токенов", + "settings.behavior.usageMetrics.title": "Метрики использования токенов", "settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.", "settings.behavior.autoCleanup.title": "Автоочистка пустых сессий", "settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.", 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 af887059a..1eed86ce0 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -100,7 +100,11 @@ export const settingsMessages = { "settings.nav.appearance": "外观", "settings.nav.general": "常规", "settings.nav.chat": "聊天", + "settings.nav.voice": "语音", + "settings.nav.connections": "连接", + "settings.nav.runtime": "运行时", "settings.nav.advanced": "高级", + "settings.nav.about": "关于", "settings.general.language.subtitle": "选择整个应用程序使用的语言。", "settings.behavior.holdLongAssistantReplies.title": "暂停跟随较长的助手回复", "settings.behavior.holdLongAssistantReplies.subtitle": "当流式回复超出视口时停止自动跟随。", @@ -125,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "启动", "settings.appearance.startup.subtitle": "选择 CodeNomad 启动时在此设备上恢复的内容。", "settings.appearance.startup.restore.title": "恢复上次状态", - "settings.appearance.startup.restore.subtitle": "重新打开标签页、活动会话和未发送的消息,恢复布局,并在此设备上缓存最近的会话消息以便更快重新打开。", + "settings.appearance.startup.restore.subtitle": "重新打开工作区和边栏标签页、活动会话和未发送的消息,并恢复滚动位置、面板布局、窗口位置和缩放级别。", "settings.appearance.startup.clear.title": "已保存的启动状态", - "settings.appearance.startup.clear.subtitle": "从此设备移除已保存的标签页、草稿、布局和缓存的会话消息。不会删除 OpenCode 对话。", + "settings.appearance.startup.clear.subtitle": "移除已保存的标签页、草稿、滚动位置和面板布局。不会删除 CodeNomad 或 OpenCode 数据。", "settings.appearance.startup.clear.action": "清除已保存状态", "settings.appearance.startup.clearSuccess": "已清除保存的启动状态。", "settings.appearance.startup.clearError": "无法清除保存的启动状态。", @@ -300,9 +304,9 @@ export const settingsMessages = { "settings.behavior.toolOutputsDefault.subtitle": "选择工具输出默认是展开还是折叠。", "settings.behavior.diagnosticsDefault.title": "诊断默认状态", "settings.behavior.diagnosticsDefault.subtitle": "选择诊断输出默认是展开还是折叠。", - "settings.behavior.toolInputsVisibility.title": "工具输入", + "settings.behavior.toolInputsVisibility.title": "工具输入可见性", "settings.behavior.toolInputsVisibility.subtitle": "设置工具调用输入参数的默认可见性。", - "settings.behavior.usageMetrics.title": "令牌用量", + "settings.behavior.usageMetrics.title": "令牌用量指标", "settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。", "settings.behavior.autoCleanup.title": "自动清理空会话", "settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。", diff --git a/packages/ui/src/lib/session-message-cache.test.ts b/packages/ui/src/lib/session-message-cache.test.ts deleted file mode 100644 index a62c79c6f..000000000 --- a/packages/ui/src/lib/session-message-cache.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { createSessionMessageCacheKey, prepareSessionMessageCache, selectSessionMessageCacheEvictions } from "./session-message-cache.ts" - -test("session message cache keys normalize path separators", () => { - assert.equal(createSessionMessageCacheKey("C:\\work\\repo\\", "session-1"), "C:/work/repo\u0000session-1") -}) - -test("session message cache evicts the oldest entries to satisfy byte and count limits", () => { - const entries = [ - { key: "old", byteSize: 5, savedAt: 1, snapshotId: "1", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, - { key: "middle", byteSize: 4, savedAt: 2, snapshotId: "2", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, - { key: "new", byteSize: 3, savedAt: 3, snapshotId: "3", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, - ] - assert.deepEqual(selectSessionMessageCacheEvictions(entries, 7, 3), ["old"]) - assert.deepEqual(selectSessionMessageCacheEvictions(entries, 20, 2), ["old"]) -}) - -test("session message cache retains the newest contiguous messages within its budget", () => { - const messages = ["one", "two", "three"].map((id) => ({ info: { id }, parts: [{ type: "text", text: id.repeat(10) }] })) - const byteLimit = messages.slice(1).reduce((total, message) => total + JSON.stringify(message).length * 2, 0) - const prepared = prepareSessionMessageCache("session", messages, "snapshot", byteLimit, 1) - assert.deepEqual(prepared?.manifest.messageIds, ["two", "three"]) - assert.equal(prepared?.manifest.startIndex, 1) - assert.equal(prepared?.manifest.complete, false) - assert.equal(prepared?.records.length, 2) -}) - -test("session message cache stores an authoritative empty manifest", () => { - const prepared = prepareSessionMessageCache("session", [], "snapshot", 100, 1) - assert.deepEqual(prepared?.manifest.messageIds, []) - assert.equal(prepared?.manifest.complete, true) - assert.equal(prepared?.manifest.totalCount, 0) -}) diff --git a/packages/ui/src/lib/session-message-cache.ts b/packages/ui/src/lib/session-message-cache.ts deleted file mode 100644 index 414f87cbe..000000000 --- a/packages/ui/src/lib/session-message-cache.ts +++ /dev/null @@ -1,441 +0,0 @@ -const DATABASE_NAME = "codenomad-session-messages" -const DATABASE_VERSION = 2 -const MANIFEST_STORE = "session-manifests" -const MESSAGE_STORE = "session-messages" -const SESSION_INDEX = "by-session" - -export const DEFAULT_SESSION_MESSAGE_CACHE_PAGE_SIZE = 50 -export const MAX_SESSION_MESSAGE_CACHE_BYTES = 16 * 1024 * 1024 -export const MAX_TOTAL_MESSAGE_CACHE_BYTES = 64 * 1024 * 1024 -export const MAX_SESSION_MESSAGE_CACHE_ENTRIES = 64 - -export interface SessionMessageCacheManifest { - key: string - snapshotId: string - messageIds: string[] - startIndex: number - totalCount: number - complete: boolean - byteSize: number - savedAt: number -} - -interface SessionMessageCacheRecord { - sessionKey: string - snapshotId: string - messageId: string - ordinal: number - payload: string -} - -export interface SessionMessageCacheCursor { - key: string - snapshotId: string - messageIds: readonly string[] - beforeIndex: number - startIndex: number - totalCount: number - complete: boolean -} - -export interface SessionMessageCachePage { - messages: unknown[] - startIndex: number - totalCount: number - done: boolean - complete: boolean -} - -interface PreparedSessionMessageCache { - manifest: SessionMessageCacheManifest - records: SessionMessageCacheRecord[] -} - -let databasePromise: Promise | null = null -let clearGeneration = 0 -let cacheEnabled = false -const resetListeners = new Set<() => void>() -let mutationQueue: Promise = Promise.resolve() - -export function isSessionMessageCacheEnabled(): boolean { - return cacheEnabled -} - -export function setSessionMessageCacheEnabled(enabled: boolean): void { - cacheEnabled = enabled - if (!enabled) resetListeners.forEach((listener) => listener()) -} - -export function onSessionMessageCacheReset(listener: () => void): () => void { - resetListeners.add(listener) - return () => resetListeners.delete(listener) -} - -function requestResult(request: IDBRequest): Promise { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")) - }) -} - -function transactionDone(transaction: IDBTransaction): Promise { - return new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve() - transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted")) - transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed")) - }) -} - -function openDatabase(): Promise { - if (typeof indexedDB === "undefined") return Promise.resolve(null) - if (databasePromise) return databasePromise - - const opening = new Promise((resolve, reject) => { - const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION) - request.onupgradeneeded = () => { - const database = request.result - for (const legacy of ["payloads", "metadata"]) { - if (database.objectStoreNames.contains(legacy)) database.deleteObjectStore(legacy) - } - if (!database.objectStoreNames.contains(MANIFEST_STORE)) { - database.createObjectStore(MANIFEST_STORE, { keyPath: "key" }) - } - if (!database.objectStoreNames.contains(MESSAGE_STORE)) { - const store = database.createObjectStore(MESSAGE_STORE, { - keyPath: ["sessionKey", "snapshotId", "messageId"], - }) - store.createIndex(SESSION_INDEX, "sessionKey") - } - } - request.onsuccess = () => { - const database = request.result - database.onversionchange = () => { - database.close() - databasePromise = null - } - resolve(database) - } - request.onerror = () => reject(request.error ?? new Error("Failed to open session message cache")) - }) - databasePromise = opening.catch((error) => { - databasePromise = null - throw error - }) - return databasePromise -} - -function createSnapshotId(): string { - return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` -} - -export function createSessionMessageCacheKey(workspace: string, sessionId: string): string { - return `${workspace.replace(/\\/g, "/").replace(/\/+$/, "")}\u0000${sessionId}` -} - -function messageIdOf(value: unknown): string | null { - if (!value || typeof value !== "object") return null - const candidate = value as { info?: { id?: unknown }; id?: unknown } - const id = candidate.info?.id ?? candidate.id - return typeof id === "string" && id.length > 0 ? id : null -} - -function messageSessionIdOf(value: unknown): string | null { - if (!value || typeof value !== "object") return null - const candidate = value as { info?: { sessionID?: unknown }; sessionId?: unknown } - const id = candidate.info?.sessionID ?? candidate.sessionId - return typeof id === "string" && id.length > 0 ? id : null -} - -function sessionIdFromKey(key: string): string { - return key.slice(key.lastIndexOf("\u0000") + 1) -} - -function isValidManifest(value: unknown, expectedKey: string): value is SessionMessageCacheManifest { - if (!value || typeof value !== "object") return false - const manifest = value as SessionMessageCacheManifest - return manifest.key === expectedKey && - typeof manifest.snapshotId === "string" && manifest.snapshotId.length > 0 && - Array.isArray(manifest.messageIds) && manifest.messageIds.every((id) => typeof id === "string" && id.length > 0) && - new Set(manifest.messageIds).size === manifest.messageIds.length && - Number.isSafeInteger(manifest.startIndex) && manifest.startIndex >= 0 && - Number.isSafeInteger(manifest.totalCount) && manifest.totalCount === manifest.startIndex + manifest.messageIds.length && - manifest.complete === (manifest.startIndex === 0) && - Number.isFinite(manifest.byteSize) && manifest.byteSize >= 0 && - Number.isFinite(manifest.savedAt) -} - -export function prepareSessionMessageCache( - key: string, - messages: readonly unknown[], - snapshotId = createSnapshotId(), - byteLimit = MAX_SESSION_MESSAGE_CACHE_BYTES, - savedAt = Date.now(), -): PreparedSessionMessageCache | null { - const serialized = messages.map((message, ordinal) => { - const messageId = messageIdOf(message) - if (!messageId) return null - const payload = JSON.stringify(message) - return { messageId, ordinal, payload, byteSize: payload.length * 2 } - }) - if (serialized.some((entry) => entry === null)) return null - - const seen = new Set() - for (const entry of serialized) { - if (!entry || seen.has(entry.messageId)) return null - seen.add(entry.messageId) - } - - let byteSize = 0 - let startIndex = serialized.length - for (let index = serialized.length - 1; index >= 0; index -= 1) { - const entry = serialized[index]! - if (entry.byteSize > byteLimit || byteSize + entry.byteSize > byteLimit) break - byteSize += entry.byteSize - startIndex = index - } - if (messages.length > 0 && startIndex === serialized.length) return null - - const selected = serialized.slice(startIndex) as Array> - const records = selected.map((entry) => ({ - sessionKey: key, - snapshotId, - messageId: entry.messageId, - ordinal: entry.ordinal, - payload: entry.payload, - })) - return { - manifest: { - key, - snapshotId, - messageIds: selected.map((entry) => entry.messageId), - startIndex, - totalCount: messages.length, - complete: startIndex === 0, - byteSize, - savedAt, - }, - records, - } -} - -export function selectSessionMessageCacheEvictions( - entries: readonly SessionMessageCacheManifest[], - byteLimit = MAX_TOTAL_MESSAGE_CACHE_BYTES, - entryLimit = MAX_SESSION_MESSAGE_CACHE_ENTRIES, -): string[] { - const oldestFirst = [...entries].sort((left, right) => left.savedAt - right.savedAt || left.key.localeCompare(right.key)) - let bytes = oldestFirst.reduce((total, entry) => total + entry.byteSize, 0) - let count = oldestFirst.length - const evictions: string[] = [] - for (const entry of oldestFirst) { - if (bytes <= byteLimit && count <= entryLimit) break - evictions.push(entry.key) - bytes -= entry.byteSize - count -= 1 - } - return evictions -} - -function enqueueMutation(operation: () => Promise): Promise { - const result = mutationQueue.then(operation) - mutationQueue = result.then(() => undefined, () => undefined) - return result -} - -async function readSessionRecordKeys(database: IDBDatabase, sessionKeys: readonly string[]): Promise> { - if (sessionKeys.length === 0) return new Map() - const transaction = database.transaction(MESSAGE_STORE, "readonly") - const completion = transactionDone(transaction) - const index = transaction.objectStore(MESSAGE_STORE).index(SESSION_INDEX) - const requests = sessionKeys.map(async (sessionKey) => [ - sessionKey, - await requestResult(index.getAllKeys(IDBKeyRange.only(sessionKey))), - ] as const) - const entries = await Promise.all(requests) - await completion - return new Map(entries) -} - -export async function openSessionMessageCache( - workspace: string, - sessionId: string, -): Promise { - const generation = clearGeneration - const database = await openDatabase() - if (!database || generation !== clearGeneration) return null - const transaction = database.transaction(MANIFEST_STORE, "readonly") - const completion = transactionDone(transaction) - const key = createSessionMessageCacheKey(workspace, sessionId) - const manifest = await requestResult(transaction.objectStore(MANIFEST_STORE).get(key)) as unknown - await completion - if (generation !== clearGeneration) return null - if (!manifest) return null - if (!isValidManifest(manifest, key)) { - await deleteCorruptSessionMessageCacheKey(key, manifest) - return null - } - return { - key: manifest.key, - snapshotId: manifest.snapshotId, - messageIds: manifest.messageIds, - beforeIndex: manifest.messageIds.length, - startIndex: manifest.startIndex, - totalCount: manifest.totalCount, - complete: manifest.complete, - } -} - -export async function readSessionMessageCachePage( - cursor: SessionMessageCacheCursor, - limit = DEFAULT_SESSION_MESSAGE_CACHE_PAGE_SIZE, -): Promise<{ page: SessionMessageCachePage; cursor: SessionMessageCacheCursor } | null> { - if (cursor.beforeIndex <= 0) return null - const generation = clearGeneration - const database = await openDatabase() - if (!database || generation !== clearGeneration) return null - const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readonly") - const completion = transactionDone(transaction) - const manifestRequest = transaction.objectStore(MANIFEST_STORE).get(cursor.key) - const start = Math.max(0, cursor.beforeIndex - Math.max(1, limit)) - const ids = cursor.messageIds.slice(start, cursor.beforeIndex) - const messageStore = transaction.objectStore(MESSAGE_STORE) - const requests = ids.map((messageId) => requestResult(messageStore.get([cursor.key, cursor.snapshotId, messageId]))) - const manifest = await requestResult(manifestRequest) as unknown - const records = await Promise.all(requests) as Array - await completion - if (generation !== clearGeneration) return null - if (!isValidManifest(manifest, cursor.key) || manifest.snapshotId !== cursor.snapshotId) return null - if (records.some((record) => !record)) { - await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) - return null - } - - try { - const messages = records.map((record) => JSON.parse(record!.payload)) - const sessionId = sessionIdFromKey(cursor.key) - if (messages.some((message, index) => messageIdOf(message) !== ids[index] || messageSessionIdOf(message) !== sessionId)) { - await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) - return null - } - const nextCursor = { ...cursor, beforeIndex: start } - return { - page: { - messages, - startIndex: cursor.startIndex + start, - totalCount: cursor.totalCount, - done: start === 0, - complete: cursor.complete, - }, - cursor: nextCursor, - } - } catch { - await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) - return null - } -} - -export async function writeSessionMessageCache(workspace: string, sessionId: string, messages: unknown[]): Promise { - const generation = clearGeneration - const key = createSessionMessageCacheKey(workspace, sessionId) - const prepared = prepareSessionMessageCache(key, messages) - if (!prepared) { - await deleteSessionMessageCache(workspace, sessionId) - return false - } - return enqueueMutation(async () => { - const database = await openDatabase() - if (!database || generation !== clearGeneration) return false - const manifestRead = database.transaction(MANIFEST_STORE, "readonly") - const manifestReadDone = transactionDone(manifestRead) - const existing = await requestResult(manifestRead.objectStore(MANIFEST_STORE).getAll()) as unknown[] - await manifestReadDone - const validExisting = existing.filter((manifest): manifest is SessionMessageCacheManifest => { - const manifestKey = (manifest as { key?: unknown })?.key - return typeof manifestKey === "string" && isValidManifest(manifest, manifestKey) - }) - const invalidKeys = existing - .filter((manifest) => !validExisting.includes(manifest as SessionMessageCacheManifest)) - .map((manifest) => (manifest as { key?: unknown })?.key) - .filter((manifestKey): manifestKey is string => typeof manifestKey === "string" && manifestKey !== key) - prepared.manifest.savedAt = Math.max(prepared.manifest.savedAt, ...validExisting.map((manifest) => manifest.savedAt + 1)) - const projected = [...validExisting.filter((manifest) => manifest.key !== key), prepared.manifest] - const evictions = selectSessionMessageCacheEvictions(projected) - const recordKeys = await readSessionRecordKeys(database, [key, ...evictions, ...invalidKeys]) - if (generation !== clearGeneration) return false - - const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") - const completion = transactionDone(transaction) - const manifestStore = transaction.objectStore(MANIFEST_STORE) - const messageStore = transaction.objectStore(MESSAGE_STORE) - for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) - for (const record of prepared.records) messageStore.put(record) - manifestStore.put(prepared.manifest) - for (const evictedKey of evictions) { - for (const recordKey of recordKeys.get(evictedKey) ?? []) messageStore.delete(recordKey) - manifestStore.delete(evictedKey) - } - for (const invalidKey of invalidKeys) { - for (const recordKey of recordKeys.get(invalidKey) ?? []) messageStore.delete(recordKey) - manifestStore.delete(invalidKey) - } - await completion - return true - }) -} - -export async function deleteSessionMessageCache(workspace: string, sessionId: string): Promise { - const key = createSessionMessageCacheKey(workspace, sessionId) - await deleteSessionMessageCacheKey(key) -} - -async function deleteSessionMessageCacheKey(key: string): Promise { - await enqueueMutation(async () => { - const database = await openDatabase() - if (!database) return - const recordKeys = await readSessionRecordKeys(database, [key]) - const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") - const completion = transactionDone(transaction) - const messageStore = transaction.objectStore(MESSAGE_STORE) - for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) - transaction.objectStore(MANIFEST_STORE).delete(key) - await completion - }) -} - -async function deleteCorruptSessionMessageCacheKey(key: string, observed: unknown): Promise { - const observedSnapshotId = (observed as { snapshotId?: unknown })?.snapshotId - await enqueueMutation(async () => { - const database = await openDatabase() - if (!database) return - const manifestRead = database.transaction(MANIFEST_STORE, "readonly") - const manifestReadDone = transactionDone(manifestRead) - const current = await requestResult(manifestRead.objectStore(MANIFEST_STORE).get(key)) as unknown - await manifestReadDone - if (!current) return - if (typeof observedSnapshotId === "string") { - if ((current as { snapshotId?: unknown }).snapshotId !== observedSnapshotId) return - } else if (isValidManifest(current, key)) { - return - } - const recordKeys = await readSessionRecordKeys(database, [key]) - const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") - const completion = transactionDone(transaction) - const messageStore = transaction.objectStore(MESSAGE_STORE) - for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) - transaction.objectStore(MANIFEST_STORE).delete(key) - await completion - }) -} - -export async function clearSessionMessageCache(): Promise { - clearGeneration += 1 - await enqueueMutation(async () => { - const database = await openDatabase() - if (!database) return - const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") - const completion = transactionDone(transaction) - transaction.objectStore(MANIFEST_STORE).clear() - transaction.objectStore(MESSAGE_STORE).clear() - await completion - }) -} diff --git a/packages/ui/src/stores/client-state.ts b/packages/ui/src/stores/client-state.ts index fb29dca9a..b13376e95 100644 --- a/packages/ui/src/stores/client-state.ts +++ b/packages/ui/src/stores/client-state.ts @@ -2,7 +2,6 @@ import { createSignal } from "solid-js" import { clearNativeClientState, loadNativeClientState, saveNativeClientState, setNativeRestoreEnabled } from "../lib/native/client-state" import { decodeClientSnapshot, isFutureClientSnapshot, normalizeRestorableSession } from "./client-state-codec" import type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } from "./client-state-codec" -import { clearSessionMessageCache, setSessionMessageCacheEnabled } from "../lib/session-message-cache" export type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } const SAVE_DEBOUNCE_MS = 250 const FLUSH_MAX_ATTEMPTS = 3 @@ -234,8 +233,6 @@ export async function clearRestoredClientState(): Promise { await runDestructiveTransition(async () => { if (!clientStateIsPrimary()) throw new Error("Client state is not owned by this window") await executeDestructiveTransaction(clearNativeClientState, "Native client state clear was rejected", true) - setSessionMessageCacheEnabled(false) - await clearSessionMessageCache() }) } @@ -247,19 +244,15 @@ export async function setRestorePreviousStateEnabled(enabled: boolean): Promise< if (!await setNativeRestoreEnabled(true)) throw new Error("Native restore preference update was rejected") writeBlock = false setRestorePreviousStateEnabledSignal(true) - setSessionMessageCacheEnabled(true) return } setRestorePreviousStateEnabledSignal(false) - setSessionMessageCacheEnabled(false) try { await executeDestructiveTransaction(() => setNativeRestoreEnabled(false), "Native restore preference update was rejected") } catch (error) { setRestorePreviousStateEnabledSignal(true) - setSessionMessageCacheEnabled(true) throw error } - await clearSessionMessageCache() }) } @@ -280,12 +273,10 @@ export function initializeClientState(): Promise { writeBlock = isFutureClientSnapshot(loaded.snapshot) ? "snapshot" : false const snapshot = decodeClientSnapshot(loaded.snapshot) resetLoadedState(snapshot, true) - setSessionMessageCacheEnabled(!writeBlock) if (!writeBlock && migrateLegacyLayoutValues()) scheduleSave() } catch (error) { initialized = true setClientStateIsPrimary(false) - setSessionMessageCacheEnabled(false) resetLoadedState() console.warn("[client-state] failed to initialize client state", error) } diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index b2163779c..44c3872a1 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -14,7 +14,7 @@ interface SessionMetadata { parentId?: string | null } -function resolveSessionMetadata(session?: Session | SessionMetadata | null): SessionMetadata | undefined { +function resolveSessionMetadata(session?: Session | null): SessionMetadata | undefined { if (!session) return undefined return { id: session.id, @@ -71,38 +71,6 @@ export function seedSessionMessagesV2( return true } -export function mergeCachedSessionMessagePageV2( - instanceId: string, - session: Session | SessionMetadata, - messages: Message[], - messageInfos: Map, - expectedRevision: number, -): number | null { - if (!session || !Array.isArray(messages)) return null - const store = messageStoreBus.getOrCreate(instanceId) - if (!canHydrateMessages(expectedRevision, store.getSessionRevision(session.id))) return null - const metadata = resolveSessionMetadata(session) - if (!metadata) return null - store.addOrUpdateSession({ - id: metadata.id, - title: metadata.title, - parentId: metadata.parentId ?? null, - revert: (session as Session)?.revert ?? undefined, - }) - store.mergeCachedMessages(metadata.id, messages.map((message) => ({ - id: message.id, - sessionId: message.sessionId, - role: message.type, - status: normalizeStatus(message.status), - createdAt: message.timestamp, - updatedAt: message.timestamp, - parts: message.parts, - isEphemeral: false, - bumpRevision: false, - })), messageInfos.values()) - return store.getSessionRevision(metadata.id) -} - interface MessageInfoOptions { status?: MessageStatus bumpRevision?: boolean @@ -113,19 +81,17 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null return } const store = messageStoreBus.getOrCreate(instanceId) - const timeInfo = (info.time ?? {}) as { created?: number; end?: number; completed?: number } + const timeInfo = (info.time ?? {}) as { created?: number; end?: number } const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now() - const endAt = typeof timeInfo.end === "number" ? timeInfo.end : timeInfo.completed - const status = options?.status ?? "complete" + const endAt = typeof timeInfo.end === "number" ? timeInfo.end : undefined store.upsertMessage({ id: info.id, sessionId: info.sessionID, role: info.role === "user" ? "user" : "assistant", - status, + status: options?.status ?? "complete", createdAt, updatedAt: endAt ?? createdAt, - isEphemeral: status === "sending" || status === "streaming", bumpRevision: Boolean(options?.bumpRevision), }) store.setMessageInfo(info.id, info) diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts index c406cbe8e..975b4b9f6 100644 --- a/packages/ui/src/stores/message-v2/instance-store.test.ts +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -42,41 +42,3 @@ describe("message-v2 permission state", () => { }) }) - -describe("message-v2 authoritative hydration", () => { - const message = (id: string) => ({ - id, - sessionId: "session-1", - role: "assistant" as const, - status: "complete" as const, - parts: [{ id: `part-${id}`, type: "text", text: id, messageID: id, sessionID: "session-1" }] as any, - }) - const info = (id: string) => ({ id, sessionID: "session-1", role: "assistant", time: { created: 1 } }) as any - - it("replaces stale messages and accepts an authoritative empty session", () => { - const store = createInstanceMessageStore("instance-1") - store.hydrateMessages("session-1", [message("message-1"), message("message-2")], [info("message-1"), info("message-2")]) - - store.hydrateMessages("session-1", [message("message-2")], [info("message-2")]) - assert.equal(store.getMessage("message-1"), undefined) - assert.equal(store.getMessageInfo("message-1"), undefined) - assert.deepEqual(store.getSessionMessageIds("session-1"), ["message-2"]) - - store.hydrateMessages("session-1", [], []) - assert.equal(store.getMessage("message-2"), undefined) - assert.deepEqual(store.getSessionMessageIds("session-1"), []) - }) - - it("prepends older cache pages without overwriting live messages", () => { - const store = createInstanceMessageStore("instance-1") - store.mergeCachedMessages("session-1", [message("message-3"), message("message-4")]) - store.upsertMessage({ - ...message("message-4"), - parts: [{ id: "part-message-4", type: "text", text: "live", messageID: "message-4", sessionID: "session-1" }] as any, - }) - store.mergeCachedMessages("session-1", [message("message-1"), message("message-2"), message("message-4")]) - - assert.deepEqual(store.getSessionMessageIds("session-1"), ["message-1", "message-2", "message-3", "message-4"]) - assert.equal((store.getMessage("message-4")?.parts["part-message-4"]?.data as any).text, "live") - }) -}) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index d1c8c44e4..437bea3d4 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -216,7 +216,6 @@ export interface InstanceMessageStore { setState: SetStoreFunction addOrUpdateSession: (input: SessionUpsertInput) => void hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void - mergeCachedMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void upsertMessage: (input: MessageUpsertInput) => void applyPartUpdate: (input: PartUpdateInput) => void applyPartDelta: (input: { @@ -433,15 +432,11 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } function hydrateMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) { - if (!Array.isArray(inputs)) return + if (!Array.isArray(inputs) || inputs.length === 0) return ensureSessionEntry(sessionId) const incomingIds = inputs.map((item) => item.id) - const incomingIdSet = new Set(incomingIds) - const staleIds = Object.values(state.messages) - .filter((record) => record.sessionId === sessionId && !incomingIdSet.has(record.id)) - .map((record) => record.id) const normalizedRecords: Record = {} const now = Date.now() @@ -476,22 +471,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const nextPermissionsByMessage: Record> = { ...state.permissions.byMessage, } - const nextQuestionsByMessage: Record> = { - ...state.questions.byMessage, - } - - if (staleIds.length > 0) { - clearRecordDisplayCacheForMessages(instanceId, staleIds) - for (const id of staleIds) { - delete nextMessages[id] - delete nextMessageInfoVersion[id] - delete nextPendingParts[id] - delete nextPermissionsByMessage[id] - delete nextQuestionsByMessage[id] - messageInfoCache.delete(id) - clearPromptDisplayOverride(instanceId, sessionId, id) - } - } Object.entries(normalizedRecords).forEach(([id, record]) => { nextMessages[id] = record @@ -507,11 +486,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } batch(() => { - setState("messages", reconcile(nextMessages)) - setState("messageInfoVersion", reconcile(nextMessageInfoVersion)) - setState("pendingParts", reconcile(nextPendingParts)) - setState("permissions", "byMessage", reconcile(nextPermissionsByMessage)) - setState("questions", "byMessage", reconcile(nextQuestionsByMessage)) + setState("messages", () => nextMessages) + setState("messageInfoVersion", () => nextMessageInfoVersion) + setState("pendingParts", () => nextPendingParts) + setState("permissions", "byMessage", () => nextPermissionsByMessage) if (usageState) { setState("usage", sessionId, usageState) @@ -524,7 +502,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt })) recomputeLastAssistantMessageId(sessionId, incomingIds) - clearLatestTodoSnapshot(sessionId) Object.values(normalizedRecords).forEach((record) => { maybeUpdateLatestTodoFromRecord(record) }) @@ -533,61 +510,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) } - function mergeCachedMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) { - if (!Array.isArray(inputs) || inputs.length === 0) return - ensureSessionEntry(sessionId) - const existingIds = new Set(state.sessions[sessionId]?.messageIds ?? []) - const additions = inputs.filter((input) => !existingIds.has(input.id) && !state.messages[input.id]) - if (additions.length === 0) return - - const infoById = new Map() - if (infos) { - for (const info of infos) { - if (typeof info.id === "string") infoById.set(info.id, info) - } - } - const additionIds = additions.map((input) => input.id) - const now = Date.now() - const normalizedRecords: Record = {} - for (const input of additions) { - const normalizedParts = normalizeParts(input.id, input.parts) - const clientPromptDisplayMetadata = resolveClientPromptDisplayText(instanceId, input) - normalizedRecords[input.id] = { - id: input.id, - sessionId: input.sessionId, - role: input.role, - status: input.status, - createdAt: input.createdAt ?? now, - updatedAt: input.updatedAt ?? now, - isEphemeral: input.isEphemeral ?? false, - clientPromptDisplayMetadata, - revision: 0, - partIds: normalizedParts?.ids ?? [], - parts: normalizedParts?.map ?? {}, - } - setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayMetadata) - } - - batch(() => { - setState("messages", (current) => ({ ...current, ...normalizedRecords })) - for (const messageId of additionIds) { - const info = infoById.get(messageId) - if (!info) continue - messageInfoCache.set(messageId, info) - setState("messageInfoVersion", messageId, (version = 0) => version + 1) - updateUsageWithInfo(info) - } - setState("sessions", sessionId, (session) => ({ - ...session, - messageIds: [...additionIds, ...(session?.messageIds ?? [])], - updatedAt: Date.now(), - })) - recomputeLastAssistantMessageId(sessionId) - Object.values(normalizedRecords).forEach((record) => maybeUpdateLatestTodoFromRecord(record)) - bumpSessionRevision(sessionId) - }) - } - function insertMessageIntoSession(sessionId: string, messageId: string) { ensureSessionEntry(sessionId) setState("sessions", sessionId, "messageIds", (ids = []) => { @@ -1422,7 +1344,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState, addOrUpdateSession, hydrateMessages, - mergeCachedMessages, upsertMessage, applyPartUpdate, applyPartDelta, diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index d46eca63b..6c0db47c7 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -12,7 +12,6 @@ import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { clearConversationPlaybackForSession } from "./conversation-speech" -import { cancelCachedSessionMessageRestore, invalidateSessionMessageCache } from "./session-message-cache" const log = getLogger("actions") @@ -99,7 +98,6 @@ async function sendMessage( if (!session) { throw new Error("Session not found") } - cancelCachedSessionMessageRestore(instanceId, sessionId) const messageId = createId("msg") const textPartId = createId("prt") @@ -467,7 +465,6 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI ) // Optimistic removal; SSE will also broadcast a part-removed event. - invalidateSessionMessageCache(instanceId, sessionId) removeMessagePartV2(instanceId, messageId, partId) updateSessionInfo(instanceId, sessionId) } @@ -492,7 +489,6 @@ async function deleteMessage(instanceId: string, sessionId: string, messageId: s ) // Optimistic removal; SSE will also broadcast a message-removed event. - invalidateSessionMessageCache(instanceId, sessionId) removeMessageV2(instanceId, messageId) updateSessionInfo(instanceId, sessionId) } diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index b8f5602c5..28b84ce64 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -6,7 +6,7 @@ import { type Session, type SessionStatus, } from "../types/session" -import type { Message, MessageInfo } from "../types/message" +import type { Message } from "../types/message" import type { Session as SDKSession, SessionListResponse } from "@opencode-ai/sdk/v2/client" import { instances, reconcilePendingSessionIndicators } from "./instances" @@ -57,7 +57,7 @@ import { deleteSessionAttachments } from "./attachments" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" -import { mergeCachedSessionMessagePageV2, seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge" +import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge" import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" @@ -84,14 +84,6 @@ import { isProjectSessionListComplete, } from "./session-list-options" import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" -import { - cacheAuthoritativeSessionMessages, - cancelCachedSessionMessageRestore, - clearCachedSessionMessageShift, - invalidateSessionMessageCache, - restoreCachedSessionMessagePages, -} from "./session-message-cache" -import { restorePreviousStateEnabled } from "./client-state" const log = getLogger("api") const sessionListRequestIds = new Map() @@ -100,25 +92,6 @@ const pendingMetadataHydrations = new Map>() const sessionWorkspaceHints = new Map>() messageStoreBus.onInstanceDestroyed((instanceId) => sessionWorkspaceHints.delete(instanceId)) -function adaptApiMessages(sessionId: string, apiMessages: any[]): { messages: Message[]; infos: Map } { - const infos = new Map() - const messages = apiMessages.map((apiMessage: any) => { - const info = (apiMessage.info || apiMessage) as MessageInfo - const messageId = info.id || String(Date.now()) - infos.set(messageId, info) - return { - id: messageId, - sessionId, - type: info.role === "user" ? "user" as const : "assistant" as const, - parts: (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)), - timestamp: info.time?.created || Date.now(), - status: (info as any).error ? "error" as const : "complete" as const, - version: 0, - } - }) - return { messages, infos } -} - function beginSessionListRequest(instanceId: string): number { const requestId = ++nextSessionListRequestId sessionListRequestIds.set(instanceId, requestId) @@ -932,7 +905,6 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { const force = options?.force ?? false const skipChildren = options?.skipChildren ?? false + if (force) { setMessagesLoaded((prev) => { const next = new Map(prev) @@ -1104,12 +1077,9 @@ async function loadMessages( throw new Error("Session not found") } - cancelCachedSessionMessageRestore(instanceId, sessionId) const loadEpoch = advanceMessageLoadEpoch(instanceId, sessionId) - const store = messageStoreBus.getOrCreate(instanceId) - let expectedRevision = store.getSessionRevision(sessionId) + const messageRevision = messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) let retryAfterRevisionConflict = false - const sessionForV2 = session setLoading((prev) => { const next = { ...prev } @@ -1122,66 +1092,10 @@ async function loadMessages( try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) - const apiMessagesRequest = getSessionWorkspacePayload(instanceId, sessionId).then((workspacePayload) => - requestData(client.session.messages({ sessionID: sessionId, ...workspacePayload }), "session.messages"), + const apiMessages = await requestData( + client.session.messages({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }), + "session.messages", ) - const apiOutcome = apiMessagesRequest.then( - (messages) => ({ ok: true as const, messages }), - (error) => ({ ok: false as const, error }), - ) - let outcome: Awaited | undefined - - if (!force && restorePreviousStateEnabled() && store.getSessionMessageIds(sessionId).length === 0) { - const pages = restoreCachedSessionMessagePages(instanceId, sessionId) - const iterator = pages[Symbol.asyncIterator]() - try { - while (!outcome) { - const winner = await Promise.race([ - apiOutcome.then((value) => ({ kind: "http" as const, value })), - iterator.next().then((value) => ({ kind: "cache" as const, value })), - ]) - if (winner.kind === "http") { - outcome = winner.value - cancelCachedSessionMessageRestore(instanceId, sessionId, { preserveShift: true }) - void iterator.return?.(undefined).catch(() => undefined) - break - } - if (winner.value.done) { - outcome = await apiOutcome - break - } - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) { - cancelCachedSessionMessageRestore(instanceId, sessionId) - return - } - const cached = adaptApiMessages(sessionId, winner.value.value.messages) - const revision = mergeCachedSessionMessagePageV2( - instanceId, - sessionForV2, - cached.messages, - cached.infos, - expectedRevision, - ) - if (revision === null) { - cancelCachedSessionMessageRestore(instanceId, sessionId) - void iterator.return?.(undefined).catch(() => undefined) - outcome = await apiOutcome - break - } - expectedRevision = revision - reconcilePendingPermissionsV2(instanceId, sessionId) - reconcilePendingQuestionsV2(instanceId, sessionId) - } - } catch (error) { - log.warn("Failed to restore cached session messages", { instanceId, sessionId, error }) - invalidateSessionMessageCache(instanceId, sessionId) - } - } - - outcome ??= await apiOutcome - cancelCachedSessionMessageRestore(instanceId, sessionId, { preserveShift: true }) - if (!outcome.ok) throw outcome.error - const apiMessages = outcome.messages if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return @@ -1191,9 +1105,41 @@ async function loadMessages( setSessionMessagesLoadError(instanceId, sessionId, null) - const adapted = adaptApiMessages(sessionId, apiMessages) + if (apiMessages.length === 0) { + if (messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) !== messageRevision) { + retryAfterRevisionConflict = true + } else { + setMessagesLoaded((prev) => { + const next = new Map(prev) + const loadedSet = next.get(instanceId) || new Set() + loadedSet.add(sessionId) + next.set(instanceId, loadedSet) + return next + }) + } + } else { + const messagesInfo = new Map() + const messages: Message[] = apiMessages.map((apiMessage: any) => { + const info = apiMessage.info || apiMessage + const role = info.role || "assistant" + const messageId = info.id || String(Date.now()) + + messagesInfo.set(messageId, info) + + const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)) - if (apiMessages.length > 0) { + const message: Message = { + id: messageId, + sessionId, + type: role === "user" ? "user" : "assistant", + parts, + timestamp: info.time?.created || Date.now(), + status: "complete" as const, + version: 0, + } + + return message + }) let agentName = "" let providerID = "" @@ -1235,26 +1181,22 @@ async function loadMessages( return next }) - } - - const latestSession = sessions().get(instanceId)?.get(sessionId) ?? sessionForV2 - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return - if (!seedSessionMessagesV2(instanceId, latestSession, adapted.messages, adapted.infos, expectedRevision)) { - retryAfterRevisionConflict = true - } else { - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) || new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - reconcilePendingPermissionsV2(instanceId, sessionId) - reconcilePendingQuestionsV2(instanceId, sessionId) - if (restorePreviousStateEnabled()) { - void cacheAuthoritativeSessionMessages(instanceId, sessionId, apiMessages, store.getSessionRevision(sessionId)).catch((error) => - log.warn("Failed to persist authoritative session messages", { instanceId, sessionId, error }), - ) + const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? { + id: sessionId, title: session?.title, parentId: session?.parentId ?? null, revert: session?.revert, + } + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return + if (!seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo, messageRevision)) { + retryAfterRevisionConflict = true + } else { + setMessagesLoaded((prev) => { + const next = new Map(prev) + const loadedSet = next.get(instanceId) || new Set() + loadedSet.add(sessionId) + next.set(instanceId, loadedSet) + return next + }) + reconcilePendingPermissionsV2(instanceId, sessionId) + reconcilePendingQuestionsV2(instanceId, sessionId) } } @@ -1267,13 +1209,6 @@ async function loadMessages( } throw error } finally { - if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { - const clearShift = () => { - if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) clearCachedSessionMessageShift(instanceId, sessionId) - } - if (typeof requestAnimationFrame === "function") requestAnimationFrame(clearShift) - else setTimeout(clearShift, 0) - } if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { setLoading((prev) => { const next = { ...prev } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index d461cd601..a5f7d1c56 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -62,7 +62,7 @@ import { type SessionRetryState, type SessionStatus, } from "../types/session" -import { ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, messagesLoaded, prependSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" +import { ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, prependSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { mergeFetchedSessionRuntimeState } from "./session-generation-recovery" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" @@ -90,8 +90,6 @@ import { import { messageStoreBus } from "./message-v2/bus" import type { InstanceMessageStore } from "./message-v2/instance-store" import { handleConversationAssistantPartUpdated } from "./conversation-speech" -import { cancelCachedSessionMessageRestore, invalidateSessionMessageCache, scheduleSessionMessageCacheWrite } from "./session-message-cache" -import { restorePreviousStateEnabled } from "./client-state" const log = getLogger("sse") const pendingSessionFetches = new Map>() @@ -307,7 +305,6 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId if (!sessionId || !messageId) return - cancelCachedSessionMessageRestore(instanceId, sessionId) if (part.type === "compaction") { ensureSessionStatus(instanceId, sessionId, "compacting", (event as any)?.directory) } @@ -365,7 +362,6 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const sessionId = typeof info.sessionID === "string" ? info.sessionID : undefined const messageId = typeof info.id === "string" ? info.id : undefined if (!sessionId || !messageId) return - cancelCachedSessionMessageRestore(instanceId, sessionId) // Flush any pending deltas for this message before applying the update. // Deltas are buffered for up to 50ms; if message.updated arrives before @@ -375,17 +371,15 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes // message status/metadata update runs on the complete content. flushPendingDeltasForMessage(instanceId, messageId, applyPartDeltaV2) - const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number; completed?: number } + const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number } const nextUpdated = typeof timeInfo.end === "number" && timeInfo.end > 0 ? timeInfo.end - : typeof timeInfo.completed === "number" && timeInfo.completed > 0 - ? timeInfo.completed - : typeof timeInfo.updated === "number" && timeInfo.updated > 0 - ? timeInfo.updated - : typeof timeInfo.created === "number" && timeInfo.created > 0 - ? timeInfo.created - : Date.now() + : typeof timeInfo.updated === "number" && timeInfo.updated > 0 + ? timeInfo.updated + : typeof timeInfo.created === "number" && timeInfo.created > 0 + ? timeInfo.created + : Date.now() withSession(instanceId, sessionId, (session) => { const currentUpdated = session.time?.updated ?? 0 @@ -397,9 +391,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const role: MessageRole = info.role === "user" ? "user" : "assistant" const hasError = Boolean((info as any).error) - const hasEnded = - (typeof timeInfo.end === "number" && timeInfo.end > 0) || - (typeof timeInfo.completed === "number" && timeInfo.completed > 0) + const hasEnded = typeof timeInfo.end === "number" && timeInfo.end > 0 const status: MessageStatus = hasError ? "error" : hasEnded ? "complete" : "streaming" let record = store.getMessage(messageId) @@ -413,7 +405,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes if (!record) { const createdAt = info.time?.created ?? Date.now() - const endAt = timeInfo.end ?? timeInfo.completed + const endAt = (info.time as { end?: number } | undefined)?.end store.upsertMessage({ id: messageId, sessionId, @@ -427,13 +419,6 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true }) updateSessionInfo(instanceId, sessionId) - if ( - restorePreviousStateEnabled() && - messagesLoaded().get(instanceId)?.has(sessionId) && - (status === "complete" || status === "error") - ) { - scheduleSessionMessageCacheWrite(instanceId, sessionId) - } } } @@ -449,7 +434,6 @@ function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent if (!props) return const { messageID, partID, field, delta } = props if (!messageID || !partID || !field || typeof delta !== "string") return - cancelCachedSessionMessageRestore(instanceId, props.sessionID) enqueueDelta(instanceId, messageID, partID, field, delta) } @@ -458,7 +442,6 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo if (!info) return if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(info.id)) return - if ((info as any).revert !== undefined) invalidateSessionMessageCache(instanceId, info.id) const instanceSessions = sessions().get(instanceId) ?? new Map() @@ -612,7 +595,6 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted if (!sessionID) return log.info(`[SSE] Session compacted: ${sessionID}`) - invalidateSessionMessageCache(instanceId, sessionID) const existing = sessions().get(instanceId)?.get(sessionID) if (existing) setSessionStatus(instanceId, sessionID, "working", { force: true }) @@ -660,7 +642,6 @@ function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): v if (!sessionID || !messageID) return log.info(`[SSE] Message removed from session ${sessionID}`, { messageID }) - invalidateSessionMessageCache(instanceId, sessionID) removeMessageV2(instanceId, messageID, sessionID) updateSessionInfo(instanceId, sessionID) } @@ -670,7 +651,6 @@ function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedE if (!sessionID || !messageID || !partID) return log.info(`[SSE] Message part removed from session ${sessionID}`, { messageID, partID }) - invalidateSessionMessageCache(instanceId, sessionID) removeMessagePartV2(instanceId, messageID, partID, sessionID) updateSessionInfo(instanceId, sessionID) } diff --git a/packages/ui/src/stores/session-message-cache.ts b/packages/ui/src/stores/session-message-cache.ts deleted file mode 100644 index 17e1c97ef..000000000 --- a/packages/ui/src/stores/session-message-cache.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { - deleteSessionMessageCache, - createSessionMessageCacheKey, - type SessionMessageCachePage, - isSessionMessageCacheEnabled, - onSessionMessageCacheReset, - openSessionMessageCache, - readSessionMessageCachePage, - writeSessionMessageCache, -} from "../lib/session-message-cache" -import { createSignal } from "solid-js" -import { getLogger } from "../lib/logger" -import { instances } from "./instances" -import { messageStoreBus } from "./message-v2/bus" - -const log = getLogger("session") -const WRITE_DEBOUNCE_MS = 500 -const pendingWrites = new Map>() -const cacheGenerations = new Map() -const pendingInvalidations = new Map>() -const invalidatedEntries = new Set() -const activeRestores = new Map() -const [restoringSessions, setRestoringSessions] = createSignal>(new Set()) -let resetGeneration = 0 - -function pendingKey(instanceId: string, sessionId: string): string { - return `${instanceId}:${sessionId}` -} - -function cacheGeneration(key: string): number { - return cacheGenerations.get(key) ?? 0 -} - -function setRestoreActive(key: string, active: boolean): void { - setRestoringSessions((current) => { - const next = new Set(current) - if (active) next.add(key) - else next.delete(key) - return next - }) -} - -export function isRestoringCachedSessionMessages(instanceId: string, sessionId: string): boolean { - return restoringSessions().has(pendingKey(instanceId, sessionId)) -} - -export function cancelCachedSessionMessageRestore( - instanceId: string, - sessionId: string, - options?: { preserveShift?: boolean }, -): void { - const key = pendingKey(instanceId, sessionId) - activeRestores.delete(key) - if (!options?.preserveShift) setRestoreActive(key, false) -} - -export function clearCachedSessionMessageShift(instanceId: string, sessionId: string): void { - setRestoreActive(pendingKey(instanceId, sessionId), false) -} - -function workspaceForInstance(instanceId: string): string | null { - return instances().get(instanceId)?.folder ?? null -} - -function waitForIdle(): Promise { - return new Promise((resolve) => { - if (typeof requestIdleCallback === "function") { - requestIdleCallback(() => resolve(), { timeout: 2_000 }) - } else { - setTimeout(resolve, 0) - } - }) -} - -function snapshotSession(instanceId: string, sessionId: string): unknown[] | null { - const store = messageStoreBus.getInstance(instanceId) - if (!store) return null - - const result: unknown[] = [] - for (const messageId of store.getSessionMessageIds(sessionId)) { - const record = store.getMessage(messageId) - const info = store.getMessageInfo(messageId) - if (!record || !info || record.isEphemeral || record.status === "sending" || record.status === "streaming") return null - - const parts = record.partIds.map((partId) => record.parts[partId]?.data).filter(Boolean).map((part) => { - const { renderCache: _renderCache, pendingPermission: _pendingPermission, ...cacheable } = part as any - return cacheable - }) - result.push({ info, parts }) - } - return result -} - -export async function* restoreCachedSessionMessagePages( - instanceId: string, - sessionId: string, -): AsyncGenerator { - if (!isSessionMessageCacheEnabled()) return - const workspace = workspaceForInstance(instanceId) - if (!workspace) return - const entryKey = createSessionMessageCacheKey(workspace, sessionId) - if (invalidatedEntries.has(entryKey)) return - const key = pendingKey(instanceId, sessionId) - const generation = cacheGeneration(key) - const reset = resetGeneration - const token = Symbol(key) - let restoredPage = false - activeRestores.set(key, token) - setRestoreActive(key, true) - try { - await pendingInvalidations.get(key) - let cursor = await openSessionMessageCache(workspace, sessionId) - const scroll = messageStoreBus.getInstance(instanceId)?.getScrollSnapshot(sessionId, "message-stream") - const deferredAnchor = scroll && !scroll.atBottom ? scroll.anchorKey : undefined - let buffered: unknown[] = [] - while ( - cursor && - cursor.beforeIndex > 0 && - activeRestores.get(key) === token && - cacheGeneration(key) === generation && - resetGeneration === reset && - !invalidatedEntries.has(entryKey) - ) { - const result = await readSessionMessageCachePage(cursor) - if (!result) return - if ( - activeRestores.get(key) !== token || - cacheGeneration(key) !== generation || - resetGeneration !== reset || - invalidatedEntries.has(entryKey) - ) return - cursor = result.cursor - const page = result.page - if (deferredAnchor && !page.messages.some((message) => (message as any)?.info?.id === deferredAnchor) && cursor.beforeIndex > 0) { - buffered = [...page.messages, ...buffered] - continue - } - if (buffered.length > 0) { - restoredPage = true - yield { ...page, messages: [...page.messages, ...buffered] } - buffered = [] - } else { - restoredPage = true - yield page - } - } - if (buffered.length > 0) { - restoredPage = true - yield { messages: buffered, startIndex: cursor?.startIndex ?? 0, totalCount: cursor?.totalCount ?? buffered.length, done: true, complete: cursor?.complete ?? true } - } - } finally { - if (activeRestores.get(key) === token) { - activeRestores.delete(key) - if (!restoredPage) setRestoreActive(key, false) - } - } -} - -export async function cacheAuthoritativeSessionMessages( - instanceId: string, - sessionId: string, - messages: unknown[], - expectedRevision: number, -): Promise { - if (!isSessionMessageCacheEnabled()) return - const store = messageStoreBus.getInstance(instanceId) - if (!store) return - const workspace = workspaceForInstance(instanceId) - if (!workspace) return - const key = pendingKey(instanceId, sessionId) - const entryKey = createSessionMessageCacheKey(workspace, sessionId) - const generation = cacheGeneration(key) - const reset = resetGeneration - await waitForIdle() - if ( - cacheGeneration(key) !== generation || - resetGeneration !== reset || - messageStoreBus.getInstance(instanceId) !== store || - store.getSessionRevision(sessionId) !== expectedRevision - ) return - const written = await writeSessionMessageCache(workspace, sessionId, messages) - if (written && cacheGeneration(key) === generation && resetGeneration === reset) { - invalidatedEntries.delete(entryKey) - } -} - -export function scheduleSessionMessageCacheWrite(instanceId: string, sessionId: string): void { - if (!isSessionMessageCacheEnabled()) return - const key = pendingKey(instanceId, sessionId) - const existing = pendingWrites.get(key) - if (existing) clearTimeout(existing) - pendingWrites.set(key, setTimeout(() => { - pendingWrites.delete(key) - const workspace = workspaceForInstance(instanceId) - const messages = snapshotSession(instanceId, sessionId) - if (!workspace || !messages) return - const generation = cacheGeneration(key) - const reset = resetGeneration - const entryKey = createSessionMessageCacheKey(workspace, sessionId) - void writeSessionMessageCache(workspace, sessionId, messages) - .then((written) => { - if (written && cacheGeneration(key) === generation && resetGeneration === reset) { - invalidatedEntries.delete(entryKey) - } - }) - .catch((error) => log.warn("Failed to cache completed session messages", { instanceId, sessionId, error })) - }, WRITE_DEBOUNCE_MS)) -} - -export function invalidateSessionMessageCache(instanceId: string, sessionId: string): void { - const key = pendingKey(instanceId, sessionId) - cancelCachedSessionMessageRestore(instanceId, sessionId) - cacheGenerations.set(key, cacheGeneration(key) + 1) - const pending = pendingWrites.get(key) - if (pending) { - clearTimeout(pending) - pendingWrites.delete(key) - } - const workspace = workspaceForInstance(instanceId) - if (!workspace) return - const entryKey = createSessionMessageCacheKey(workspace, sessionId) - invalidatedEntries.add(entryKey) - const invalidation = deleteSessionMessageCache(workspace, sessionId) - .then(() => { invalidatedEntries.delete(entryKey) }) - .catch((error) => log.warn("Failed to invalidate cached session messages", { instanceId, sessionId, error })) - .finally(() => { - if (pendingInvalidations.get(key) === invalidation) pendingInvalidations.delete(key) - }) - pendingInvalidations.set(key, invalidation) -} - -function resetPendingSessionMessageCacheWork(): void { - resetGeneration += 1 - activeRestores.clear() - setRestoringSessions(new Set()) - for (const timer of pendingWrites.values()) clearTimeout(timer) - pendingWrites.clear() -} - -onSessionMessageCacheReset(resetPendingSessionMessageCacheWork) - -messageStoreBus.onInstanceDestroyed((instanceId) => { - const prefix = `${instanceId}:` - for (const [key, timer] of pendingWrites) { - if (!key.startsWith(prefix)) continue - clearTimeout(timer) - pendingWrites.delete(key) - } - for (const key of cacheGenerations.keys()) { - if (key.startsWith(prefix)) cacheGenerations.set(key, cacheGeneration(key) + 1) - } - for (const key of activeRestores.keys()) { - if (key.startsWith(prefix)) activeRestores.delete(key) - } - setRestoringSessions((current) => new Set([...current].filter((key) => !key.startsWith(prefix)))) - for (const key of pendingInvalidations.keys()) { - if (key.startsWith(prefix)) pendingInvalidations.delete(key) - } -}) diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 114484a4f..99bfeb78f 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -176,20 +176,4 @@ describe("session request authority", () => { cleanup() } }) - - it("preserves historical assistant error status during hydration", async () => { - const instanceId = "errored-message-load", sessionId = "session" - const { client, cleanup } = setup(instanceId) - ;(client.session as any).messages = async () => ({ - data: [{ ...apiMessage("errored-message", sessionId), info: { ...apiMessage("errored-message", sessionId).info, error: { name: "ProviderError" } } }], - }) - setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) - - try { - await loadMessages(instanceId, sessionId) - assert.equal(messageStoreBus.getOrCreate(instanceId).getMessage("errored-message")?.status, "error") - } finally { - cleanup() - } - }) }) diff --git a/packages/ui/src/stores/settings-screen.ts b/packages/ui/src/stores/settings-screen.ts index f83f009a6..f083324a8 100644 --- a/packages/ui/src/stores/settings-screen.ts +++ b/packages/ui/src/stores/settings-screen.ts @@ -4,14 +4,11 @@ export type SettingsSectionId = | "general" | "chat" | "notifications" - | "speech" - | "remote" - | "opencode" - | "providers" - | "sidecars" - | "config-files" + | "voice" + | "connections" + | "runtime" | "advanced" - | "info" + | "about" const [settingsOpen, setSettingsOpen] = createSignal(false) const [activeSettingsSection, setActiveSettingsSection] = createSignal("general") diff --git a/packages/ui/src/styles/components/provider-auth.css b/packages/ui/src/styles/components/provider-auth.css index 3132b842a..1acc258ca 100644 --- a/packages/ui/src/styles/components/provider-auth.css +++ b/packages/ui/src/styles/components/provider-auth.css @@ -12,19 +12,6 @@ border-radius: 0; } -.providers-manager-embedded { - position: static; - transform: none; - width: 100%; - max-height: none; - border: 1px solid var(--border-base); - background: var(--surface-base); -} - -.providers-manager-embedded .providers-manager-body { - overflow: visible; -} - .providers-manager-header { display: flex; align-items: flex-start; @@ -351,12 +338,6 @@ height: 100dvh; } - .providers-manager-embedded { - width: 100%; - max-height: none; - height: auto; - } - .providers-connect-bar, .providers-panel-header { grid-template-columns: 1fr; diff --git a/packages/ui/src/styles/components/settings-screen.css b/packages/ui/src/styles/components/settings-screen.css index 466fd32fc..e6c943388 100644 --- a/packages/ui/src/styles/components/settings-screen.css +++ b/packages/ui/src/styles/components/settings-screen.css @@ -41,8 +41,6 @@ display: flex; flex-direction: column; gap: 1rem; - min-height: 0; - overflow: hidden; padding: 1.25rem; background: linear-gradient(180deg, color-mix(in oklab, var(--surface-secondary) 92%, var(--accent-primary) 8%), var(--surface-secondary)); @@ -96,9 +94,6 @@ display: flex; flex-direction: column; gap: 0.35rem; - min-height: 0; - overflow-x: hidden; - overflow-y: auto; } .settings-nav-button { @@ -117,7 +112,7 @@ .settings-nav-button:focus-visible { outline: 2px solid var(--accent-primary); - outline-offset: -2px; + outline-offset: 2px; } .settings-nav-button:hover { @@ -555,7 +550,6 @@ .settings-expansion-table { margin-bottom: 1.25rem; - border: 1px solid var(--border-base); } .settings-expansion-table-header, From 9b0eabee58e2c9c73f1f7c1c832ab6275f1c88c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 26 Jul 2026 18:20:59 +0200 Subject: [PATCH 7/7] feat(settings): finalize dedicated settings sections Split remote access, OpenCode runtime, providers, SideCars, config files, advanced controls, and information into dedicated navigation sections while preserving the existing settings behavior. Embed the provider manager directly in Settings and keep stale asynchronous provider and OAuth operations scoped to the current ready instance. Preserve the dialog entry point used by the model selector. Validated with the UI typecheck and diff checks after separating the unrelated session-cache work into its own branch. --- packages/ui/src/App.tsx | 2 +- .../src/components/folder-selection-view.tsx | 4 +- packages/ui/src/components/instance-tabs.tsx | 2 +- .../provider-auth/provider-manager-modal.tsx | 179 +++++++++++++----- .../ui/src/components/settings-screen.tsx | 50 +++-- .../settings/advanced-settings-section.tsx | 90 ++------- .../config-files-settings-section.tsx | 1 + .../settings/connections-settings-section.tsx | 65 ------- .../settings/general-settings-section.tsx | 2 - .../settings/opencode-settings-section.tsx | 97 ++++++++++ .../settings/providers-settings-section.tsx | 7 + .../settings/runtime-settings-section.tsx | 45 ----- packages/ui/src/stores/settings-screen.ts | 11 +- .../src/styles/components/provider-auth.css | 19 ++ .../src/styles/components/settings-screen.css | 8 +- 15 files changed, 317 insertions(+), 265 deletions(-) delete mode 100644 packages/ui/src/components/settings/connections-settings-section.tsx create mode 100644 packages/ui/src/components/settings/opencode-settings-section.tsx create mode 100644 packages/ui/src/components/settings/providers-settings-section.tsx delete mode 100644 packages/ui/src/components/settings/runtime-settings-section.tsx diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index cea0b2917..189957c7f 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -327,7 +327,7 @@ const App: Component = () => { function handleLaunchErrorAdvanced() { clearLaunchError() - openSettings("runtime") + openSettings("opencode") } function handleNewInstanceRequest() { diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index e8905090f..af499dcfe 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -539,7 +539,7 @@ const FolderSelectionView: Component = (props) => { + + +
-
{t("settings.providers.empty.noInstance")}
+
{t("settings.providers.empty.noInstance")}
@@ -524,7 +585,10 @@ export const ProviderManagerModal: Component = (props
-
{loadError()}
+ +
+ + @@ -563,6 +627,7 @@ export const ProviderManagerModal: Component = (props options={methodOptions()} optionValue="value" optionTextValue="label" + disabled={stage() !== "prompts" && stage() !== "error"} itemComponent={(itemProps) => {itemProps.item.rawValue.label}} > @@ -581,9 +646,9 @@ export const ProviderManagerModal: Component = (props
-
{t("settings.providers.oauth.promptsUnsupported")}
+
-
{t("settings.providers.oauth.noPrompts")}
+
{t("settings.providers.oauth.noPrompts")}
{(prompt) => (
@@ -598,8 +663,8 @@ export const ProviderManagerModal: Component = (props
-
{authorization()?.instructions || t("settings.providers.oauth.enterCode")}
-
{t("settings.providers.oauth.waitingTitle")}
{authorization()?.instructions}
+
{authorization()?.instructions || t("settings.providers.oauth.enterCode")}
+
{t("settings.providers.oauth.waitingTitle")}
{authorization()?.instructions}
-
{t("settings.providers.oauth.popupBlocked")}
+
-
{t("settings.providers.success")}
-
{actionError()}
+
{t("settings.providers.success")}
void submitAuth()}>{t("settings.providers.actions.working")}}> @@ -630,19 +694,32 @@ export const ProviderManagerModal: Component = (props

{t("settings.providers.configured.title")}

-
{t("settings.providers.loading")}
-
{t("settings.providers.empty.noConfiguredProviders")}
+
{t("settings.providers.loading")}
+
{t("settings.providers.empty.noConfiguredProviders")}
{(provider) => (

{provider.name || provider.id}

{provider.id}

{methodSummary(provider.id)}

{describeProviderSource(provider)}

- +
)}
+ + ) + + if (props.embedded) { + return
{content()}
+ } + + return ( + + + + + {content()} diff --git a/packages/ui/src/components/settings-screen.tsx b/packages/ui/src/components/settings-screen.tsx index ce12b0d48..adc07b773 100644 --- a/packages/ui/src/components/settings-screen.tsx +++ b/packages/ui/src/components/settings-screen.tsx @@ -1,6 +1,6 @@ import { Dialog } from "@kobalte/core/dialog" import { Select } from "@kobalte/core/select" -import { Settings, Bell, ChevronDown, FileCog, Info, MessageSquare, SlidersHorizontal, Terminal, Volume2, Wifi, X } from "lucide-solid" +import { Settings, Bell, ChevronDown, FileCog, Globe, Info, MessageSquare, MonitorUp, PlugZap, SlidersHorizontal, Terminal, Volume2, X } from "lucide-solid" import { createMemo, For, type Component } from "solid-js" import { useI18n } from "../lib/i18n" import { @@ -15,9 +15,14 @@ import { ChatSettingsSection } from "./settings/chat-settings-section" import { InfoSettingsSection } from "./settings/info-settings-section" import { NotificationsSettingsSection } from "./settings/notifications-settings-section" import { SpeechSettingsSection } from "./settings/speech-settings-section" -import { ConnectionsSettingsSection } from "./settings/connections-settings-section" -import { RuntimeSettingsSection } from "./settings/runtime-settings-section" +import { ProvidersSettingsSection } from "./settings/providers-settings-section" +import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" import { AdvancedSettingsSection } from "./settings/advanced-settings-section" +import { ConfigFilesSettingsSection } from "./settings/config-files-settings-section" +import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" +import { SavedRemoteServersCard } from "./settings/saved-remote-servers-card" +import { SideCarsSettingsSection } from "./settings/sidecars-settings-section" +import { canOpenRemoteWindows } from "../lib/runtime-env" import { confirmSettingsDiscard } from "../stores/settings-dirty-guard" type SettingsSectionOption = { @@ -34,12 +39,17 @@ export const SettingsScreen: Component = () => { { id: "general", icon: SlidersHorizontal, label: t("settings.nav.general") }, { id: "chat", icon: MessageSquare, label: t("settings.nav.chat") }, { id: "notifications", icon: Bell, label: t("settings.nav.notifications") }, - { id: "voice", icon: Volume2, label: t("settings.nav.voice") }, - { id: "connections", icon: Wifi, label: t("settings.nav.connections") }, - { id: "runtime", icon: Terminal, label: t("settings.nav.runtime") }, - { id: "advanced", icon: FileCog, label: t("settings.nav.advanced") }, - { id: "about", icon: Info, label: t("settings.nav.about") }, + { id: "speech", icon: Volume2, label: t("settings.nav.speech") }, + { id: "opencode", icon: Terminal, label: t("settings.nav.opencode") }, + { id: "providers", icon: PlugZap, label: t("settings.nav.providers") }, + { id: "sidecars", icon: Globe, label: t("settings.nav.sidecars") }, + { id: "config-files", icon: FileCog, label: t("settings.nav.configFiles") }, + { id: "advanced", icon: Settings, label: t("settings.nav.advanced") }, + { id: "info", icon: Info, label: t("settings.nav.info") }, ] + if (canOpenRemoteWindows()) { + items.splice(4, 0, { id: "remote", icon: MonitorUp, label: t("settings.nav.remote") }) + } return items }) @@ -51,15 +61,26 @@ export const SettingsScreen: Component = () => { return case "notifications": return - case "voice": + case "speech": return - case "connections": - return - case "runtime": - return + case "remote": + return canOpenRemoteWindows() ? ( +
+ + +
+ ) : + case "opencode": + return + case "providers": + return + case "sidecars": + return + case "config-files": + return case "advanced": return - case "about": + case "info": return case "general": default: @@ -166,6 +187,7 @@ export const SettingsScreen: Component = () => { type="button" class="settings-nav-button" data-selected={activeSettingsSection() === section.id ? "true" : "false"} + aria-current={activeSettingsSection() === section.id ? "page" : undefined} onClick={() => void handleSectionChange(section.id)} > diff --git a/packages/ui/src/components/settings/advanced-settings-section.tsx b/packages/ui/src/components/settings/advanced-settings-section.tsx index 1c601ade6..6de2da029 100644 --- a/packages/ui/src/components/settings/advanced-settings-section.tsx +++ b/packages/ui/src/components/settings/advanced-settings-section.tsx @@ -1,77 +1,24 @@ -import { Select } from "@kobalte/core/select" -import { ChevronDown } from "lucide-solid" -import { createMemo, Show, type Component } from "solid-js" +import { createMemo, type Component } from "solid-js" import { useI18n } from "../../lib/i18n" import { getBehaviorSettings } from "../../lib/settings/behavior-registry" -import { isTauriHost } from "../../lib/runtime-env" -import { useConfig, type ServerLogLevel } from "../../stores/preferences" +import { useConfig } from "../../stores/preferences" import EnvironmentVariablesEditor from "../environment-variables-editor" import { BehaviorSettingRows } from "./behavior-setting-rows" -import { ConfigFilesSettingsSection } from "./config-files-settings-section" - -type LogLevelOption = { value: ServerLogLevel; label: string } export const AdvancedSettingsSection: Component = () => { const { t } = useI18n() const config = useConfig() - const { serverSettings, updateLogLevel } = config - const logLevelOptions = createMemo(() => [ - { value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") }, - { value: "INFO", label: t("settings.opencode.logLevel.option.info") }, - { value: "WARN", label: t("settings.opencode.logLevel.option.warn") }, - { value: "ERROR", label: t("settings.opencode.logLevel.option.error") }, - ]) - const selectedLogLevel = createMemo( - () => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0], - ) - const transportSettings = createMemo(() => - getBehaviorSettings(config).filter((setting) => setting.id === "behavior.tauriNativeEventTransport"), + const advancedSettings = createMemo(() => + getBehaviorSettings(config).filter( + (setting) => + setting.id === "behavior.autoCleanupBlankSessions" || + setting.id === "behavior.keepUnseenSubagentIdleStatus" || + setting.id === "behavior.tauriNativeEventTransport", + ), ) return (
-
-
-
-

{t("settings.opencode.logLevel.title")}

-

{t("settings.opencode.logLevel.subtitle")}

-
- {t("settings.scope.server")} -
-
-
-
-
{t("settings.opencode.logLevel.selector.title")}
-
{t("settings.opencode.logLevel.selector.subtitle")}
-
- - value={selectedLogLevel()} - onChange={(option) => option && updateLogLevel(option.value)} - options={logLevelOptions()} - optionValue="value" - optionTextValue="label" - itemComponent={(itemProps) => ( - - {itemProps.item.rawValue.label} - - )} - > - -
- > - {(state) => {state.selectedOption()?.label}} - -
- -
- - - - -
-
-
-
@@ -83,24 +30,9 @@ export const AdvancedSettingsSection: Component = () => {
- -
-
- -
-
-
-
-
-
-

{t("settings.configFiles.title")}

-

{t("settings.configFiles.subtitle")}

-
- {t("settings.scope.server")} -
-
- +
+
diff --git a/packages/ui/src/components/settings/config-files-settings-section.tsx b/packages/ui/src/components/settings/config-files-settings-section.tsx index e1f3f9867..9c08312e2 100644 --- a/packages/ui/src/components/settings/config-files-settings-section.tsx +++ b/packages/ui/src/components/settings/config-files-settings-section.tsx @@ -122,6 +122,7 @@ export const ConfigFilesSettingsSection: Component = () => { return (
+

{t("settings.configFiles.title")}

diff --git a/packages/ui/src/components/settings/connections-settings-section.tsx b/packages/ui/src/components/settings/connections-settings-section.tsx deleted file mode 100644 index 504810c1b..000000000 --- a/packages/ui/src/components/settings/connections-settings-section.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { PlugZap } from "lucide-solid" -import { createMemo, createSignal, Show, type Component } from "solid-js" -import { useI18n } from "../../lib/i18n" -import { canOpenRemoteWindows } from "../../lib/runtime-env" -import { activeInstanceId, instances } from "../../stores/instances" -import { ProviderManagerModal } from "../provider-auth/provider-manager-modal" -import { RemoteAccessSettingsSection } from "./remote-access-settings-section" -import { SavedRemoteServersCard } from "./saved-remote-servers-card" - -export const ConnectionsSettingsSection: Component = () => { - const { t } = useI18n() - const [providerManagerOpen, setProviderManagerOpen] = createSignal(false) - const providerInstance = createMemo(() => { - const id = activeInstanceId() - return id ? instances().get(id) ?? null : null - }) - const providerInstanceLabel = createMemo(() => { - const instance = providerInstance() - if (!instance) return "" - return `${instance.projectName || instance.folder} (${instance.id})` - }) - - return ( - <> -
- - - - - -
-
-
- -
-

{t("settings.providers.title")}

-

{t("settings.providers.subtitle")}

-

- {providerInstance() - ? t("settings.providers.target", { workspace: providerInstanceLabel() }) - : t("settings.providers.empty.noInstance")} -

-
-
- -
-
-
- - - - ) -} diff --git a/packages/ui/src/components/settings/general-settings-section.tsx b/packages/ui/src/components/settings/general-settings-section.tsx index 4d71318c7..f55502c02 100644 --- a/packages/ui/src/components/settings/general-settings-section.tsx +++ b/packages/ui/src/components/settings/general-settings-section.tsx @@ -26,8 +26,6 @@ export const GeneralSettingsSection: Component = () => { setting.id === "behavior.messageTimeline" || setting.id === "behavior.timelineToolCalls" || setting.id === "behavior.diffViewMode" || - setting.id === "behavior.autoCleanupBlankSessions" || - setting.id === "behavior.keepUnseenSubagentIdleStatus" || setting.id === "behavior.promptSubmitOnEnter", ), { diff --git a/packages/ui/src/components/settings/opencode-settings-section.tsx b/packages/ui/src/components/settings/opencode-settings-section.tsx new file mode 100644 index 000000000..f919dbf01 --- /dev/null +++ b/packages/ui/src/components/settings/opencode-settings-section.tsx @@ -0,0 +1,97 @@ +import { Select } from "@kobalte/core/select" +import { createEffect, createMemo, createSignal, type Component } from "solid-js" +import { ChevronDown, Terminal } from "lucide-solid" +import OpenCodeBinarySelector from "../opencode-binary-selector" +import { useConfig, type ServerLogLevel } from "../../stores/preferences" +import { useI18n } from "../../lib/i18n" +import { OpenCodeUpdateCard } from "./opencode-update-card" + +type LogLevelOption = { value: ServerLogLevel; label: string } + +export const OpenCodeSettingsSection: Component = () => { + const { t } = useI18n() + const { serverSettings, updateLastUsedBinary, updateLogLevel } = useConfig() + const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") + const logLevelOptions = createMemo(() => [ + { value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") }, + { value: "INFO", label: t("settings.opencode.logLevel.option.info") }, + { value: "WARN", label: t("settings.opencode.logLevel.option.warn") }, + { value: "ERROR", label: t("settings.opencode.logLevel.option.error") }, + ]) + const selectedLogLevel = createMemo( + () => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0], + ) + + createEffect(() => { + const binary = serverSettings().opencodeBinary || "opencode" + setSelectedBinary((current) => (current === binary ? current : binary)) + }) + + const handleBinaryChange = (binary: string) => { + setSelectedBinary(binary) + updateLastUsedBinary(binary) + } + + return ( +
+
+
+
+ +
+

{t("settings.opencode.runtime.title")}

+

{t("settings.opencode.runtime.subtitle")}

+
+
+ {t("settings.scope.server")} +
+ + +
+ + + +
+
+
+

{t("settings.opencode.logLevel.title")}

+

{t("settings.opencode.logLevel.subtitle")}

+
+ {t("settings.scope.server")} +
+
+
+
+
{t("settings.opencode.logLevel.selector.title")}
+
{t("settings.opencode.logLevel.selector.subtitle")}
+
+ + value={selectedLogLevel()} + onChange={(option) => option && updateLogLevel(option.value)} + options={logLevelOptions()} + optionValue="value" + optionTextValue="label" + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + > + +
+ > + {(state) => {state.selectedOption()?.label}} + +
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/ui/src/components/settings/providers-settings-section.tsx b/packages/ui/src/components/settings/providers-settings-section.tsx new file mode 100644 index 000000000..50988162c --- /dev/null +++ b/packages/ui/src/components/settings/providers-settings-section.tsx @@ -0,0 +1,7 @@ +import { type Component } from "solid-js" +import { activeInstanceId } from "../../stores/instances" +import { ProviderManagerModal } from "../provider-auth/provider-manager-modal" + +export const ProvidersSettingsSection: Component = () => { + return +} diff --git a/packages/ui/src/components/settings/runtime-settings-section.tsx b/packages/ui/src/components/settings/runtime-settings-section.tsx deleted file mode 100644 index ef56c26e1..000000000 --- a/packages/ui/src/components/settings/runtime-settings-section.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { createEffect, createSignal, type Component } from "solid-js" -import { Terminal } from "lucide-solid" -import OpenCodeBinarySelector from "../opencode-binary-selector" -import { useConfig } from "../../stores/preferences" -import { useI18n } from "../../lib/i18n" -import { OpenCodeUpdateCard } from "./opencode-update-card" -import { SideCarsSettingsSection } from "./sidecars-settings-section" - -export const RuntimeSettingsSection: Component = () => { - const { t } = useI18n() - const { serverSettings, updateLastUsedBinary } = useConfig() - const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") - - createEffect(() => { - const binary = serverSettings().opencodeBinary || "opencode" - setSelectedBinary((current) => (current === binary ? current : binary)) - }) - - const handleBinaryChange = (binary: string) => { - setSelectedBinary(binary) - updateLastUsedBinary(binary) - } - - return ( -
-
-
-
- -
-

{t("settings.opencode.runtime.title")}

-

{t("settings.opencode.runtime.subtitle")}

-
-
- {t("settings.scope.server")} -
- - -
- - - -
- ) -} diff --git a/packages/ui/src/stores/settings-screen.ts b/packages/ui/src/stores/settings-screen.ts index f083324a8..f83f009a6 100644 --- a/packages/ui/src/stores/settings-screen.ts +++ b/packages/ui/src/stores/settings-screen.ts @@ -4,11 +4,14 @@ export type SettingsSectionId = | "general" | "chat" | "notifications" - | "voice" - | "connections" - | "runtime" + | "speech" + | "remote" + | "opencode" + | "providers" + | "sidecars" + | "config-files" | "advanced" - | "about" + | "info" const [settingsOpen, setSettingsOpen] = createSignal(false) const [activeSettingsSection, setActiveSettingsSection] = createSignal("general") diff --git a/packages/ui/src/styles/components/provider-auth.css b/packages/ui/src/styles/components/provider-auth.css index 1acc258ca..3132b842a 100644 --- a/packages/ui/src/styles/components/provider-auth.css +++ b/packages/ui/src/styles/components/provider-auth.css @@ -12,6 +12,19 @@ border-radius: 0; } +.providers-manager-embedded { + position: static; + transform: none; + width: 100%; + max-height: none; + border: 1px solid var(--border-base); + background: var(--surface-base); +} + +.providers-manager-embedded .providers-manager-body { + overflow: visible; +} + .providers-manager-header { display: flex; align-items: flex-start; @@ -338,6 +351,12 @@ height: 100dvh; } + .providers-manager-embedded { + width: 100%; + max-height: none; + height: auto; + } + .providers-connect-bar, .providers-panel-header { grid-template-columns: 1fr; diff --git a/packages/ui/src/styles/components/settings-screen.css b/packages/ui/src/styles/components/settings-screen.css index e6c943388..466fd32fc 100644 --- a/packages/ui/src/styles/components/settings-screen.css +++ b/packages/ui/src/styles/components/settings-screen.css @@ -41,6 +41,8 @@ display: flex; flex-direction: column; gap: 1rem; + min-height: 0; + overflow: hidden; padding: 1.25rem; background: linear-gradient(180deg, color-mix(in oklab, var(--surface-secondary) 92%, var(--accent-primary) 8%), var(--surface-secondary)); @@ -94,6 +96,9 @@ display: flex; flex-direction: column; gap: 0.35rem; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; } .settings-nav-button { @@ -112,7 +117,7 @@ .settings-nav-button:focus-visible { outline: 2px solid var(--accent-primary); - outline-offset: 2px; + outline-offset: -2px; } .settings-nav-button:hover { @@ -550,6 +555,7 @@ .settings-expansion-table { margin-bottom: 1.25rem; + border: 1px solid var(--border-base); } .settings-expansion-table-header,