diff --git a/package-lock.json b/package-lock.json index d8ab5dee..78cb9ee1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.12.1", + "version": "0.12.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.12.1", + "version": "0.12.3", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -3305,6 +3305,23 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz", + "integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tauri-apps/plugin-notification": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", @@ -11985,7 +12002,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.12.1", + "version": "0.12.3", "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", @@ -12022,7 +12039,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.12.1", + "version": "0.12.3", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -12063,7 +12080,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.12.1", + "version": "0.12.3", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -12071,7 +12088,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.12.1", + "version": "0.12.3", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", diff --git a/package.json b/package.json index 18ee0d30..6fb55909 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.12.1", + "version": "0.12.3", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index 37efb960..8d97bbe5 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.11.4", + "minServerVersion": "0.12.3", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 60509dcb..3e88adf2 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -1,4 +1,5 @@ import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron" +import fs from "fs" import type { CliProcessManager, CliStatus } from "./process-manager" let wakeLockId: number | null = null @@ -65,6 +66,24 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan return { canceled: result.canceled, paths: result.filePaths } }) + ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise => { + if (!Array.isArray(paths)) { + return [] + } + + const directories = paths.filter((value): value is string => { + if (typeof value !== "string" || value.trim().length === 0) { + return false + } + try { + return fs.statSync(value).isDirectory() + } catch { + return false + } + }) + return directories + }) + ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => { const next = Boolean(enabled) if (next) { diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 0ab3736a..75bad994 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -1,4 +1,4 @@ -const { contextBridge, ipcRenderer } = require("electron") +const { contextBridge, ipcRenderer, webUtils } = require("electron") const electronAPI = { onCliStatus: (callback) => { @@ -12,6 +12,14 @@ const electronAPI = { getCliStatus: () => ipcRenderer.invoke("cli:getStatus"), restartCli: () => ipcRenderer.invoke("cli:restart"), openDialog: (options) => ipcRenderer.invoke("dialog:open", options), + getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths), + getPathForFile: (file) => { + try { + return webUtils.getPathForFile(file) + } catch { + return null + } + }, setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), } diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 48f10b14..f3bf2a39 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.12.1", + "version": "0.12.3", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index 82ed1e6d..ad23a13d 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -4,6 +4,6 @@ "private": true, "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.2.14" + "@opencode-ai/plugin": "1.2.25" } } \ No newline at end of file diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index a0571f3b..1371298e 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.12.1", + "version": "0.12.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.12.1", + "version": "0.12.3", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index 2e9cc3ac..2d3002f5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.12.1", + "version": "0.12.3", "description": "CodeNomad Server", "license": "MIT", "author": { diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index 4a05a8e2..7fc109dc 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.12.1", + "version": "0.12.3", "private": true, "license": "MIT", "scripts": { diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 6b55b945..e193ef83 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -828,14 +828,31 @@ impl CliEntry { if dev { // Dev: plain HTTP + Vite dev server proxy. + let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL") + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + std::env::var("ELECTRON_RENDERER_URL") + .ok() + .filter(|value| !value.trim().is_empty()) + }) + .unwrap_or_else(|| "http://localhost:3000".to_string()); + let log_level = std::env::var("CLI_LOG_LEVEL") + .ok() + .map(|value| value.trim().to_lowercase()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "info".to_string()); + args.push("--https".to_string()); args.push("false".to_string()); args.push("--http".to_string()); args.push("true".to_string()); + args.push("--http-port".to_string()); + args.push("0".to_string()); args.push("--ui-dev-server".to_string()); - args.push("http://localhost:3000".to_string()); + args.push(ui_dev_server); args.push("--log-level".to_string()); - args.push("debug".to_string()); + args.push(log_level); } else { // Prod desktop: always keep loopback HTTP enabled. args.push("--https".to_string()); @@ -900,6 +917,11 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option { if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { + candidates.push(Some(dir.join("resources/server/dist/bin.js"))); + candidates.push(Some(dir.join("resources/server/dist/index.js"))); + candidates.push(Some(dir.join("resources/server/dist/server/bin.js"))); + candidates.push(Some(dir.join("resources/server/dist/server/index.js"))); + let resources = dir.join("../Resources"); candidates.push(Some(resources.join("server/dist/bin.js"))); candidates.push(Some(resources.join("server/dist/index.js"))); @@ -995,9 +1017,18 @@ fn first_existing(paths: Vec>) -> Option { } fn normalize_path(path: PathBuf) -> String { - if let Ok(clean) = path.canonicalize() { - clean.to_string_lossy().to_string() + let resolved = if let Ok(clean) = path.canonicalize() { + clean } else { - path.to_string_lossy().to_string() + path + }; + + let rendered = resolved.to_string_lossy().to_string(); + if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") { + format!("\\\\{}", stripped) + } else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") { + stripped.to_string() + } else { + rendered } } diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 06505675..ac402932 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -35,7 +35,6 @@ fn cli_restart(app: AppHandle, state: tauri::State) -> Result bool { cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok() } @@ -46,7 +45,10 @@ fn should_allow_internal(url: &Url) -> bool { // On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`. // This must be treated as an internal origin or the navigation guard will // redirect it to the system browser and the app will appear blank. - "http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")), + "http" | "https" => matches!( + url.host_str(), + Some("127.0.0.1" | "localhost" | "tauri.localhost") + ), _ => false, } } @@ -66,6 +68,39 @@ fn intercept_navigation(webview: &Webview, url: &Url) -> bool { false } +fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec { + paths + .iter() + .filter_map(|path| match std::fs::metadata(path) { + Ok(metadata) if metadata.is_dir() => Some(path.to_string_lossy().to_string()), + _ => None, + }) + .collect() +} + +fn emit_window_event(app_handle: &AppHandle, window_label: &str, event_name: &str) { + if let Some(window) = app_handle.get_webview_window(window_label) { + let _ = window.emit(event_name, ()); + } +} + +fn emit_folder_drop_event( + app_handle: &AppHandle, + window_label: &str, + event_name: &str, + paths: &[std::path::PathBuf], +) { + let directories = collect_directory_paths(paths); + + if directories.is_empty() { + return; + } + + if let Some(window) = app_handle.get_webview_window(window_label) { + let _ = window.emit(event_name, json!({ "paths": directories })); + } +} + fn main() { let navigation_guard: TauriPlugin = PluginBuilder::new("external-link-guard") .on_navigation(|webview, url| intercept_navigation(webview, url)) @@ -187,6 +222,27 @@ fn main() { app.exit(0); }); } + tauri::RunEvent::WindowEvent { + label, + event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Enter { paths, .. }), + .. + } => { + emit_folder_drop_event(&app_handle, &label, "desktop:folder-drag-enter", &paths); + } + tauri::RunEvent::WindowEvent { + label, + event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }), + .. + } => { + emit_folder_drop_event(&app_handle, &label, "desktop:folder-drop", &paths); + } + tauri::RunEvent::WindowEvent { + label, + event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Leave), + .. + } => { + emit_window_event(&app_handle, &label, "desktop:folder-drag-leave"); + } tauri::RunEvent::WindowEvent { event: tauri::WindowEvent::CloseRequested { api, .. }, .. @@ -234,13 +290,16 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { "new_instance", "New Instance", true, - Some("CmdOrCtrl+N") + Some("CmdOrCtrl+N"), )?; - + let file_menu = SubmenuBuilder::new(app, "File") .item(&new_instance_item) .separator() - .text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" }) + .text( + if is_mac { "close" } else { "quit" }, + if is_mac { "Close" } else { "Quit" }, + ) .build()?; submenus.push(file_menu); @@ -263,7 +322,6 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { .text("force_reload", "Force Reload") .text("toggle_devtools", "Toggle Developer Tools") .separator() - .separator() .text("toggle_fullscreen", "Toggle Full Screen") .build()?; @@ -277,9 +335,12 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { submenus.push(window_menu); // Build the main menu with all submenus - let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect(); + let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus + .iter() + .map(|s| s as &dyn tauri::menu::IsMenuItem<_>) + .collect(); let menu = MenuBuilder::new(app).items(&submenu_refs).build()?; - + app.set_menu(menu)?; Ok(()) } diff --git a/packages/ui/package.json b/packages/ui/package.json index d8fbf761..84f5df2b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.12.1", + "version": "0.12.3", "private": true, "license": "MIT", "type": "module", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index cee2ab06..4420af92 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -9,7 +9,7 @@ import { showConfirmDialog } from "./stores/alerts" import InstanceTabs from "./components/instance-tabs" import InstanceDisconnectedModal from "./components/instance-disconnected-modal" import InstanceShell from "./components/instance/instance-shell2" -import { RemoteAccessOverlay } from "./components/remote-access-overlay" +import { SettingsScreen } from "./components/settings-screen" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { initMarkdown } from "./lib/markdown" import { initGithubStars } from "./stores/github-stars" @@ -54,6 +54,7 @@ import { } from "./stores/sessions" import { getInstanceSessionIndicatorStatus } from "./stores/session-status" +import { openSettings } from "./stores/settings-screen" const log = getLogger("actions") @@ -77,8 +78,6 @@ const App: Component = () => { setToolInputsVisibility, } = useConfig() const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) - const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) - const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const phoneQuery = useMediaQuery("(max-width: 767px)") @@ -252,7 +251,6 @@ const App: Component = () => { clearLaunchError() const instanceId = await createInstance(folderPath, selectedBinary) setShowFolderSelection(false) - setIsAdvancedSettingsOpen(false) log.info("Created instance", { instanceId, @@ -274,7 +272,7 @@ const App: Component = () => { function handleLaunchErrorAdvanced() { clearLaunchError() - setIsAdvancedSettingsOpen(true) + openSettings("opencode") } function handleNewInstanceRequest() { @@ -487,7 +485,6 @@ const App: Component = () => { onSelect={setActiveInstanceId} onClose={handleCloseInstance} onNew={handleNewInstanceRequest} - onOpenRemoteAccess={() => setRemoteAccessOpen(true)} /> @@ -533,10 +530,6 @@ const App: Component = () => { setIsAdvancedSettingsOpen(true)} - onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)} - onOpenRemoteAccess={() => setRemoteAccessOpen(true)} /> @@ -546,12 +539,8 @@ const App: Component = () => { setIsAdvancedSettingsOpen(true)} - onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)} onClose={() => { setShowFolderSelection(false) - setIsAdvancedSettingsOpen(false) clearLaunchError() }} /> @@ -559,7 +548,7 @@ const App: Component = () => { - setRemoteAccessOpen(false)} /> + diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 5406b055..ad833fed 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -2,16 +2,17 @@ import { Select } from "@kobalte/core/select" import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid" import { useConfig } from "../stores/preferences" -import AdvancedSettingsModal from "./advanced-settings-modal" import DirectoryBrowserDialog from "./directory-browser-dialog" import Kbd from "./kbd" -import { ThemeModeToggle } from "./theme-mode-toggle" import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions" +import { useFolderDrop } from "../lib/hooks/use-folder-drop" 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 { showAlertDialog } from "../stores/alerts" +import { openSettings, settingsOpen } from "../stores/settings-screen" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href @@ -19,15 +20,11 @@ const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).h interface FolderSelectionViewProps { onSelectFolder: (folder: string, binaryPath?: string) => void isLoading?: boolean - advancedSettingsOpen?: boolean - onAdvancedSettingsOpen?: () => void - onAdvancedSettingsClose?: () => void - onOpenRemoteAccess?: () => void onClose?: () => void } const FolderSelectionView: Component = (props) => { - const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig() + const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig() const { t, locale } = useI18n() const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") @@ -193,6 +190,31 @@ const FolderSelectionView: Component = (props) => { }) }) + function dropTargetBlocked() { + return isLoading() || isFolderBrowserOpen() || settingsOpen() + } + + function showInvalidFolderDropAlert() { + showAlertDialog(t("folderSelection.drop.invalidMessage"), { + title: t("folderSelection.drop.invalidTitle"), + variant: "warning", + }) + } + + + const folderDrop = useFolderDrop({ + enabled: () => !dropTargetBlocked(), + onInvalidDrop: showInvalidFolderDropAlert, + onDrop: async (paths) => { + const firstPath = paths[0] + if (!firstPath) { + showInvalidFolderDropAlert() + return + } + handleFolderSelect(firstPath) + }, + }) + function formatRelativeTime(timestamp: number): string { const seconds = Math.floor((Date.now() - timestamp) / 1000) const minutes = Math.floor(seconds / 60) @@ -237,11 +259,6 @@ const FolderSelectionView: Component = (props) => { handleFolderSelect(path) } - function handleBinaryChange(binary: string) { - - setSelectedBinary(binary) - } - function handleRemove(path: string, e?: Event) { if (isLoading()) return e?.stopPropagation() @@ -317,6 +334,10 @@ const FolderSelectionView: Component = (props) => {
= (props) => {
- - - - + +
- {/* Advanced settings section */} + {/* OpenCode settings section */}
- @@ -619,16 +648,17 @@ const FolderSelectionView: Component = (props) => {
+ + + - props.onAdvancedSettingsClose?.()} - selectedBinary={selectedBinary()} - onBinaryChange={handleBinaryChange} - isLoading={props.isLoading} - /> - @@ -17,13 +16,11 @@ interface InstanceTabsProps { onSelect: (instanceId: string) => void onClose: (instanceId: string) => void onNew: () => void - onOpenRemoteAccess?: () => void } const InstanceTabs: Component = (props) => { const { t } = useI18n() const { preferences } = useConfig() - const [notificationsOpen, setNotificationsOpen] = createSignal(false) const notificationsSupported = createMemo(() => isOsNotificationSupportedSync()) const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled)) @@ -33,8 +30,10 @@ const InstanceTabs: Component = (props) => { }) const notificationTitle = createMemo(() => { - if (!notificationsSupported()) return "Notifications unsupported" - return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled" + if (!notificationsSupported()) return t("settings.notifications.status.unsupported") + return notificationsEnabled() + ? t("settings.notifications.status.enabled") + : t("settings.notifications.status.disabled") }) return ( @@ -72,32 +71,35 @@ const InstanceTabs: Component = (props) => { /> - + - - - - + - - setNotificationsOpen(false)} /> ) diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 9b518435..1d59cf88 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -578,7 +578,6 @@ export default function MessageBlock(props: MessageBlockProps) { const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId) - const isDeleteMessageHovered = () => { const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState) @@ -1290,12 +1289,6 @@ function ReasoningCard(props: ReasoningCardProps) { const [deletingUpTo, setDeletingUpTo] = createSignal(false) const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) - let headerEl: HTMLDivElement | undefined - let actionsEl: HTMLDivElement | undefined - let primaryEl: HTMLSpanElement | undefined - let metaMeasureEl: HTMLSpanElement | undefined - const [showMetaInline, setShowMetaInline] = createSignal(true) - createEffect(() => { setExpanded(Boolean(props.defaultExpanded)) }) @@ -1323,33 +1316,6 @@ function ReasoningCard(props: ReasoningCardProps) { const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier())) - const updateMetaLayout = () => { - if (!hasMeta()) return - if (!headerEl || !actionsEl || !primaryEl || !metaMeasureEl) return - - const headerWidth = headerEl.getBoundingClientRect().width - const actionsWidth = actionsEl.getBoundingClientRect().width - const primaryWidth = primaryEl.getBoundingClientRect().width - const metaWidth = metaMeasureEl.getBoundingClientRect().width - - const availableLeft = Math.max(0, headerWidth - actionsWidth - 12) - setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft) - } - - createEffect(() => { - if (!hasMeta() || typeof ResizeObserver === "undefined") { - setShowMetaInline(true) - return - } - - updateMetaLayout() - const observer = new ResizeObserver(() => updateMetaLayout()) - if (headerEl) observer.observe(headerEl) - if (actionsEl) observer.observe(actionsEl) - if (primaryEl) observer.observe(primaryEl) - onCleanup(() => observer.disconnect()) - }) - const reasoningText = () => { const part = props.part as any if (!part) return "" @@ -1428,7 +1394,7 @@ function ReasoningCard(props: ReasoningCardProps) { return (
-
(headerEl = el)}> +
-
(actionsEl = el)}> +
- +
diff --git a/packages/ui/src/components/settings-screen.tsx b/packages/ui/src/components/settings-screen.tsx new file mode 100644 index 00000000..f3776cbd --- /dev/null +++ b/packages/ui/src/components/settings-screen.tsx @@ -0,0 +1,107 @@ +import { Dialog } from "@kobalte/core/dialog" +import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid" +import { createMemo, For, type Component } from "solid-js" +import { useI18n } from "../lib/i18n" +import { + activeSettingsSection, + closeSettings, + settingsOpen, + setActiveSettingsSection, + type SettingsSectionId, +} from "../stores/settings-screen" +import { AppearanceSettingsSection } from "./settings/appearance-settings-section" +import { NotificationsSettingsSection } from "./settings/notifications-settings-section" +import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" +import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" + +export const SettingsScreen: Component = () => { + const { t } = useI18n() + + const sections = createMemo(() => [ + { id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") }, + { id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") }, + { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") }, + { id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") }, + ]) + + const renderSection = () => { + switch (activeSettingsSection()) { + case "notifications": + return + case "remote": + return + case "opencode": + return + case "appearance": + default: + return + } + } + + return ( + !open && closeSettings()}> + + +
+ + {t("settings.title")} + + + +
+
+
+

{t("settings.content.eyebrow")}

+

+ {sections().find((section) => section.id === activeSettingsSection())?.label} +

+
+ +
+ +
{renderSection()}
+
+
+
+
+
+ ) +} diff --git a/packages/ui/src/components/settings/appearance-settings-section.tsx b/packages/ui/src/components/settings/appearance-settings-section.tsx new file mode 100644 index 00000000..7a634160 --- /dev/null +++ b/packages/ui/src/components/settings/appearance-settings-section.tsx @@ -0,0 +1,270 @@ +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 } from "../../stores/preferences" +import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry" + +const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [ + { value: "system", icon: Laptop }, + { value: "light", icon: Sun }, + { value: "dark", icon: Moon }, +] + +export const AppearanceSettingsSection: Component = () => { + const { t } = useI18n() + const { themeMode, setThemeMode } = useTheme() + const { + preferences, + updatePreferences, + toggleShowThinkingBlocks, + toggleKeyboardShortcutHints, + toggleShowTimelineTools, + toggleUsageMetrics, + toggleAutoCleanupBlankSessions, + togglePromptSubmitOnEnter, + setDiffViewMode, + setToolOutputExpansion, + setDiagnosticsExpansion, + setThinkingBlocksExpansion, + setToolInputsVisibility, + } = useConfig() + + const behaviorSettings = createMemo(() => + getBehaviorSettings({ + preferences, + updatePreferences, + toggleShowThinkingBlocks, + toggleKeyboardShortcutHints, + toggleShowTimelineTools, + toggleUsageMetrics, + toggleAutoCleanupBlankSessions, + togglePromptSubmitOnEnter, + setDiffViewMode, + setToolOutputExpansion, + setDiagnosticsExpansion, + setThinkingBlocksExpansion, + setToolInputsVisibility, + }), + ) + + 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 } + + 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") + } + + 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")}

+
+ {t("settings.scope.device")} +
+ +
+ {(setting) => } +
+
+
+ ) +} diff --git a/packages/ui/src/components/settings/notifications-settings-section.tsx b/packages/ui/src/components/settings/notifications-settings-section.tsx new file mode 100644 index 00000000..3dc01eb4 --- /dev/null +++ b/packages/ui/src/components/settings/notifications-settings-section.tsx @@ -0,0 +1,227 @@ +import { Show, createEffect, createResource, type Component } from "solid-js" +import { Bell } from "lucide-solid" +import { showToastNotification } from "../../lib/notifications" +import { + getOsNotificationCapability, + requestOsNotificationPermission, + type OsNotificationPermission, +} from "../../lib/os-notifications" +import { useConfig } from "../../stores/preferences" +import { useI18n } from "../../lib/i18n" + +function formatPermissionLabel(permission: OsNotificationPermission, t: ReturnType["t"]): string { + switch (permission) { + case "granted": + return t("settings.notifications.permission.granted") + case "denied": + return t("settings.notifications.permission.denied") + case "default": + return t("settings.notifications.permission.default") + case "unsupported": + return t("settings.notifications.permission.unsupported") + default: + return String(permission) + } +} + +export const NotificationsSettingsSection: Component = () => { + const { t } = useI18n() + const { preferences, updatePreferences } = useConfig() + const [capability, { refetch }] = createResource(() => getOsNotificationCapability()) + + createEffect(() => { + void refetch() + }) + + const handleEnableToggle = async (enabled: boolean) => { + if (!enabled) { + updatePreferences({ osNotificationsEnabled: false }) + return + } + + const cap = capability() + if (cap && !cap.supported) { + showToastNotification({ + title: t("settings.section.notifications.title"), + message: cap.info ?? t("settings.notifications.messages.unsupportedEnvironment"), + variant: "warning", + }) + updatePreferences({ osNotificationsEnabled: false }) + return + } + + const permission = await requestOsNotificationPermission() + if (permission !== "granted") { + showToastNotification({ + title: t("settings.section.notifications.title"), + message: + permission === "denied" + ? t("settings.notifications.messages.permissionDenied") + : t("settings.notifications.messages.permissionNotGranted"), + variant: "warning", + }) + updatePreferences({ osNotificationsEnabled: false }) + return + } + + updatePreferences({ osNotificationsEnabled: true }) + void refetch() + } + + const handleRequestPermission = async () => { + const cap = capability() + if (cap && !cap.supported) { + showToastNotification({ + title: t("settings.section.notifications.title"), + message: cap.info ?? t("settings.notifications.messages.unsupportedGeneral"), + variant: "warning", + }) + return + } + + const permission = await requestOsNotificationPermission() + if (permission === "granted") { + showToastNotification({ + title: t("settings.section.notifications.title"), + message: t("settings.notifications.messages.permissionGranted"), + variant: "success", + duration: 6000, + }) + void refetch() + return + } + + showToastNotification({ + title: t("settings.section.notifications.title"), + message: + permission === "denied" + ? t("settings.notifications.messages.permissionRequestDenied") + : t("settings.notifications.messages.permissionNotGranted"), + variant: "warning", + }) + void refetch() + } + + const supported = () => capability()?.supported ?? false + const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported", t) + const infoMessage = () => capability()?.info + + return ( +
+
+
+
+ +
+

{t("settings.notifications.sessionStatus.title")}

+

{t("settings.notifications.sessionStatus.subtitle")}

+
+
+ {t("settings.scope.device")} +
+ +
+
+
+
{t("settings.notifications.enable.title")}
+
+ {t("settings.notifications.enable.permission", { permission: permissionLabel() })} +
+
+ +
+ + +
+
+
{t("settings.notifications.requestPermission.title")}
+
{t("settings.notifications.requestPermission.subtitle")}
+
+ +
+
+ +
+
+
{t("settings.notifications.allowVisible.title")}
+
{t("settings.notifications.allowVisible.subtitle")}
+
+ +
+ + +
{infoMessage()}
+
+ + +
{t("settings.notifications.unsupportedNote")}
+
+
+
+ +
+
+
+

{t("settings.notifications.events.title")}

+

{t("settings.notifications.events.subtitle")}

+
+ {t("settings.scope.device")} +
+ +
+
+
+
{t("settings.notifications.events.needsInput")}
+
+ +
+ +
+
+
{t("settings.notifications.events.idle")}
+
+ +
+
+
+
+ ) +} 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 00000000..8af940eb --- /dev/null +++ b/packages/ui/src/components/settings/opencode-settings-section.tsx @@ -0,0 +1,52 @@ +import { createEffect, createSignal, type Component } from "solid-js" +import { Terminal } from "lucide-solid" +import OpenCodeBinarySelector from "../opencode-binary-selector" +import EnvironmentVariablesEditor from "../environment-variables-editor" +import { useConfig } from "../../stores/preferences" +import { useI18n } from "../../lib/i18n" + +export const OpenCodeSettingsSection: 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")} +
+ + +
+ +
+
+
+

{t("advancedSettings.environmentVariables.title")}

+

{t("advancedSettings.environmentVariables.subtitle")}

+
+ {t("settings.scope.server")} +
+ +
+
+ ) +} diff --git a/packages/ui/src/components/settings/remote-access-settings-section.tsx b/packages/ui/src/components/settings/remote-access-settings-section.tsx new file mode 100644 index 00000000..049036dd --- /dev/null +++ b/packages/ui/src/components/settings/remote-access-settings-section.tsx @@ -0,0 +1,401 @@ +import { Switch } from "@kobalte/core/switch" +import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js" +import { toDataURL } from "qrcode" +import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" +import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types" +import { serverApi } from "../../lib/api-client" +import { restartCli } from "../../lib/native/cli" +import { serverSettings, setListeningMode } from "../../stores/preferences" +import { showConfirmDialog } from "../../stores/alerts" +import { getLogger } from "../../lib/logger" +import { useI18n } from "../../lib/i18n" + +const log = getLogger("actions") + +export const RemoteAccessSettingsSection: Component = () => { + const { t } = useI18n() + const [meta, setMeta] = createSignal(null) + const [authStatus, setAuthStatus] = createSignal<{ + authenticated: boolean + username?: string + passwordUserProvided?: boolean + } | null>(null) + const [loading, setLoading] = createSignal(false) + const [applyingListeningMode, setApplyingListeningMode] = createSignal(false) + const [qrCodes, setQrCodes] = createSignal>({}) + const [expandedUrl, setExpandedUrl] = createSignal(null) + const [error, setError] = createSignal(null) + const [passwordFormOpen, setPasswordFormOpen] = createSignal(false) + const [passwordValue, setPasswordValue] = createSignal("") + const [passwordConfirm, setPasswordConfirm] = createSignal("") + const [passwordError, setPasswordError] = createSignal(null) + const [savingPassword, setSavingPassword] = createSignal(false) + + const addresses = createMemo(() => meta()?.addresses ?? []) + const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) + const allowExternalConnections = createMemo(() => currentMode() === "all") + const displayAddresses = createMemo(() => { + const list = addresses() + if (!allowExternalConnections()) return [] + return list.filter((address) => address.scope !== "loopback") + }) + + const refreshMeta = async () => { + setLoading(true) + setError(null) + setPasswordError(null) + try { + const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()]) + setMeta(metaResult) + setAuthStatus(authResult) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + } + + onMount(() => { + void refreshMeta() + }) + + const toggleExpanded = async (url: string) => { + if (expandedUrl() === url) { + setExpandedUrl(null) + return + } + setExpandedUrl(url) + if (!qrCodes()[url]) { + try { + const dataUrl = await toDataURL(url, { margin: 1, scale: 4 }) + setQrCodes((prev) => ({ ...prev, [url]: dataUrl })) + } catch (err) { + log.error("Failed to generate QR code", err) + } + } + } + + const handleAllowConnectionsChange = async (checked: boolean) => { + const targetMode: "local" | "all" = checked ? "all" : "local" + if (targetMode === currentMode() || applyingListeningMode()) return + + const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), { + title: checked + ? t("remoteAccess.listeningMode.restartConfirm.title.all") + : t("remoteAccess.listeningMode.restartConfirm.title.local"), + variant: "warning", + confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"), + cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"), + }) + + if (!confirmed) return + + setApplyingListeningMode(true) + setError(null) + try { + await setListeningMode(targetMode) + const restarted = await restartCli() + if (!restarted) { + setError(t("remoteAccess.restart.errorManual")) + } else { + setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev)) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setApplyingListeningMode(false) + } + + void refreshMeta() + } + + const handleOpenUrl = (url: string) => { + try { + window.open(url, "_blank", "noopener,noreferrer") + } catch (err) { + log.error("Failed to open URL", err) + } + } + + const handleSubmitPassword = async () => { + setPasswordError(null) + + const next = passwordValue() + const confirm = passwordConfirm() + if (next.trim().length < 8) { + setPasswordError(t("remoteAccess.password.error.tooShort")) + return + } + if (next !== confirm) { + setPasswordError(t("remoteAccess.password.error.mismatch")) + return + } + + setSavingPassword(true) + try { + const result = await serverApi.setServerPassword(next) + setAuthStatus({ + authenticated: true, + username: result.username, + passwordUserProvided: result.passwordUserProvided, + }) + setPasswordValue("") + setPasswordConfirm("") + setPasswordFormOpen(false) + } catch (err) { + setPasswordError(err instanceof Error ? err.message : String(err)) + } finally { + setSavingPassword(false) + } + } + + return ( +
+
+
+
+ +
+

{t("remoteAccess.sections.listeningMode.label")}

+

{t("remoteAccess.sections.listeningMode.help")}

+
+
+
+ {t("settings.scope.server")} + +
+
+ + void handleAllowConnectionsChange(nextChecked)} + disabled={loading() || applyingListeningMode()} + > + + + + {allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")} + + + +
+ {t("remoteAccess.toggle.title")} + + {allowExternalConnections() + ? t("remoteAccess.toggle.caption.all") + : t("remoteAccess.toggle.caption.local")} + +
+
+ +

{t("remoteAccess.toggle.note")}

+
+ +
+
+
+ +
+

{t("remoteAccess.sections.serverPassword.label")}

+

{t("remoteAccess.sections.serverPassword.help")}

+
+
+ {t("settings.scope.server")} +
+ + {t("remoteAccess.authStatus.unavailable")}
} + > +
+

{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}

+

+ {authStatus()!.passwordUserProvided + ? t("remoteAccess.password.status.set") + : t("remoteAccess.password.status.unset")} +

+ +
+ +
+ + +
+ + setPasswordValue(event.currentTarget.value)} + placeholder={t("remoteAccess.password.form.placeholder")} + /> +
+
+ + setPasswordConfirm(event.currentTarget.value)} + /> +
+ + + {(message) =>
{message()}
} +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+

{t("remoteAccess.sections.addresses.label")}

+

{t("remoteAccess.sections.addresses.help")}

+
+
+ {t("settings.scope.server")} +
+ + {t("remoteAccess.addresses.loading")}
}> + {error()}
}> + 0 || meta()?.localUrl} + fallback={
{t("remoteAccess.addresses.none")}
} + > +
+ + {(url) => { + const value = () => url() + const expandedState = () => expandedUrl() === value() + const qr = () => qrCodes()[value()] + return ( +
+
+
+

{value()}

+

{t("remoteAccess.address.scope.loopback")}

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+ + + {(address) => { + const url = address.remoteUrl + const expandedState = () => expandedUrl() === url + const qr = () => qrCodes()[url] + const scopeLabel = () => + address.scope === "external" + ? t("remoteAccess.address.scope.network") + : address.scope === "loopback" + ? t("remoteAccess.address.scope.loopback") + : t("remoteAccess.address.scope.internal") + + return ( +
+
+
+

{url}

+

+ {address.family.toUpperCase()} - {scopeLabel()} - {address.ip} +

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+
+
+
+
+
+
+ ) +} diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 1b2de3a3..567f6fd9 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -1,5 +1,5 @@ import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js" -import VirtualItem from "./virtual-item" +import VirtualItem, { type VirtualItemHeightChangeMeta } from "./virtual-item" const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48 const USER_SCROLL_INTENT_WINDOW_MS = 600 @@ -374,7 +374,14 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } function handleContentRendered() { - scheduleAnchorScroll() + if (autoScroll() && !anchorLock()) { + scheduleAutoPinToBottom() + return + } + if (anchorLock() && !autoScroll()) { + scheduleAnchorCorrection() + return + } } function handleScroll() { @@ -470,9 +477,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const bottomAfter = rect.bottom const bottomBefore = bottomAfter - delta const wasAboveViewport = bottomBefore < containerRect.top - if (!wasAboveViewport) { - return - } + if (!wasAboveViewport) return const next = (pendingScrollCompensations.get(key) ?? 0) + delta pendingScrollCompensations.set(key, next) @@ -516,25 +521,51 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } let pendingAutoPin = false + let pendingAutoPinFrame: number | null = null + + function clearPendingAutoPinFrame() { + if (pendingAutoPinFrame !== null) { + cancelAnimationFrame(pendingAutoPinFrame) + pendingAutoPinFrame = null + } + } + + function applyAutoPinToBottom() { + if (!containerRef) return false + if (!autoScroll()) return false + if (anchorLock()) return false + + const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0) + if (containerRef.scrollTop !== maxScrollTop) { + containerRef.scrollTop = maxScrollTop + lastKnownScrollTop = maxScrollTop + } + return true + } + function scheduleAutoPinToBottom() { if (!containerRef) return if (pendingAutoPin) return pendingAutoPin = true + clearPendingAutoPinFrame() const gen = scrollCompensationGen - // Flush in a microtask so adjustments land before the next paint. + // Flush in a microtask so adjustments land before the next paint, + // then re-apply on the next two frames to catch deferred layout. queueMicrotask(() => { if (gen !== scrollCompensationGen) return pendingAutoPin = false - if (!containerRef) return - if (!autoScroll()) return - if (anchorLock()) return - - const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0) - if (containerRef.scrollTop !== maxScrollTop) { - containerRef.scrollTop = maxScrollTop - lastKnownScrollTop = maxScrollTop - } + if (!applyAutoPinToBottom()) return + pendingAutoPinFrame = requestAnimationFrame(() => { + pendingAutoPinFrame = null + if (gen !== scrollCompensationGen) return + if (!applyAutoPinToBottom()) return + pendingAutoPinFrame = requestAnimationFrame(() => { + pendingAutoPinFrame = null + if (gen !== scrollCompensationGen) return + applyAutoPinToBottom() + }) + }) }) } @@ -623,6 +654,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { pendingScrollCompensationScheduled = false pendingScrollCompensations = new Map() pendingAutoPin = false + clearPendingAutoPinFrame() suppressAutoScrollOnce = false pendingActiveScroll = false @@ -713,7 +745,13 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { suppressAutoScrollOnce = false return } - if (autoScroll()) scheduleAnchorScroll(true) + if (autoScroll()) { + scheduleAutoPinToBottom() + return + } + if (anchorLock() && !autoScroll()) { + scheduleAnchorCorrection() + } }) // Drop anchor lock if the anchored key is removed. @@ -820,6 +858,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { scrollCompensationGen += 1 pendingScrollCompensationScheduled = false pendingScrollCompensations = new Map() + clearPendingAutoPinFrame() clearScrollToBottomFrames() if (detachScrollIntentListeners) { detachScrollIntentListeners() @@ -883,6 +922,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const anchorId = () => getAnchorId(key()) const overscanPx = props.overscanPx ?? 800 const suspendMeasurements = () => measurementsSuspended() || !isActive() + const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll() return ( (props: VirtualFollowListProps) { scrollContainer={scrollElement} threshold={overscanPx} placeholderClass="message-stream-placeholder" - virtualizationEnabled={virtualizationEnabled} + virtualizationEnabled={itemVirtualizationEnabled} suspendMeasurements={suspendMeasurements} - onHeightChange={(nextHeight, previousHeight) => { + onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => { const delta = nextHeight - previousHeight // Follow mode: keep the viewport pinned to the bottom as @@ -913,12 +953,11 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // while scrolling upward, compensate scrollTop so visible // content stays stable. if (delta) { + if (meta.isStaleCacheCorrection) return scheduleScrollCompensation(key(), delta) } }} - > - {() => props.renderItem(item(), index)} - + >{() => props.renderItem(item(), index)} ) }} diff --git a/packages/ui/src/components/virtual-item.tsx b/packages/ui/src/components/virtual-item.tsx index 5c0c6c42..6a06cd29 100644 --- a/packages/ui/src/components/virtual-item.tsx +++ b/packages/ui/src/components/virtual-item.tsx @@ -167,10 +167,17 @@ interface VirtualItemProps { forceVisible?: Accessor suspendMeasurements?: Accessor onMeasured?: () => void - onHeightChange?: (nextHeight: number, previousHeight: number) => void + onHeightChange?: (nextHeight: number, previousHeight: number, meta: VirtualItemHeightChangeMeta) => void id?: string } +export interface VirtualItemHeightChangeMeta { + source: "initial-visible-measure" | "resize" + previousCachedHeight: number | null + isStaleCacheCorrection: boolean + wasHidden: boolean +} + export default function VirtualItem(props: VirtualItemProps) { const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children) const cachedHeight = sizeCache.get(props.cacheKey) @@ -183,10 +190,11 @@ export default function VirtualItem(props: VirtualItemProps) { // When content first mounts, onHeightChange deltas should reflect the DOM's // placeholder height (not 0), otherwise scroll compensation can overshoot. const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight()) - const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined) let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0) let pendingVisibility: boolean | null = null let visibilityFrame: number | null = null + let awaitingVisibleMeasurement = true + let lastMeasurementWhileHidden = true const flushVisibility = () => { if (visibilityFrame !== null) { cancelAnimationFrame(visibilityFrame) @@ -210,14 +218,14 @@ export default function VirtualItem(props: VirtualItemProps) { } const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true) const measurementsSuspended = () => Boolean(props.suspendMeasurements?.()) + const forceVisible = () => Boolean(props.forceVisible?.()) const shouldHideContent = createMemo(() => { - if (props.forceVisible?.()) return false + if (forceVisible()) return false if (!virtualizationEnabled()) return false return !isIntersecting() }) - - let wrapperRef: HTMLDivElement | undefined - + + let wrapperRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined let resizeObserver: ResizeObserver | undefined @@ -230,6 +238,17 @@ export default function VirtualItem(props: VirtualItemProps) { } } + function scheduleVisibleMeasurements() { + if (shouldHideContent() || measurementsSuspended()) return + if (!contentRef) return + queueMicrotask(() => { + if (shouldHideContent() || measurementsSuspended()) return + if (!contentRef) return + updateMeasuredHeight() + setupResizeObserver() + }) + } + function cleanupIntersectionObserver() { if (intersectionCleanup) { intersectionCleanup() @@ -237,13 +256,24 @@ export default function VirtualItem(props: VirtualItemProps) { } } - function persistMeasurement(nextHeight: number) { + function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) { if (!Number.isFinite(nextHeight) || nextHeight < 0) { return } const before = measuredHeight() const normalized = nextHeight - const previous = sizeCache.get(props.cacheKey) ?? measuredHeight() + const previousCachedHeight = sizeCache.get(props.cacheKey) ?? null + const previous = previousCachedHeight ?? measuredHeight() + const measurementMeta: VirtualItemHeightChangeMeta = { + source: meta?.source ?? "resize", + previousCachedHeight, + isStaleCacheCorrection: + (meta?.source ?? "resize") === "initial-visible-measure" && + previousCachedHeight !== null && + normalized > 0 && + Math.abs(normalized - previousCachedHeight) > 1, + wasHidden: meta?.wasHidden ?? shouldHideContent(), + } // Only keep the previous measurement when the element reports 0 height. // Allow shrinkage so placeholder height matches real content height; // keeping the max height can cause mount/unmount jitter near the @@ -254,34 +284,40 @@ export default function VirtualItem(props: VirtualItemProps) { hasReportedMeasurement = true props.onMeasured?.() } - setHasMeasured(true) sizeCache.set(props.cacheKey, previous) setMeasuredHeight(previous) - if (previous !== before) props.onHeightChange?.(previous, before) + if (previous !== before) props.onHeightChange?.(previous, before, measurementMeta) return } if (normalized > 0) { sizeCache.set(props.cacheKey, normalized) - setHasMeasured(true) if (!hasReportedMeasurement) { hasReportedMeasurement = true props.onMeasured?.() } } setMeasuredHeight(normalized) - if (normalized !== before) props.onHeightChange?.(normalized, before) + if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta) } function updateMeasuredHeight() { - if (!contentRef || measurementsSuspended()) return + if (!contentRef) return + if (measurementsSuspended()) return // Prefer subpixel-accurate height for scroll compensation. // offsetHeight rounds to integers which can accumulate error. const rect = contentRef.getBoundingClientRect() const next = Math.max(0, Math.round(rect.height * 2) / 2) - if (next === measuredHeight()) return - persistMeasurement(next) + const currentMeasured = measuredHeight() + const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize" + const wasHidden = lastMeasurementWhileHidden + if (measurementSource === "initial-visible-measure") { + awaitingVisibleMeasurement = false + lastMeasurementWhileHidden = false + } + if (next === currentMeasured) return + persistMeasurement(next, { source: measurementSource, wasHidden }) } - + function setupResizeObserver() { if (!contentRef || measurementsSuspended()) return cleanupResizeObserver() @@ -377,30 +413,29 @@ export default function VirtualItem(props: VirtualItemProps) { cleanupResizeObserver() } } - - createEffect(() => { - if (shouldHideContent() || measurementsSuspended()) { + const hidden = shouldHideContent() + if (hidden) { + awaitingVisibleMeasurement = true + lastMeasurementWhileHidden = true + } + if (hidden || measurementsSuspended()) { cleanupResizeObserver() - } else if (contentRef) { - queueMicrotask(() => { - updateMeasuredHeight() - setupResizeObserver() - }) + } + if (!hidden && !measurementsSuspended() && contentRef) { + scheduleVisibleMeasurements() } }) - + createEffect(() => { const key = props.cacheKey const cached = sizeCache.get(key) if (cached !== undefined) { setMeasuredHeight(cached) - setHasMeasured(true) } else { setMeasuredHeight(fallbackPlaceholderHeight()) - setHasMeasured(false) } }) @@ -418,7 +453,7 @@ export default function VirtualItem(props: VirtualItemProps) { } return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT }) - + onCleanup(() => { cleanupResizeObserver() cleanupIntersectionObserver() diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index 4105e274..a74fc840 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -14,7 +14,7 @@ import { getLogger } from "../logger" import { requestData } from "../opencode-api" import { emitSessionSidebarRequest } from "../session-sidebar-events" import { tGlobal } from "../i18n" -import { runtimeEnv } from "../runtime-env" +import { registerBehaviorCommands } from "../settings/behavior-registry" const log = getLogger("actions") @@ -427,178 +427,19 @@ export function useCommands(options: UseCommandsOptions) { }, }) - commandRegistry.register({ - id: "prompt-submit-shortcut", - label: () => - options.preferences().promptSubmitOnEnter - ? tGlobal("commands.promptSubmitShortcut.label.swapped") - : tGlobal("commands.promptSubmitShortcut.label.default"), - description: () => tGlobal("commands.promptSubmitShortcut.description"), - category: "Input & Focus", - keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"), - action: options.togglePromptSubmitOnEnter, - }) - - commandRegistry.register({ - id: "thinking", - label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"), - description: () => tGlobal("commands.thinkingBlocks.description"), - category: "System", - keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")], - action: options.toggleShowThinkingBlocks, - }) - - commandRegistry.register({ - id: "timeline-tools", - label: () => tGlobal(options.preferences().showTimelineTools ? "commands.timelineToolCalls.label.hide" : "commands.timelineToolCalls.label.show"), - description: () => tGlobal("commands.timelineToolCalls.description"), - category: "System", - keywords: () => splitKeywords("commands.timelineToolCalls.keywords"), - action: options.toggleShowTimelineTools, - }) - - commandRegistry.register({ - id: "keyboard-shortcut-hints", - label: () => - tGlobal( - options.preferences().showKeyboardShortcutHints - ? "commands.keyboardShortcutHints.label.hide" - : "commands.keyboardShortcutHints.label.show", - ), - description: () => - tGlobal( - runtimeEnv.host === "web" - ? "commands.keyboardShortcutHints.description.disabledWeb" - : "commands.keyboardShortcutHints.description", - ), - category: "System", - keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"), - disabled: () => runtimeEnv.host === "web", - action: options.toggleKeyboardShortcutHints, - }) - - commandRegistry.register({ - id: "thinking-default-visibility", - label: () => { - const mode = options.preferences().thinkingBlocksExpansion ?? "expanded" - const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed") - return tGlobal("commands.thinkingBlocksDefault.label", { state }) - }, - description: () => tGlobal("commands.thinkingBlocksDefault.description"), - category: "System", - keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")], - action: () => { - const mode = options.preferences().thinkingBlocksExpansion ?? "expanded" - const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded" - options.setThinkingBlocksExpansion(next) - }, - }) - - commandRegistry.register({ - id: "diff-view-split", - label: () => { - const prefix = (options.preferences().diffViewMode || "split") === "split" ? "✓ " : "" - return `${prefix}${tGlobal("commands.diffViewSplit.label")}` - }, - description: () => tGlobal("commands.diffViewSplit.description"), - category: "System", - keywords: () => splitKeywords("commands.diffViewSplit.keywords"), - action: () => options.setDiffViewMode("split"), - }) - - commandRegistry.register({ - id: "diff-view-unified", - label: () => { - const prefix = (options.preferences().diffViewMode || "split") === "unified" ? "✓ " : "" - return `${prefix}${tGlobal("commands.diffViewUnified.label")}` - }, - description: () => tGlobal("commands.diffViewUnified.description"), - category: "System", - keywords: () => splitKeywords("commands.diffViewUnified.keywords"), - action: () => options.setDiffViewMode("unified"), - }) - - commandRegistry.register({ - id: "tool-output-default-visibility", - label: () => { - const mode = options.preferences().toolOutputExpansion || "expanded" - const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed") - return tGlobal("commands.toolOutputsDefault.label", { state }) - }, - description: () => tGlobal("commands.toolOutputsDefault.description"), - category: "System", - keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"), - action: () => { - const mode = options.preferences().toolOutputExpansion || "expanded" - const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded" - options.setToolOutputExpansion(next) - }, - }) - - commandRegistry.register({ - id: "diagnostics-default-visibility", - label: () => { - const mode = options.preferences().diagnosticsExpansion || "expanded" - const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed") - return tGlobal("commands.diagnosticsDefault.label", { state }) - }, - description: () => tGlobal("commands.diagnosticsDefault.description"), - category: "System", - keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"), - action: () => { - const mode = options.preferences().diagnosticsExpansion || "expanded" - const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded" - options.setDiagnosticsExpansion(next) - }, - }) - - commandRegistry.register({ - id: "tool-inputs-visibility", - label: () => { - const mode = options.preferences().toolInputsVisibility || "hidden" - const state = - mode === "expanded" - ? tGlobal("commands.common.expanded") - : mode === "collapsed" - ? tGlobal("commands.common.collapsed") - : tGlobal("commands.common.hidden") - return tGlobal("commands.toolInputsVisibility.label", { state }) - }, - description: () => tGlobal("commands.toolInputsVisibility.description"), - category: "System", - keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"), - action: () => { - const mode = options.preferences().toolInputsVisibility || "hidden" - const next: ToolInputsVisibilityPreference = - mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden" - options.setToolInputsVisibility(next) - }, - }) - - commandRegistry.register({ - id: "token-usage-visibility", - label: () => { - const visible = options.preferences().showUsageMetrics ?? true - const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden") - return tGlobal("commands.tokenUsageDisplay.label", { state }) - }, - description: () => tGlobal("commands.tokenUsageDisplay.description"), - category: "System", - keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"), - action: options.toggleUsageMetrics, - }) - - commandRegistry.register({ - id: "auto-cleanup-blank-sessions", - label: () => { - const enabled = options.preferences().autoCleanupBlankSessions - const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled") - return tGlobal("commands.autoCleanupBlankSessions.label", { state }) - }, - description: () => tGlobal("commands.autoCleanupBlankSessions.description"), - category: "System", - keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"), - action: options.toggleAutoCleanupBlankSessions, + registerBehaviorCommands((command) => commandRegistry.register(command), { + preferences: options.preferences, + toggleShowThinkingBlocks: options.toggleShowThinkingBlocks, + toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints, + toggleShowTimelineTools: options.toggleShowTimelineTools, + toggleUsageMetrics: options.toggleUsageMetrics, + toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions, + togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter, + setDiffViewMode: options.setDiffViewMode, + setToolOutputExpansion: options.setToolOutputExpansion, + setDiagnosticsExpansion: options.setDiagnosticsExpansion, + setThinkingBlocksExpansion: options.setThinkingBlocksExpansion, + setToolInputsVisibility: options.setToolInputsVisibility, }) commandRegistry.register({ diff --git a/packages/ui/src/lib/hooks/use-folder-drop.ts b/packages/ui/src/lib/hooks/use-folder-drop.ts new file mode 100644 index 00000000..66fafb22 --- /dev/null +++ b/packages/ui/src/lib/hooks/use-folder-drop.ts @@ -0,0 +1,158 @@ +import { Accessor, createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { + containsFileDrop, + extractDroppedDirectoryPaths, + listenForNativeFolderDrops, + listenForNativeFolderDropState, + normalizeDroppedDirectoryPaths, + supportsDesktopFolderDrop, +} from "../native/desktop-file-drop" +import { runtimeEnv } from "../runtime-env" + +interface UseFolderDropOptions { + enabled: Accessor + onDrop: (paths: string[]) => void | Promise + onInvalidDrop?: () => void +} + +interface FolderDropBindings { + onDragEnter: (event: DragEvent) => void + onDragOver: (event: DragEvent) => void + onDragLeave: (event: DragEvent) => void + onDrop: (event: DragEvent) => void +} + +export function useFolderDrop(options: UseFolderDropOptions): { + isActive: Accessor + isSupported: boolean + bind: FolderDropBindings +} { + const [isActive, setIsActive] = createSignal(false) + const [dragDepth, setDragDepth] = createSignal(0) + const isSupported = supportsDesktopFolderDrop() + + function reset() { + setDragDepth(0) + setIsActive(false) + } + + async function handleResolvedPaths(paths: string[]) { + reset() + if (!options.enabled()) { + return + } + const directoryPaths = await normalizeDroppedDirectoryPaths(paths) + if (directoryPaths.length === 0) { + options.onInvalidDrop?.() + return + } + await options.onDrop(directoryPaths) + } + + createEffect(() => { + if (!options.enabled()) { + reset() + } + }) + + onMount(() => { + if (!isSupported) { + return + } + + let disposeNativeDrop = () => {} + let disposeNativeState = () => {} + + void listenForNativeFolderDrops((paths) => { + if (!options.enabled()) { + return + } + void handleResolvedPaths(paths) + }).then((dispose) => { + disposeNativeDrop = dispose + }) + + void listenForNativeFolderDropState((state) => { + if (!options.enabled()) { + reset() + return + } + if (state === "enter") { + setIsActive(true) + return + } + reset() + }).then((dispose) => { + disposeNativeState = dispose + }) + + onCleanup(() => { + disposeNativeDrop() + disposeNativeState() + }) + }) + + const bind: FolderDropBindings = { + onDragEnter(event) { + if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) { + return + } + event.preventDefault() + setDragDepth((prev) => prev + 1) + setIsActive(true) + }, + onDragOver(event) { + if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) { + return + } + event.preventDefault() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "copy" + } + setIsActive(true) + }, + onDragLeave(event) { + if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) { + return + } + event.preventDefault() + const nextDepth = Math.max(0, dragDepth() - 1) + setDragDepth(nextDepth) + if (nextDepth === 0) { + setIsActive(false) + } + }, + onDrop(event) { + if (!isSupported) { + return + } + event.preventDefault() + event.stopPropagation() + + if (!options.enabled()) { + reset() + return + } + + if (runtimeEnv.host === "tauri") { + reset() + return + } + + const paths = extractDroppedDirectoryPaths(event) + if (paths.length === 0) { + reset() + options.onInvalidDrop?.() + return + } + + void handleResolvedPaths(paths) + }, + } + + return { + isActive, + isSupported, + bind, + } +} diff --git a/packages/ui/src/lib/i18n/messages/en/app.ts b/packages/ui/src/lib/i18n/messages/en/app.ts index c1d20689..510e5208 100644 --- a/packages/ui/src/lib/i18n/messages/en/app.ts +++ b/packages/ui/src/lib/i18n/messages/en/app.ts @@ -1,9 +1,9 @@ export const appMessages = { "app.launchError.title": "Unable to launch OpenCode", - "app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from Advanced Settings.", + "app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from OpenCode settings.", "app.launchError.binaryPathLabel": "Binary path", "app.launchError.errorOutputLabel": "Error output", - "app.launchError.openAdvancedSettings": "Open Advanced Settings", + "app.launchError.openAdvancedSettings": "Open OpenCode Settings", "app.launchError.close": "Close", "app.launchError.closeTitle": "Close (Esc)", "app.launchError.fallbackMessage": "Failed to launch workspace", diff --git a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts index 0f943d71..e548f92c 100644 --- a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts @@ -22,6 +22,7 @@ export const folderSelectionMessages = { "folderSelection.browse.buttonOpening": "Opening...", "folderSelection.advancedSettings": "Advanced Settings", + "folderSelection.opencode": "OpenCode", "folderSelection.hints.navigate": "Navigate", "folderSelection.hints.select": "Select", @@ -31,6 +32,11 @@ export const folderSelectionMessages = { "folderSelection.loading.title": "Starting instance...", "folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.", + "folderSelection.drop.title": "Drop a folder to open it", + "folderSelection.drop.subtitle": "Start a new instance in the dropped folder.", + "folderSelection.drop.invalidTitle": "Couldn't open dropped item", + "folderSelection.drop.invalidMessage": "Drop a folder to start a new instance.", + "folderSelection.dialog.title": "Select Workspace", "folderSelection.dialog.description": "Select workspace to start coding.", } as const diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 6ba19291..cbf95000 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -55,4 +55,88 @@ export const settingsMessages = { "contextUsagePanel.labels.used": "Used", "contextUsagePanel.labels.available": "Avail", "contextUsagePanel.unavailable": "--", + + "settings.title": "Settings", + "settings.navigationAriaLabel": "Settings sections", + "settings.close": "Close settings", + "settings.content.eyebrow": "Workspace preferences", + "settings.open.title": "Open settings", + "settings.open.ariaLabel": "Open settings", + "settings.nav.appearance": "Appearance", + "settings.nav.notifications": "Notifications", + "settings.nav.remote": "Remote Access", + "settings.nav.opencode": "OpenCode", + "settings.scope.device": "This device", + "settings.scope.server": "Server setting", + "settings.common.enabled": "Enabled", + "settings.common.disabled": "Disabled", + "settings.section.appearance.title": "Appearance", + "settings.section.appearance.subtitle": "Adjust how the app looks on this device.", + "settings.appearance.theme.title": "Theme", + "settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.", + "settings.appearance.theme.option.system": "Match your operating system setting", + "settings.appearance.theme.option.light": "Use the light appearance", + "settings.appearance.theme.option.dark": "Use the dark appearance", + "settings.section.notifications.title": "Notifications", + "settings.section.notifications.subtitle": "Control OS-level notifications for session activity.", + "settings.notifications.permission.granted": "Granted", + "settings.notifications.permission.denied": "Denied", + "settings.notifications.permission.default": "Not granted", + "settings.notifications.permission.unsupported": "Unsupported", + "settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.", + "settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.", + "settings.notifications.messages.permissionNotGranted": "Notification permission not granted.", + "settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.", + "settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.", + "settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.", + "settings.notifications.sessionStatus.title": "Session status notifications", + "settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.", + "settings.notifications.enable.title": "Enable notifications", + "settings.notifications.enable.permission": "Permission: {permission}", + "settings.notifications.requestPermission.title": "Request permission", + "settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.", + "settings.notifications.requestPermission.action": "Request", + "settings.notifications.allowVisible.title": "Notify when the app is focused", + "settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.", + "settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.", + "settings.notifications.events.title": "Notify me when", + "settings.notifications.events.subtitle": "Choose which session events should send alerts.", + "settings.notifications.events.needsInput": "Session needs input", + "settings.notifications.events.idle": "Session becomes idle", + "settings.notifications.status.enabled": "Notifications enabled", + "settings.notifications.status.disabled": "Notifications disabled", + "settings.notifications.status.unsupported": "Notifications unsupported", + "settings.section.remote.title": "Remote Access", + "settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.", + "settings.section.opencode.title": "OpenCode", + "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", + "settings.opencode.runtime.title": "Runtime", + "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + + "settings.appearance.behavior.title": "Interaction", + "settings.appearance.behavior.subtitle": "Message, diff, and input defaults.", + "settings.behavior.keyboardHints.title": "Keyboard shortcut hints", + "settings.behavior.keyboardHints.subtitle": "Show keyboard shortcut hints across the UI.", + "settings.behavior.thinking.title": "Thinking sections", + "settings.behavior.thinking.subtitle": "Show or hide AI thinking sections in messages.", + "settings.behavior.thinkingDefault.title": "Thinking default", + "settings.behavior.thinkingDefault.subtitle": "Choose whether thinking sections start expanded or collapsed.", + "settings.behavior.timelineTools.title": "Timeline tool calls", + "settings.behavior.timelineTools.subtitle": "Show or hide tool call entries in the message timeline.", + "settings.behavior.diffView.title": "Diff view", + "settings.behavior.diffView.subtitle": "Choose how tool-call diffs are displayed.", + "settings.behavior.diffView.option.split": "Split", + "settings.behavior.diffView.option.unified": "Unified", + "settings.behavior.toolOutputsDefault.title": "Tool outputs default", + "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.subtitle": "Set default visibility for tool call input arguments.", + "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.", + "settings.behavior.promptSubmit.title": "Enter to submit", + "settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/app.ts b/packages/ui/src/lib/i18n/messages/es/app.ts index 9fd24e92..554a5910 100644 --- a/packages/ui/src/lib/i18n/messages/es/app.ts +++ b/packages/ui/src/lib/i18n/messages/es/app.ts @@ -1,9 +1,9 @@ export const appMessages = { "app.launchError.title": "No se pudo iniciar OpenCode", - "app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en Configuración avanzada.", + "app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en la configuración de OpenCode.", "app.launchError.binaryPathLabel": "Ruta del binario", "app.launchError.errorOutputLabel": "Salida de error", - "app.launchError.openAdvancedSettings": "Abrir Configuración avanzada", + "app.launchError.openAdvancedSettings": "Abrir Configuración de OpenCode", "app.launchError.close": "Cerrar", "app.launchError.closeTitle": "Cerrar (Esc)", "app.launchError.fallbackMessage": "No se pudo iniciar el workspace", diff --git a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts index a5bc4947..56948be4 100644 --- a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts @@ -22,6 +22,7 @@ export const folderSelectionMessages = { "folderSelection.browse.buttonOpening": "Abriendo...", "folderSelection.advancedSettings": "Configuración avanzada", + "folderSelection.opencode": "OpenCode", "folderSelection.hints.navigate": "Navegar", "folderSelection.hints.select": "Seleccionar", @@ -31,6 +32,11 @@ export const folderSelectionMessages = { "folderSelection.loading.title": "Iniciando instancia...", "folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.", + "folderSelection.drop.title": "Suelta una carpeta para abrirla", + "folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.", + "folderSelection.drop.invalidTitle": "No se pudo abrir el elemento soltado", + "folderSelection.drop.invalidMessage": "Suelta una carpeta para iniciar una nueva instancia.", + "folderSelection.dialog.title": "Seleccionar workspace", "folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index a504c18a..8a9cbe6c 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -55,4 +55,88 @@ export const settingsMessages = { "contextUsagePanel.labels.used": "Usado", "contextUsagePanel.labels.available": "Disp.", "contextUsagePanel.unavailable": "--", + + "settings.title": "Settings", + "settings.navigationAriaLabel": "Settings sections", + "settings.close": "Close settings", + "settings.content.eyebrow": "Workspace preferences", + "settings.open.title": "Open settings", + "settings.open.ariaLabel": "Open settings", + "settings.nav.appearance": "Appearance", + "settings.nav.notifications": "Notifications", + "settings.nav.remote": "Remote Access", + "settings.nav.opencode": "OpenCode", + "settings.scope.device": "This device", + "settings.scope.server": "Server setting", + "settings.common.enabled": "Enabled", + "settings.common.disabled": "Desactivado", + "settings.section.appearance.title": "Appearance", + "settings.section.appearance.subtitle": "Adjust how the app looks on this device.", + "settings.appearance.theme.title": "Theme", + "settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.", + "settings.appearance.theme.option.system": "Match your operating system setting", + "settings.appearance.theme.option.light": "Use the light appearance", + "settings.appearance.theme.option.dark": "Use the dark appearance", + "settings.section.notifications.title": "Notifications", + "settings.section.notifications.subtitle": "Control OS-level notifications for session activity.", + "settings.notifications.permission.granted": "Granted", + "settings.notifications.permission.denied": "Denied", + "settings.notifications.permission.default": "Not granted", + "settings.notifications.permission.unsupported": "Unsupported", + "settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.", + "settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.", + "settings.notifications.messages.permissionNotGranted": "Notification permission not granted.", + "settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.", + "settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.", + "settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.", + "settings.notifications.sessionStatus.title": "Session status notifications", + "settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.", + "settings.notifications.enable.title": "Enable notifications", + "settings.notifications.enable.permission": "Permission: {permission}", + "settings.notifications.requestPermission.title": "Request permission", + "settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.", + "settings.notifications.requestPermission.action": "Request", + "settings.notifications.allowVisible.title": "Notify when the app is focused", + "settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.", + "settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.", + "settings.notifications.events.title": "Notify me when", + "settings.notifications.events.subtitle": "Choose which session events should send alerts.", + "settings.notifications.events.needsInput": "Session needs input", + "settings.notifications.events.idle": "Session becomes idle", + "settings.notifications.status.enabled": "Notifications enabled", + "settings.notifications.status.disabled": "Notifications disabled", + "settings.notifications.status.unsupported": "Notifications unsupported", + "settings.section.remote.title": "Remote Access", + "settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.", + "settings.section.opencode.title": "OpenCode", + "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", + "settings.opencode.runtime.title": "Runtime", + "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + + "settings.appearance.behavior.title": "Interaccion", + "settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.", + "settings.behavior.keyboardHints.title": "Sugerencias de atajos de teclado", + "settings.behavior.keyboardHints.subtitle": "Muestra sugerencias de atajos de teclado en toda la interfaz.", + "settings.behavior.thinking.title": "Secciones de pensamiento", + "settings.behavior.thinking.subtitle": "Muestra u oculta las secciones de pensamiento de la IA en los mensajes.", + "settings.behavior.thinkingDefault.title": "Pensamiento por defecto", + "settings.behavior.thinkingDefault.subtitle": "Elige si las secciones de pensamiento comienzan expandidas o contraidas.", + "settings.behavior.timelineTools.title": "Llamadas de herramientas en la linea de tiempo", + "settings.behavior.timelineTools.subtitle": "Muestra u oculta entradas de llamadas de herramientas en la linea de tiempo de mensajes.", + "settings.behavior.diffView.title": "Vista de diferencias", + "settings.behavior.diffView.subtitle": "Elige como se muestran los diffs de llamadas de herramientas.", + "settings.behavior.diffView.option.split": "Dividida", + "settings.behavior.diffView.option.unified": "Unificada", + "settings.behavior.toolOutputsDefault.title": "Salidas de herramientas por defecto", + "settings.behavior.toolOutputsDefault.subtitle": "Elige si las salidas de herramientas comienzan expandidas o contraidas.", + "settings.behavior.diagnosticsDefault.title": "Diagnosticos por defecto", + "settings.behavior.diagnosticsDefault.subtitle": "Elige si la salida de diagnosticos comienza expandida o contraida.", + "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": "Metricas de uso de tokens", + "settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.", + "settings.behavior.autoCleanup.title": "Limpieza automatica de sesiones en blanco", + "settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.", + "settings.behavior.promptSubmit.title": "Enter para enviar", + "settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.", } as const diff --git a/packages/ui/src/lib/i18n/messages/fr/app.ts b/packages/ui/src/lib/i18n/messages/fr/app.ts index 0dffcf65..46349919 100644 --- a/packages/ui/src/lib/i18n/messages/fr/app.ts +++ b/packages/ui/src/lib/i18n/messages/fr/app.ts @@ -1,9 +1,9 @@ export const appMessages = { "app.launchError.title": "Impossible de lancer OpenCode", - "app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les Paramètres avancés.", + "app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les paramètres OpenCode.", "app.launchError.binaryPathLabel": "Chemin du binaire", "app.launchError.errorOutputLabel": "Sortie d'erreur", - "app.launchError.openAdvancedSettings": "Ouvrir les paramètres avancés", + "app.launchError.openAdvancedSettings": "Ouvrir les paramètres OpenCode", "app.launchError.close": "Fermer", "app.launchError.closeTitle": "Fermer (Esc)", "app.launchError.fallbackMessage": "Échec du lancement de l'espace de travail", diff --git a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts index 92d5998a..cd1f2cdc 100644 --- a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts @@ -22,6 +22,7 @@ export const folderSelectionMessages = { "folderSelection.browse.buttonOpening": "Ouverture...", "folderSelection.advancedSettings": "Paramètres avancés", + "folderSelection.opencode": "OpenCode", "folderSelection.hints.navigate": "Naviguer", "folderSelection.hints.select": "Sélectionner", @@ -31,6 +32,11 @@ export const folderSelectionMessages = { "folderSelection.loading.title": "Démarrage de l'instance...", "folderSelection.loading.subtitle": "Patientez pendant que nous préparons votre espace de travail.", + "folderSelection.drop.title": "Déposez un dossier pour l'ouvrir", + "folderSelection.drop.subtitle": "Démarrez une nouvelle instance dans le dossier déposé.", + "folderSelection.drop.invalidTitle": "Impossible d'ouvrir l'élément déposé", + "folderSelection.drop.invalidMessage": "Déposez un dossier pour démarrer une nouvelle instance.", + "folderSelection.dialog.title": "Sélectionner l'espace de travail", "folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.", } as const diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index facdaad2..9a7009ba 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -55,4 +55,88 @@ export const settingsMessages = { "contextUsagePanel.labels.used": "Utilisé", "contextUsagePanel.labels.available": "Dispo", "contextUsagePanel.unavailable": "--", + + "settings.title": "Settings", + "settings.navigationAriaLabel": "Settings sections", + "settings.close": "Close settings", + "settings.content.eyebrow": "Workspace preferences", + "settings.open.title": "Open settings", + "settings.open.ariaLabel": "Open settings", + "settings.nav.appearance": "Appearance", + "settings.nav.notifications": "Notifications", + "settings.nav.remote": "Remote Access", + "settings.nav.opencode": "OpenCode", + "settings.scope.device": "This device", + "settings.scope.server": "Server setting", + "settings.common.enabled": "Enabled", + "settings.common.disabled": "Desactive", + "settings.section.appearance.title": "Appearance", + "settings.section.appearance.subtitle": "Adjust how the app looks on this device.", + "settings.appearance.theme.title": "Theme", + "settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.", + "settings.appearance.theme.option.system": "Match your operating system setting", + "settings.appearance.theme.option.light": "Use the light appearance", + "settings.appearance.theme.option.dark": "Use the dark appearance", + "settings.section.notifications.title": "Notifications", + "settings.section.notifications.subtitle": "Control OS-level notifications for session activity.", + "settings.notifications.permission.granted": "Granted", + "settings.notifications.permission.denied": "Denied", + "settings.notifications.permission.default": "Not granted", + "settings.notifications.permission.unsupported": "Unsupported", + "settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.", + "settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.", + "settings.notifications.messages.permissionNotGranted": "Notification permission not granted.", + "settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.", + "settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.", + "settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.", + "settings.notifications.sessionStatus.title": "Session status notifications", + "settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.", + "settings.notifications.enable.title": "Enable notifications", + "settings.notifications.enable.permission": "Permission: {permission}", + "settings.notifications.requestPermission.title": "Request permission", + "settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.", + "settings.notifications.requestPermission.action": "Request", + "settings.notifications.allowVisible.title": "Notify when the app is focused", + "settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.", + "settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.", + "settings.notifications.events.title": "Notify me when", + "settings.notifications.events.subtitle": "Choose which session events should send alerts.", + "settings.notifications.events.needsInput": "Session needs input", + "settings.notifications.events.idle": "Session becomes idle", + "settings.notifications.status.enabled": "Notifications enabled", + "settings.notifications.status.disabled": "Notifications disabled", + "settings.notifications.status.unsupported": "Notifications unsupported", + "settings.section.remote.title": "Remote Access", + "settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.", + "settings.section.opencode.title": "OpenCode", + "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", + "settings.opencode.runtime.title": "Runtime", + "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + + "settings.appearance.behavior.title": "Interaction", + "settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.", + "settings.behavior.keyboardHints.title": "Indications de raccourcis clavier", + "settings.behavior.keyboardHints.subtitle": "Afficher des indications de raccourcis clavier dans toute l'interface.", + "settings.behavior.thinking.title": "Sections de reflexion", + "settings.behavior.thinking.subtitle": "Afficher ou masquer les sections de reflexion de l'IA dans les messages.", + "settings.behavior.thinkingDefault.title": "Etat initial de la reflexion", + "settings.behavior.thinkingDefault.subtitle": "Choisir si les sections de reflexion commencent developpees ou reduites.", + "settings.behavior.timelineTools.title": "Appels d'outils dans la chronologie", + "settings.behavior.timelineTools.subtitle": "Afficher ou masquer les entrees d'appels d'outils dans la chronologie des messages.", + "settings.behavior.diffView.title": "Vue du diff", + "settings.behavior.diffView.subtitle": "Choisir comment les diffs des appels d'outils sont affiches.", + "settings.behavior.diffView.option.split": "Scinde", + "settings.behavior.diffView.option.unified": "Unifie", + "settings.behavior.toolOutputsDefault.title": "Etat initial des sorties d'outils", + "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.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.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.", + "settings.behavior.promptSubmit.title": "Entrer pour envoyer", + "settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.", } as const diff --git a/packages/ui/src/lib/i18n/messages/ja/app.ts b/packages/ui/src/lib/i18n/messages/ja/app.ts index d1aea577..e96bf3ca 100644 --- a/packages/ui/src/lib/i18n/messages/ja/app.ts +++ b/packages/ui/src/lib/i18n/messages/ja/app.ts @@ -1,9 +1,9 @@ export const appMessages = { "app.launchError.title": "OpenCode を起動できません", - "app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、詳細設定から別のバイナリを選択してください。", + "app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、OpenCode 設定から別のバイナリを選択してください。", "app.launchError.binaryPathLabel": "バイナリのパス", "app.launchError.errorOutputLabel": "エラー出力", - "app.launchError.openAdvancedSettings": "詳細設定を開く", + "app.launchError.openAdvancedSettings": "OpenCode 設定を開く", "app.launchError.close": "閉じる", "app.launchError.closeTitle": "閉じる (Esc)", "app.launchError.fallbackMessage": "ワークスペースの起動に失敗しました", diff --git a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts index 619d9e89..4c05e401 100644 --- a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts @@ -22,6 +22,7 @@ export const folderSelectionMessages = { "folderSelection.browse.buttonOpening": "開いています...", "folderSelection.advancedSettings": "詳細設定", + "folderSelection.opencode": "OpenCode", "folderSelection.hints.navigate": "移動", "folderSelection.hints.select": "選択", @@ -31,6 +32,11 @@ export const folderSelectionMessages = { "folderSelection.loading.title": "インスタンスを起動中...", "folderSelection.loading.subtitle": "ワークスペースを準備しています。しばらくお待ちください。", + "folderSelection.drop.title": "フォルダをドロップして開く", + "folderSelection.drop.subtitle": "ドロップしたフォルダで新しいインスタンスを開始します。", + "folderSelection.drop.invalidTitle": "ドロップした項目を開けませんでした", + "folderSelection.drop.invalidMessage": "新しいインスタンスを開始するにはフォルダをドロップしてください。", + "folderSelection.dialog.title": "ワークスペースを選択", "folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。", } as const diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 3856fead..6bc70aab 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -55,4 +55,88 @@ export const settingsMessages = { "contextUsagePanel.labels.used": "使用", "contextUsagePanel.labels.available": "残り", "contextUsagePanel.unavailable": "--", + + "settings.title": "Settings", + "settings.navigationAriaLabel": "Settings sections", + "settings.close": "Close settings", + "settings.content.eyebrow": "Workspace preferences", + "settings.open.title": "Open settings", + "settings.open.ariaLabel": "Open settings", + "settings.nav.appearance": "Appearance", + "settings.nav.notifications": "Notifications", + "settings.nav.remote": "Remote Access", + "settings.nav.opencode": "OpenCode", + "settings.scope.device": "This device", + "settings.scope.server": "Server setting", + "settings.common.enabled": "Enabled", + "settings.common.disabled": "無効", + "settings.section.appearance.title": "Appearance", + "settings.section.appearance.subtitle": "Adjust how the app looks on this device.", + "settings.appearance.theme.title": "Theme", + "settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.", + "settings.appearance.theme.option.system": "Match your operating system setting", + "settings.appearance.theme.option.light": "Use the light appearance", + "settings.appearance.theme.option.dark": "Use the dark appearance", + "settings.section.notifications.title": "Notifications", + "settings.section.notifications.subtitle": "Control OS-level notifications for session activity.", + "settings.notifications.permission.granted": "Granted", + "settings.notifications.permission.denied": "Denied", + "settings.notifications.permission.default": "Not granted", + "settings.notifications.permission.unsupported": "Unsupported", + "settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.", + "settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.", + "settings.notifications.messages.permissionNotGranted": "Notification permission not granted.", + "settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.", + "settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.", + "settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.", + "settings.notifications.sessionStatus.title": "Session status notifications", + "settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.", + "settings.notifications.enable.title": "Enable notifications", + "settings.notifications.enable.permission": "Permission: {permission}", + "settings.notifications.requestPermission.title": "Request permission", + "settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.", + "settings.notifications.requestPermission.action": "Request", + "settings.notifications.allowVisible.title": "Notify when the app is focused", + "settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.", + "settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.", + "settings.notifications.events.title": "Notify me when", + "settings.notifications.events.subtitle": "Choose which session events should send alerts.", + "settings.notifications.events.needsInput": "Session needs input", + "settings.notifications.events.idle": "Session becomes idle", + "settings.notifications.status.enabled": "Notifications enabled", + "settings.notifications.status.disabled": "Notifications disabled", + "settings.notifications.status.unsupported": "Notifications unsupported", + "settings.section.remote.title": "Remote Access", + "settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.", + "settings.section.opencode.title": "OpenCode", + "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", + "settings.opencode.runtime.title": "Runtime", + "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + + "settings.appearance.behavior.title": "操作", + "settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。", + "settings.behavior.keyboardHints.title": "キーボードショートカットのヒント", + "settings.behavior.keyboardHints.subtitle": "UI全体でキーボードショートカットのヒントを表示します。", + "settings.behavior.thinking.title": "思考セクション", + "settings.behavior.thinking.subtitle": "メッセージ内のAIの思考セクションを表示/非表示にします。", + "settings.behavior.thinkingDefault.title": "思考の既定", + "settings.behavior.thinkingDefault.subtitle": "思考セクションを最初に展開/折りたたみのどちらで表示するかを選びます。", + "settings.behavior.timelineTools.title": "タイムラインのツール呼び出し", + "settings.behavior.timelineTools.subtitle": "メッセージタイムラインでツール呼び出しを表示/非表示にします。", + "settings.behavior.diffView.title": "差分表示", + "settings.behavior.diffView.subtitle": "ツール呼び出しの差分の表示方法を選びます。", + "settings.behavior.diffView.option.split": "分割", + "settings.behavior.diffView.option.unified": "統合", + "settings.behavior.toolOutputsDefault.title": "ツール出力の既定", + "settings.behavior.toolOutputsDefault.subtitle": "ツール出力を最初に展開/折りたたみのどちらで表示するかを選びます。", + "settings.behavior.diagnosticsDefault.title": "診断の既定", + "settings.behavior.diagnosticsDefault.subtitle": "診断出力を最初に展開/折りたたみのどちらで表示するかを選びます。", + "settings.behavior.toolInputsVisibility.title": "ツール入力の表示", + "settings.behavior.toolInputsVisibility.subtitle": "ツール呼び出しの入力引数の既定の表示状態を設定します。", + "settings.behavior.usageMetrics.title": "トークン使用量メトリクス", + "settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。", + "settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ", + "settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。", + "settings.behavior.promptSubmit.title": "Enterで送信", + "settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。", } as const diff --git a/packages/ui/src/lib/i18n/messages/ru/app.ts b/packages/ui/src/lib/i18n/messages/ru/app.ts index dd2c50fb..1f8c41cd 100644 --- a/packages/ui/src/lib/i18n/messages/ru/app.ts +++ b/packages/ui/src/lib/i18n/messages/ru/app.ts @@ -1,9 +1,9 @@ export const appMessages = { "app.launchError.title": "Не удалось запустить OpenCode", - "app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в расширенных настройках.", + "app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в настройках OpenCode.", "app.launchError.binaryPathLabel": "Путь к бинарнику", "app.launchError.errorOutputLabel": "Вывод ошибки", - "app.launchError.openAdvancedSettings": "Открыть расширенные настройки", + "app.launchError.openAdvancedSettings": "Открыть настройки OpenCode", "app.launchError.close": "Закрыть", "app.launchError.closeTitle": "Закрыть (Esc)", "app.launchError.fallbackMessage": "Не удалось запустить рабочее пространство", diff --git a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts index de868689..4a005938 100644 --- a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts @@ -22,6 +22,7 @@ export const folderSelectionMessages = { "folderSelection.browse.buttonOpening": "Открытие…", "folderSelection.advancedSettings": "Расширенные настройки", + "folderSelection.opencode": "OpenCode", "folderSelection.hints.navigate": "Навигация", "folderSelection.hints.select": "Выбрать", @@ -31,6 +32,11 @@ export const folderSelectionMessages = { "folderSelection.loading.title": "Запуск экземпляра…", "folderSelection.loading.subtitle": "Подождите, пока мы подготовим рабочее пространство.", + "folderSelection.drop.title": "Перетащите папку, чтобы открыть ее", + "folderSelection.drop.subtitle": "Запустите новый экземпляр в перетащенной папке.", + "folderSelection.drop.invalidTitle": "Не удалось открыть перетащенный элемент", + "folderSelection.drop.invalidMessage": "Перетащите папку, чтобы запустить новый экземпляр.", + "folderSelection.dialog.title": "Выберите рабочее пространство", "folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.", } as const diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 09cb228b..ce52f835 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -55,4 +55,88 @@ export const settingsMessages = { "contextUsagePanel.labels.used": "Использовано", "contextUsagePanel.labels.available": "Доступно", "contextUsagePanel.unavailable": "--", + + "settings.title": "Settings", + "settings.navigationAriaLabel": "Settings sections", + "settings.close": "Close settings", + "settings.content.eyebrow": "Workspace preferences", + "settings.open.title": "Open settings", + "settings.open.ariaLabel": "Open settings", + "settings.nav.appearance": "Appearance", + "settings.nav.notifications": "Notifications", + "settings.nav.remote": "Remote Access", + "settings.nav.opencode": "OpenCode", + "settings.scope.device": "This device", + "settings.scope.server": "Server setting", + "settings.common.enabled": "Enabled", + "settings.common.disabled": "Отключено", + "settings.section.appearance.title": "Appearance", + "settings.section.appearance.subtitle": "Adjust how the app looks on this device.", + "settings.appearance.theme.title": "Theme", + "settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.", + "settings.appearance.theme.option.system": "Match your operating system setting", + "settings.appearance.theme.option.light": "Use the light appearance", + "settings.appearance.theme.option.dark": "Use the dark appearance", + "settings.section.notifications.title": "Notifications", + "settings.section.notifications.subtitle": "Control OS-level notifications for session activity.", + "settings.notifications.permission.granted": "Granted", + "settings.notifications.permission.denied": "Denied", + "settings.notifications.permission.default": "Not granted", + "settings.notifications.permission.unsupported": "Unsupported", + "settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.", + "settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.", + "settings.notifications.messages.permissionNotGranted": "Notification permission not granted.", + "settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.", + "settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.", + "settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.", + "settings.notifications.sessionStatus.title": "Session status notifications", + "settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.", + "settings.notifications.enable.title": "Enable notifications", + "settings.notifications.enable.permission": "Permission: {permission}", + "settings.notifications.requestPermission.title": "Request permission", + "settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.", + "settings.notifications.requestPermission.action": "Request", + "settings.notifications.allowVisible.title": "Notify when the app is focused", + "settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.", + "settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.", + "settings.notifications.events.title": "Notify me when", + "settings.notifications.events.subtitle": "Choose which session events should send alerts.", + "settings.notifications.events.needsInput": "Session needs input", + "settings.notifications.events.idle": "Session becomes idle", + "settings.notifications.status.enabled": "Notifications enabled", + "settings.notifications.status.disabled": "Notifications disabled", + "settings.notifications.status.unsupported": "Notifications unsupported", + "settings.section.remote.title": "Remote Access", + "settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.", + "settings.section.opencode.title": "OpenCode", + "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", + "settings.opencode.runtime.title": "Runtime", + "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + + "settings.appearance.behavior.title": "Взаимодействие", + "settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.", + "settings.behavior.keyboardHints.title": "Подсказки сочетаний клавиш", + "settings.behavior.keyboardHints.subtitle": "Показывать подсказки сочетаний клавиш по всему интерфейсу.", + "settings.behavior.thinking.title": "Разделы размышлений", + "settings.behavior.thinking.subtitle": "Показывать или скрывать разделы размышлений ИИ в сообщениях.", + "settings.behavior.thinkingDefault.title": "Размышления по умолчанию", + "settings.behavior.thinkingDefault.subtitle": "Выберите, начинать ли разделы размышлений развернутыми или свернутыми.", + "settings.behavior.timelineTools.title": "Вызовы инструментов в таймлайне", + "settings.behavior.timelineTools.subtitle": "Показывать или скрывать записи вызовов инструментов в таймлайне сообщений.", + "settings.behavior.diffView.title": "Вид диффа", + "settings.behavior.diffView.subtitle": "Выберите, как отображаются диффы вызовов инструментов.", + "settings.behavior.diffView.option.split": "Раздельный", + "settings.behavior.diffView.option.unified": "Единый", + "settings.behavior.toolOutputsDefault.title": "Выводы инструментов по умолчанию", + "settings.behavior.toolOutputsDefault.subtitle": "Выберите, начинать ли выводы инструментов развернутыми или свернутыми.", + "settings.behavior.diagnosticsDefault.title": "Диагностика по умолчанию", + "settings.behavior.diagnosticsDefault.subtitle": "Выберите, начинать ли вывод диагностики развернутым или свернутым.", + "settings.behavior.toolInputsVisibility.title": "Видимость входных данных инструмента", + "settings.behavior.toolInputsVisibility.subtitle": "Задайте видимость по умолчанию для входных аргументов вызовов инструментов.", + "settings.behavior.usageMetrics.title": "Метрики использования токенов", + "settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.", + "settings.behavior.autoCleanup.title": "Автоочистка пустых сессий", + "settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.", + "settings.behavior.promptSubmit.title": "Enter для отправки", + "settings.behavior.promptSubmit.subtitle": "Enter отправляет; Cmd/Ctrl+Enter вставляет новую строку.", } as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/app.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/app.ts index 477a447b..cd2a82f8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/app.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/app.ts @@ -1,9 +1,9 @@ export const appMessages = { "app.launchError.title": "无法启动 OpenCode", - "app.launchError.description": "我们无法启动所选的 OpenCode 可执行文件。请查看下面的错误输出,或在“高级设置”中选择其他可执行文件。", + "app.launchError.description": "我们无法启动所选的 OpenCode 可执行文件。请查看下面的错误输出,或在 OpenCode 设置中选择其他可执行文件。", "app.launchError.binaryPathLabel": "可执行文件路径", "app.launchError.errorOutputLabel": "错误输出", - "app.launchError.openAdvancedSettings": "打开高级设置", + "app.launchError.openAdvancedSettings": "打开 OpenCode 设置", "app.launchError.close": "关闭", "app.launchError.closeTitle": "关闭 (Esc)", "app.launchError.fallbackMessage": "启动工作区失败", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts index 034dcb0b..1c765fe9 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts @@ -22,6 +22,7 @@ export const folderSelectionMessages = { "folderSelection.browse.buttonOpening": "正在打开...", "folderSelection.advancedSettings": "高级设置", + "folderSelection.opencode": "OpenCode", "folderSelection.hints.navigate": "导航", "folderSelection.hints.select": "选择", @@ -31,6 +32,11 @@ export const folderSelectionMessages = { "folderSelection.loading.title": "正在启动实例...", "folderSelection.loading.subtitle": "正在准备你的工作区,请稍候。", + "folderSelection.drop.title": "拖放文件夹以打开", + "folderSelection.drop.subtitle": "在拖放的文件夹中启动一个新实例。", + "folderSelection.drop.invalidTitle": "无法打开拖放的项目", + "folderSelection.drop.invalidMessage": "请拖放一个文件夹来启动新实例。", + "folderSelection.dialog.title": "选择工作区", "folderSelection.dialog.description": "选择工作区以开始编码。", } as const 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 77400102..8451aeae 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -55,4 +55,88 @@ export const settingsMessages = { "contextUsagePanel.labels.used": "已用", "contextUsagePanel.labels.available": "可用", "contextUsagePanel.unavailable": "--", + + "settings.title": "Settings", + "settings.navigationAriaLabel": "Settings sections", + "settings.close": "Close settings", + "settings.content.eyebrow": "Workspace preferences", + "settings.open.title": "Open settings", + "settings.open.ariaLabel": "Open settings", + "settings.nav.appearance": "Appearance", + "settings.nav.notifications": "Notifications", + "settings.nav.remote": "Remote Access", + "settings.nav.opencode": "OpenCode", + "settings.scope.device": "This device", + "settings.scope.server": "Server setting", + "settings.common.enabled": "Enabled", + "settings.common.disabled": "已禁用", + "settings.section.appearance.title": "Appearance", + "settings.section.appearance.subtitle": "Adjust how the app looks on this device.", + "settings.appearance.theme.title": "Theme", + "settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.", + "settings.appearance.theme.option.system": "Match your operating system setting", + "settings.appearance.theme.option.light": "Use the light appearance", + "settings.appearance.theme.option.dark": "Use the dark appearance", + "settings.section.notifications.title": "Notifications", + "settings.section.notifications.subtitle": "Control OS-level notifications for session activity.", + "settings.notifications.permission.granted": "Granted", + "settings.notifications.permission.denied": "Denied", + "settings.notifications.permission.default": "Not granted", + "settings.notifications.permission.unsupported": "Unsupported", + "settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.", + "settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.", + "settings.notifications.messages.permissionNotGranted": "Notification permission not granted.", + "settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.", + "settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.", + "settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.", + "settings.notifications.sessionStatus.title": "Session status notifications", + "settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.", + "settings.notifications.enable.title": "Enable notifications", + "settings.notifications.enable.permission": "Permission: {permission}", + "settings.notifications.requestPermission.title": "Request permission", + "settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.", + "settings.notifications.requestPermission.action": "Request", + "settings.notifications.allowVisible.title": "Notify when the app is focused", + "settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.", + "settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.", + "settings.notifications.events.title": "Notify me when", + "settings.notifications.events.subtitle": "Choose which session events should send alerts.", + "settings.notifications.events.needsInput": "Session needs input", + "settings.notifications.events.idle": "Session becomes idle", + "settings.notifications.status.enabled": "Notifications enabled", + "settings.notifications.status.disabled": "Notifications disabled", + "settings.notifications.status.unsupported": "Notifications unsupported", + "settings.section.remote.title": "Remote Access", + "settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.", + "settings.section.opencode.title": "OpenCode", + "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", + "settings.opencode.runtime.title": "Runtime", + "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + + "settings.appearance.behavior.title": "交互", + "settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。", + "settings.behavior.keyboardHints.title": "键盘快捷键提示", + "settings.behavior.keyboardHints.subtitle": "在整个界面中显示键盘快捷键提示。", + "settings.behavior.thinking.title": "思考区块", + "settings.behavior.thinking.subtitle": "在消息中显示或隐藏AI的思考区块。", + "settings.behavior.thinkingDefault.title": "思考默认状态", + "settings.behavior.thinkingDefault.subtitle": "选择思考区块默认是展开还是折叠。", + "settings.behavior.timelineTools.title": "时间线工具调用", + "settings.behavior.timelineTools.subtitle": "在消息时间线中显示或隐藏工具调用条目。", + "settings.behavior.diffView.title": "差异视图", + "settings.behavior.diffView.subtitle": "选择工具调用差异的显示方式。", + "settings.behavior.diffView.option.split": "分栏", + "settings.behavior.diffView.option.unified": "统一", + "settings.behavior.toolOutputsDefault.title": "工具输出默认状态", + "settings.behavior.toolOutputsDefault.subtitle": "选择工具输出默认是展开还是折叠。", + "settings.behavior.diagnosticsDefault.title": "诊断默认状态", + "settings.behavior.diagnosticsDefault.subtitle": "选择诊断输出默认是展开还是折叠。", + "settings.behavior.toolInputsVisibility.title": "工具输入可见性", + "settings.behavior.toolInputsVisibility.subtitle": "设置工具调用输入参数的默认可见性。", + "settings.behavior.usageMetrics.title": "令牌用量指标", + "settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。", + "settings.behavior.autoCleanup.title": "自动清理空会话", + "settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。", + "settings.behavior.promptSubmit.title": "回车发送", + "settings.behavior.promptSubmit.subtitle": "使用回车发送;Cmd/Ctrl+回车插入新行。", } as const diff --git a/packages/ui/src/lib/native/desktop-file-drop.ts b/packages/ui/src/lib/native/desktop-file-drop.ts new file mode 100644 index 00000000..6691fb63 --- /dev/null +++ b/packages/ui/src/lib/native/desktop-file-drop.ts @@ -0,0 +1,155 @@ +import { getLogger } from "../logger" +import { runtimeEnv } from "../runtime-env" + +const log = getLogger("actions") + +type NativeFolderDropState = "enter" | "leave" + +interface TauriFolderDropPayload { + paths?: unknown +} + +function normalizePathList(input: unknown): string[] { + if (!Array.isArray(input)) { + return [] + } + return input.filter((value): value is string => typeof value === "string" && value.trim().length > 0) +} + +function getFilePath(file: File): string | null { + if (typeof file.path === "string" && file.path.trim().length > 0) { + return file.path + } + if (runtimeEnv.host === "electron") { + const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file) + if (typeof electronPath === "string" && electronPath.trim().length > 0) { + return electronPath + } + } + return null +} + +async function resolveElectronDirectoryPaths(paths: string[]): Promise { + const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI + if (!api?.getDirectoryPaths || paths.length === 0) { + return [] + } + try { + return await api.getDirectoryPaths(paths) + } catch (error) { + log.error("[native] failed to validate dropped directory paths", error) + return [] + } +} + +export function supportsDesktopFolderDrop(): boolean { + return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web" +} + +export function containsFileDrop(event: DragEvent): boolean { + const types = event.dataTransfer?.types + if (!types) { + return false + } + return Array.from(types).includes("Files") +} + +export function extractDroppedDirectoryPaths(event: DragEvent): string[] { + const dataTransfer = event.dataTransfer + if (!dataTransfer) { + return [] + } + + const directoryHints = new Set() + for (const item of Array.from(dataTransfer.items ?? [])) { + if (item.kind !== "file") { + continue + } + const entry = item.webkitGetAsEntry?.() + if (!entry?.isDirectory) { + continue + } + const file = item.getAsFile() + const filePath = file ? getFilePath(file) : null + if (filePath) { + directoryHints.add(filePath) + } + } + + const paths = new Set() + for (const file of Array.from(dataTransfer.files ?? [])) { + const filePath = getFilePath(file) + if (!filePath) { + continue + } + if (directoryHints.size > 0 && !directoryHints.has(filePath)) { + continue + } + paths.add(filePath) + } + + return Array.from(paths) +} + +export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise { + const uniquePaths = Array.from(new Set(paths.filter((path) => typeof path === "string" && path.trim().length > 0))) + if (uniquePaths.length === 0) { + return [] + } + if (runtimeEnv.host === "electron") { + return resolveElectronDirectoryPaths(uniquePaths) + } + return uniquePaths +} + +export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> { + if (runtimeEnv.host !== "tauri") { + return () => {} + } + + const eventApi = window.__TAURI__?.event + if (!eventApi?.listen) { + return () => {} + } + + try { + const unlisten = await eventApi.listen("desktop:folder-drop", (event) => { + const payload = (event.payload ?? {}) as TauriFolderDropPayload + const paths = normalizePathList(payload.paths) + if (paths.length > 0) { + onDrop(paths) + } + }) + return () => { + unlisten() + } + } catch (error) { + log.error("[native] failed to listen for folder-drop event", error) + return () => {} + } +} + +export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> { + if (runtimeEnv.host !== "tauri") { + return () => {} + } + + const eventApi = window.__TAURI__?.event + if (!eventApi?.listen) { + return () => {} + } + + try { + const [unlistenEnter, unlistenLeave] = await Promise.all([ + eventApi.listen("desktop:folder-drag-enter", () => onState("enter")), + eventApi.listen("desktop:folder-drag-leave", () => onState("leave")), + ]) + return () => { + unlistenEnter() + unlistenLeave() + } + } catch (error) { + log.error("[native] failed to listen for folder-drop state", error) + return () => {} + } +} diff --git a/packages/ui/src/lib/settings/behavior-registry.ts b/packages/ui/src/lib/settings/behavior-registry.ts new file mode 100644 index 00000000..b7ab1b98 --- /dev/null +++ b/packages/ui/src/lib/settings/behavior-registry.ts @@ -0,0 +1,452 @@ +import type { Accessor } from "solid-js" +import type { + Preferences, + ExpansionPreference, + ToolInputsVisibilityPreference, +} from "../../stores/preferences" +import type { Command } from "../commands" +import { tGlobal } from "../i18n" +import { runtimeEnv } from "../runtime-env" + +export type BehaviorSettingKind = "toggle" | "enum" + +export type BehaviorToggleSetting = { + kind: "toggle" + id: string + titleKey: string + subtitleKey: string + get: (preferences: Preferences) => boolean + set: (next: boolean) => void + disabled?: () => boolean +} + +export type BehaviorEnumSetting = { + kind: "enum" + id: string + titleKey: string + subtitleKey: string + get: (preferences: Preferences) => T + set: (next: T) => void + options: Array<{ value: T; labelKey: string }> + disabled?: () => boolean +} + +export type BehaviorSetting = BehaviorToggleSetting | BehaviorEnumSetting + +export type BehaviorRegistryActions = { + preferences: Accessor + updatePreferences?: (updates: Partial) => void + toggleShowThinkingBlocks: () => void + toggleKeyboardShortcutHints: () => void + toggleShowTimelineTools: () => void + toggleUsageMetrics: () => void + toggleAutoCleanupBlankSessions: () => void + togglePromptSubmitOnEnter: () => void + setDiffViewMode: (mode: "split" | "unified") => void + setToolOutputExpansion: (mode: ExpansionPreference) => void + setDiagnosticsExpansion: (mode: ExpansionPreference) => void + setThinkingBlocksExpansion: (mode: ExpansionPreference) => void + setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void +} + +function splitKeywords(key: string): string[] { + return tGlobal(key) + .split(",") + .map((value) => value.trim()) + .filter(Boolean) +} + +function setBooleanByToggle(getCurrent: () => boolean, toggle: () => void, next: boolean) { + if (getCurrent() === next) return + toggle() +} + +export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorSetting[] { + const prefs = actions.preferences + const updatePreferences = actions.updatePreferences + + return [ + { + kind: "toggle", + id: "behavior.keyboardShortcutHints", + titleKey: "settings.behavior.keyboardHints.title", + subtitleKey: "settings.behavior.keyboardHints.subtitle", + get: (p) => Boolean(p.showKeyboardShortcutHints ?? true), + set: (next) => { + if (updatePreferences) { + updatePreferences({ showKeyboardShortcutHints: next }) + return + } + setBooleanByToggle( + () => Boolean(prefs().showKeyboardShortcutHints ?? true), + actions.toggleKeyboardShortcutHints, + next, + ) + }, + disabled: () => runtimeEnv.host === "web", + }, + { + kind: "toggle", + id: "behavior.thinkingBlocks", + titleKey: "settings.behavior.thinking.title", + subtitleKey: "settings.behavior.thinking.subtitle", + get: (p) => Boolean(p.showThinkingBlocks), + set: (next) => { + if (updatePreferences) { + updatePreferences({ showThinkingBlocks: next }) + return + } + setBooleanByToggle( + () => Boolean(prefs().showThinkingBlocks), + actions.toggleShowThinkingBlocks, + next, + ) + }, + }, + { + kind: "enum", + id: "behavior.thinkingBlocksDefault", + titleKey: "settings.behavior.thinkingDefault.title", + subtitleKey: "settings.behavior.thinkingDefault.subtitle", + get: (p) => (p.thinkingBlocksExpansion ?? "expanded") as ExpansionPreference, + set: (next) => { + if (updatePreferences) { + updatePreferences({ thinkingBlocksExpansion: next as ExpansionPreference }) + return + } + actions.setThinkingBlocksExpansion(next as ExpansionPreference) + }, + options: [ + { value: "expanded", labelKey: "commands.common.expanded" }, + { value: "collapsed", labelKey: "commands.common.collapsed" }, + ], + }, + { + kind: "toggle", + id: "behavior.timelineToolCalls", + titleKey: "settings.behavior.timelineTools.title", + subtitleKey: "settings.behavior.timelineTools.subtitle", + get: (p) => Boolean(p.showTimelineTools), + set: (next) => { + if (updatePreferences) { + updatePreferences({ showTimelineTools: next }) + return + } + setBooleanByToggle( + () => Boolean(prefs().showTimelineTools), + actions.toggleShowTimelineTools, + next, + ) + }, + }, + { + kind: "enum", + id: "behavior.diffViewMode", + titleKey: "settings.behavior.diffView.title", + subtitleKey: "settings.behavior.diffView.subtitle", + get: (p) => (p.diffViewMode ?? "split") as "split" | "unified", + set: (next) => { + if (updatePreferences) { + updatePreferences({ diffViewMode: next as "split" | "unified" }) + return + } + actions.setDiffViewMode(next as "split" | "unified") + }, + options: [ + { value: "split", labelKey: "settings.behavior.diffView.option.split" }, + { value: "unified", labelKey: "settings.behavior.diffView.option.unified" }, + ], + }, + { + kind: "enum", + id: "behavior.toolOutputsDefault", + titleKey: "settings.behavior.toolOutputsDefault.title", + subtitleKey: "settings.behavior.toolOutputsDefault.subtitle", + get: (p) => (p.toolOutputExpansion ?? "expanded") as ExpansionPreference, + set: (next) => { + if (updatePreferences) { + updatePreferences({ toolOutputExpansion: next as ExpansionPreference }) + return + } + actions.setToolOutputExpansion(next as ExpansionPreference) + }, + options: [ + { value: "expanded", labelKey: "commands.common.expanded" }, + { value: "collapsed", labelKey: "commands.common.collapsed" }, + ], + }, + { + kind: "enum", + id: "behavior.diagnosticsDefault", + titleKey: "settings.behavior.diagnosticsDefault.title", + subtitleKey: "settings.behavior.diagnosticsDefault.subtitle", + get: (p) => (p.diagnosticsExpansion ?? "expanded") as ExpansionPreference, + set: (next) => { + if (updatePreferences) { + updatePreferences({ diagnosticsExpansion: next as ExpansionPreference }) + return + } + actions.setDiagnosticsExpansion(next as ExpansionPreference) + }, + options: [ + { value: "expanded", labelKey: "commands.common.expanded" }, + { value: "collapsed", labelKey: "commands.common.collapsed" }, + ], + }, + { + kind: "enum", + id: "behavior.toolInputsVisibility", + titleKey: "settings.behavior.toolInputsVisibility.title", + subtitleKey: "settings.behavior.toolInputsVisibility.subtitle", + get: (p) => (p.toolInputsVisibility ?? "hidden") as ToolInputsVisibilityPreference, + set: (next) => { + if (updatePreferences) { + updatePreferences({ toolInputsVisibility: next as ToolInputsVisibilityPreference }) + return + } + actions.setToolInputsVisibility(next as ToolInputsVisibilityPreference) + }, + options: [ + { value: "hidden", labelKey: "commands.common.hidden" }, + { value: "collapsed", labelKey: "commands.common.collapsed" }, + { value: "expanded", labelKey: "commands.common.expanded" }, + ], + }, + { + kind: "toggle", + id: "behavior.usageMetrics", + titleKey: "settings.behavior.usageMetrics.title", + subtitleKey: "settings.behavior.usageMetrics.subtitle", + get: (p) => Boolean(p.showUsageMetrics ?? true), + set: (next) => { + if (updatePreferences) { + updatePreferences({ showUsageMetrics: next }) + return + } + setBooleanByToggle( + () => Boolean(prefs().showUsageMetrics ?? true), + actions.toggleUsageMetrics, + next, + ) + }, + }, + { + kind: "toggle", + id: "behavior.autoCleanupBlankSessions", + titleKey: "settings.behavior.autoCleanup.title", + subtitleKey: "settings.behavior.autoCleanup.subtitle", + get: (p) => Boolean(p.autoCleanupBlankSessions), + set: (next) => { + if (updatePreferences) { + updatePreferences({ autoCleanupBlankSessions: next }) + return + } + setBooleanByToggle( + () => Boolean(prefs().autoCleanupBlankSessions), + actions.toggleAutoCleanupBlankSessions, + next, + ) + }, + }, + { + kind: "toggle", + id: "behavior.promptSubmitOnEnter", + titleKey: "settings.behavior.promptSubmit.title", + subtitleKey: "settings.behavior.promptSubmit.subtitle", + get: (p) => Boolean(p.promptSubmitOnEnter), + set: (next) => { + if (updatePreferences) { + updatePreferences({ promptSubmitOnEnter: next }) + return + } + setBooleanByToggle( + () => Boolean(prefs().promptSubmitOnEnter), + actions.togglePromptSubmitOnEnter, + next, + ) + }, + }, + ] +} + +export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[] { + return [ + { + id: "prompt-submit-shortcut", + label: () => + actions.preferences().promptSubmitOnEnter + ? tGlobal("commands.promptSubmitShortcut.label.swapped") + : tGlobal("commands.promptSubmitShortcut.label.default"), + description: () => tGlobal("commands.promptSubmitShortcut.description"), + category: "Input & Focus", + keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"), + action: actions.togglePromptSubmitOnEnter, + }, + { + id: "thinking", + label: () => + tGlobal( + actions.preferences().showThinkingBlocks + ? "commands.thinkingBlocks.label.hide" + : "commands.thinkingBlocks.label.show", + ), + description: () => tGlobal("commands.thinkingBlocks.description"), + category: "System", + keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")], + action: actions.toggleShowThinkingBlocks, + }, + { + id: "timeline-tools", + label: () => + tGlobal( + actions.preferences().showTimelineTools + ? "commands.timelineToolCalls.label.hide" + : "commands.timelineToolCalls.label.show", + ), + description: () => tGlobal("commands.timelineToolCalls.description"), + category: "System", + keywords: () => splitKeywords("commands.timelineToolCalls.keywords"), + action: actions.toggleShowTimelineTools, + }, + { + id: "keyboard-shortcut-hints", + label: () => + tGlobal( + actions.preferences().showKeyboardShortcutHints + ? "commands.keyboardShortcutHints.label.hide" + : "commands.keyboardShortcutHints.label.show", + ), + description: () => + tGlobal( + runtimeEnv.host === "web" + ? "commands.keyboardShortcutHints.description.disabledWeb" + : "commands.keyboardShortcutHints.description", + ), + category: "System", + keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"), + disabled: () => runtimeEnv.host === "web", + action: actions.toggleKeyboardShortcutHints, + }, + { + id: "thinking-default-visibility", + label: () => { + const mode = actions.preferences().thinkingBlocksExpansion ?? "expanded" + const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed") + return tGlobal("commands.thinkingBlocksDefault.label", { state }) + }, + description: () => tGlobal("commands.thinkingBlocksDefault.description"), + category: "System", + keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")], + action: () => { + const mode = actions.preferences().thinkingBlocksExpansion ?? "expanded" + const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded" + actions.setThinkingBlocksExpansion(next) + }, + }, + { + id: "diff-view-split", + label: () => { + const prefix = (actions.preferences().diffViewMode || "split") === "split" ? "✓ " : "" + return `${prefix}${tGlobal("commands.diffViewSplit.label")}` + }, + description: () => tGlobal("commands.diffViewSplit.description"), + category: "System", + keywords: () => splitKeywords("commands.diffViewSplit.keywords"), + action: () => actions.setDiffViewMode("split"), + }, + { + id: "diff-view-unified", + label: () => { + const prefix = (actions.preferences().diffViewMode || "split") === "unified" ? "✓ " : "" + return `${prefix}${tGlobal("commands.diffViewUnified.label")}` + }, + description: () => tGlobal("commands.diffViewUnified.description"), + category: "System", + keywords: () => splitKeywords("commands.diffViewUnified.keywords"), + action: () => actions.setDiffViewMode("unified"), + }, + { + id: "tool-output-default-visibility", + label: () => { + const mode = actions.preferences().toolOutputExpansion || "expanded" + const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed") + 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" + actions.setToolOutputExpansion(next) + }, + }, + { + id: "diagnostics-default-visibility", + label: () => { + const mode = actions.preferences().diagnosticsExpansion || "expanded" + const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed") + return tGlobal("commands.diagnosticsDefault.label", { state }) + }, + description: () => tGlobal("commands.diagnosticsDefault.description"), + category: "System", + keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"), + action: () => { + const mode = actions.preferences().diagnosticsExpansion || "expanded" + const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded" + actions.setDiagnosticsExpansion(next) + }, + }, + { + id: "tool-inputs-visibility", + label: () => { + const mode = actions.preferences().toolInputsVisibility || "hidden" + const state = + mode === "expanded" + ? tGlobal("commands.common.expanded") + : mode === "collapsed" + ? tGlobal("commands.common.collapsed") + : tGlobal("commands.common.hidden") + return tGlobal("commands.toolInputsVisibility.label", { state }) + }, + description: () => tGlobal("commands.toolInputsVisibility.description"), + category: "System", + keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"), + action: () => { + const mode = actions.preferences().toolInputsVisibility || "hidden" + const next: ToolInputsVisibilityPreference = + mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden" + actions.setToolInputsVisibility(next) + }, + }, + { + id: "token-usage-visibility", + label: () => { + const visible = actions.preferences().showUsageMetrics ?? true + const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden") + return tGlobal("commands.tokenUsageDisplay.label", { state }) + }, + description: () => tGlobal("commands.tokenUsageDisplay.description"), + category: "System", + keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"), + action: actions.toggleUsageMetrics, + }, + { + id: "auto-cleanup-blank-sessions", + label: () => { + const enabled = actions.preferences().autoCleanupBlankSessions + const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled") + return tGlobal("commands.autoCleanupBlankSessions.label", { state }) + }, + description: () => tGlobal("commands.autoCleanupBlankSessions.description"), + category: "System", + keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"), + action: actions.toggleAutoCleanupBlankSessions, + }, + ] +} + +export function registerBehaviorCommands(register: (command: Command) => void, actions: BehaviorRegistryActions) { + const commands = getBehaviorCommands(actions) + commands.forEach((command) => register(command)) +} diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index 6642f107..7b1b35be 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -5,7 +5,7 @@ import { getQuestionCallId, getQuestionMessageId } from "../../types/question" import type { Message, MessageInfo, ClientPart } from "../../types/message" import type { Session } from "../../types/session" import { messageStoreBus } from "./bus" -import type { MessageStatus, SessionRevertState } from "./types" +import type { MessageStatus, ReplaceMessageIdOptions, SessionRevertState } from "./types" interface SessionMetadata { id: string @@ -121,10 +121,10 @@ export function applyPartDeltaV2( }) } -export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void { +export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string, options?: Omit): void { if (!oldId || !newId || oldId === newId) return const store = messageStoreBus.getOrCreate(instanceId) - store.replaceMessageId({ oldId, newId }) + store.replaceMessageId({ oldId, newId, ...(options ?? {}) }) } function extractPermissionMessageId(permission: PermissionRequestLike): string | undefined { diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index bc2bb1f3..12f70bc3 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -586,10 +586,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() }) return } - + const partId = ensurePartId(input.messageId, input.part, message.partIds.length) const cloned = clonePart(input.part) - + setState( "messages", input.messageId, @@ -792,6 +792,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt id: options.newId, isEphemeral: false, updatedAt: Date.now(), + partIds: options.clearParts ? [] : existing.partIds, + parts: options.clearParts ? {} : existing.parts, } setState("messages", options.newId, cloned) diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 4d41cabc..986990a3 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -152,6 +152,7 @@ export interface PartUpdateInput { export interface ReplaceMessageIdOptions { oldId: string newId: string + clearParts?: boolean } export interface ScrollCacheKey { diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index f94f6ce7..8e193c6d 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -94,7 +94,7 @@ async function sendMessage( } const messageId = createId("msg") - const textPartId = createId("part") + const textPartId = createId("prt") const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments) @@ -110,7 +110,6 @@ async function sendMessage( const requestParts: any[] = [ { - id: textPartId, type: "text" as const, text: resolvedPrompt, }, @@ -120,9 +119,8 @@ async function sendMessage( for (const att of attachments) { const source = att.source if (source.type === "file") { - const partId = createId("part") + const partId = createId("prt") requestParts.push({ - id: partId, type: "file" as const, url: att.url, mime: source.mime, @@ -148,9 +146,8 @@ async function sendMessage( continue } - const partId = createId("part") + const partId = createId("prt") requestParts.push({ - id: partId, type: "text" as const, text: value, }) @@ -184,7 +181,6 @@ async function sendMessage( }) const requestBody = { - messageID: messageId, parts: requestParts, ...(session.agent && { agent: session.agent }), ...(session.model.providerId && diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 0d6ed470..710c7dab 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -240,19 +240,22 @@ function resolveMessageRole(info?: MessageInfo | null): MessageRole { return info?.role === "user" ? "user" : "assistant" } -function findPendingMessageId( +function findPendingSyntheticMessageId( store: InstanceMessageStore, sessionId: string, role: MessageRole, ): string | undefined { const messageIds = store.getSessionMessageIds(sessionId) - const lastId = messageIds[messageIds.length - 1] - if (!lastId) return undefined - const record = store.getMessage(lastId) - if (!record) return undefined - if (record.sessionId !== sessionId) return undefined - if (record.role !== role) return undefined - return record.status === "sending" ? record.id : undefined + for (const messageId of messageIds) { + const record = store.getMessage(messageId) + if (!record) continue + if (record.sessionId !== sessionId) continue + if (record.role !== role) continue + if (record.status !== "sending") continue + if (!record.isEphemeral) continue + return record.id + } + return undefined } function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void { @@ -282,9 +285,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes let record = store.getMessage(messageId) if (!record) { - const pendingId = findPendingMessageId(store, sessionId, role) + const pendingId = findPendingSyntheticMessageId(store, sessionId, role) if (pendingId && pendingId !== messageId) { - replaceMessageIdV2(instanceId, pendingId, messageId) + replaceMessageIdV2(instanceId, pendingId, messageId, { clearParts: role === "user" }) record = store.getMessage(messageId) } } @@ -345,9 +348,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes let record = store.getMessage(messageId) if (!record) { - const pendingId = findPendingMessageId(store, sessionId, role) + const pendingId = findPendingSyntheticMessageId(store, sessionId, role) if (pendingId && pendingId !== messageId) { - replaceMessageIdV2(instanceId, pendingId, messageId) + replaceMessageIdV2(instanceId, pendingId, messageId, { clearParts: role === "user" }) record = store.getMessage(messageId) } } diff --git a/packages/ui/src/stores/settings-screen.ts b/packages/ui/src/stores/settings-screen.ts new file mode 100644 index 00000000..3de9eb13 --- /dev/null +++ b/packages/ui/src/stores/settings-screen.ts @@ -0,0 +1,17 @@ +import { createSignal } from "solid-js" + +export type SettingsSectionId = "appearance" | "notifications" | "remote" | "opencode" + +const [settingsOpen, setSettingsOpen] = createSignal(false) +const [activeSettingsSection, setActiveSettingsSection] = createSignal("appearance") + +export function openSettings(section: SettingsSectionId = "appearance") { + setActiveSettingsSection(section) + setSettingsOpen(true) +} + +export function closeSettings() { + setSettingsOpen(false) +} + +export { settingsOpen, activeSettingsSection, setActiveSettingsSection } diff --git a/packages/ui/src/styles/components/folder-drop.css b/packages/ui/src/styles/components/folder-drop.css new file mode 100644 index 00000000..b070e60e --- /dev/null +++ b/packages/ui/src/styles/components/folder-drop.css @@ -0,0 +1,39 @@ +.folder-drop-overlay { + position: absolute; + inset: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: color-mix(in srgb, var(--folder-overlay-bg) 88%, var(--accent-primary) 12%); + backdrop-filter: blur(3px); + pointer-events: none; +} + +.folder-drop-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-sm); + width: min(560px, 100%); + padding: 2rem; + border: 2px dashed var(--accent-primary); + border-radius: var(--radius-xl); + background-color: color-mix(in srgb, var(--surface-base) 92%, var(--accent-primary) 8%); + box-shadow: var(--folder-card-shadow); + text-align: center; +} + +.folder-drop-title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.folder-drop-subtext { + max-width: 32rem; + font-size: var(--font-size-base); + line-height: var(--line-height-relaxed); + color: var(--text-secondary); +} diff --git a/packages/ui/src/styles/components/selector.css b/packages/ui/src/styles/components/selector.css index a65cce76..bcecda00 100644 --- a/packages/ui/src/styles/components/selector.css +++ b/packages/ui/src/styles/components/selector.css @@ -15,6 +15,9 @@ ring-color: var(--accent-primary); } +.selector-trigger:disabled, +.selector-trigger[aria-disabled="true"], +.selector-trigger[data-disabled], .selector-trigger-disabled { @apply opacity-50 cursor-not-allowed; } diff --git a/packages/ui/src/styles/components/settings-screen.css b/packages/ui/src/styles/components/settings-screen.css new file mode 100644 index 00000000..7f6abe56 --- /dev/null +++ b/packages/ui/src/styles/components/settings-screen.css @@ -0,0 +1,538 @@ +.settings-screen-frame { + @apply fixed inset-0 z-50 flex items-center justify-center p-4; +} + +/* Override .modal-surface (defined later in panels.css). */ +.modal-surface.settings-screen-shell { + width: min(1120px, 100%); + height: min(88vh, 920px); + max-height: none; + display: grid; + grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); + overflow: hidden; + border: 1px solid var(--border-base); + border-radius: 0; + box-shadow: 0 32px 96px color-mix(in oklab, var(--overlay-scrim) 55%, transparent); +} + +/* Settings UI uses square corners (no radius). */ +.modal-surface.settings-screen-shell .selector-trigger, +.modal-surface.settings-screen-shell .selector-popover, +.modal-surface.settings-screen-shell .selector-option, +.modal-surface.settings-screen-shell .selector-button, +.modal-surface.settings-screen-shell .selector-input, +.modal-surface.settings-screen-shell .selector-search-input, +.modal-surface.settings-screen-shell .remote-close, +.modal-surface.settings-screen-shell .remote-section, +.modal-surface.settings-screen-shell .remote-refresh, +.modal-surface.settings-screen-shell .remote-toggle, +.modal-surface.settings-screen-shell .remote-toggle-switch, +.modal-surface.settings-screen-shell .remote-toggle-thumb, +.modal-surface.settings-screen-shell .remote-address, +.modal-surface.settings-screen-shell .remote-pill, +.modal-surface.settings-screen-shell .remote-qr, +.modal-surface.settings-screen-shell .remote-card, +.modal-surface.settings-screen-shell .remote-error { + border-radius: 0; +} + +.settings-screen-nav { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.25rem; + background: + linear-gradient(180deg, color-mix(in oklab, var(--surface-secondary) 92%, var(--accent-primary) 8%), var(--surface-secondary)); + border-right: 1px solid var(--border-base); +} + +.settings-screen-nav-header { + padding-bottom: 0.75rem; + border-bottom: 1px solid color-mix(in oklab, var(--border-base) 82%, transparent); +} + +.settings-screen-nav-title-row { + display: flex; + align-items: flex-start; + gap: 0.875rem; +} + +.settings-screen-nav-icon-wrap { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 0; + background: color-mix(in oklab, var(--accent-primary) 16%, var(--surface-base)); + color: var(--accent-primary); +} + +.settings-screen-nav-icon { + width: 1.125rem; + height: 1.125rem; +} + +.settings-screen-title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.settings-screen-subtitle { + margin-top: 0.25rem; + font-size: var(--font-size-sm); + color: var(--text-muted); +} + +.settings-screen-nav-list { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.settings-nav-button { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 0.875rem; + border-radius: 0; + border: 1px solid transparent; + background: transparent; + color: var(--text-secondary); + transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease, transform 140ms ease; + outline: none; +} + +.settings-nav-button:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +.settings-nav-button:hover { + background: color-mix(in oklab, var(--surface-base) 70%, transparent); + color: var(--text-primary); +} + +.settings-nav-button[data-selected="true"] { + background: color-mix(in oklab, var(--accent-primary) 14%, var(--surface-base)); + border-color: color-mix(in oklab, var(--accent-primary) 26%, var(--border-base)); + color: var(--text-primary); + transform: translateX(2px); +} + +.settings-nav-button-icon { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +.settings-screen-content { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + radial-gradient(circle at top right, color-mix(in oklab, var(--accent-primary) 9%, transparent), transparent 28%), + var(--surface-base); +} + +.settings-screen-content-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border-base); + background: color-mix(in oklab, var(--surface-base) 92%, var(--surface-secondary) 8%); + flex-shrink: 0; +} + +.settings-screen-content-header-title-group { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.settings-screen-content-eyebrow { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + +.settings-screen-content-title { + font-size: clamp(1.35rem, 2vw, 1.85rem); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + line-height: 1.2; +} + +.settings-screen-close { + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.settings-screen-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 1rem; +} + +.settings-section-stack, +.settings-panel-body, +.settings-stack { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.settings-card { + padding: 1.25rem; + border: 1px solid var(--border-base); + border-radius: 0; + background: color-mix(in oklab, var(--surface-base) 86%, var(--surface-secondary) 14%); +} + +.settings-card-padless { + padding: 0; + overflow: hidden; +} + +.settings-card-content, +.settings-card-header-padded { + padding: 1rem; +} + +.settings-card-content { + padding-top: 0; +} + +.settings-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid color-mix(in oklab, var(--border-base) 65%, transparent); +} + +.settings-card-heading-with-icon { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.settings-card-heading-icon { + width: 1rem; + height: 1rem; + margin-top: 0.15rem; + color: var(--accent-primary); +} + +.settings-card-title { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.settings-card-subtitle { + margin-top: 0.2rem; + font-size: var(--font-size-sm); + color: var(--text-muted); +} + +.settings-card-message { + padding: 1rem; + border: 1px dashed var(--border-base); + border-radius: 0; + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +.settings-card-content { + padding: 1rem; + border: 1px solid var(--border-base); + border-radius: 0; + background: var(--surface-base); +} + +.settings-help-text { + margin: 0; + color: var(--text-secondary); + font-size: var(--font-size-sm); +} + +.settings-password-actions { + display: flex; + justify-content: flex-start; + margin-top: 0.75rem; +} + +.settings-form-group { + margin-top: 0.75rem; +} + +.settings-form-label { + display: block; + margin-bottom: 0.375rem; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-secondary); +} + +.settings-pill-button { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.875rem; + border-radius: 0; + border: 1px solid var(--border-base); + background: var(--surface-secondary); + color: var(--text-primary); + font-size: var(--font-size-sm); + cursor: pointer; + transition: background-color 140ms ease, border-color 140ms ease; +} + +.settings-pill-button:hover { + background: var(--surface-hover); + border-color: color-mix(in oklab, var(--accent-primary) 28%, var(--border-base)); +} + +.settings-pill-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.settings-error-message { + margin-top: 0.625rem; + padding: 0.75rem; + border: 1px solid var(--border-critical, #e65c5c); + background: color-mix(in srgb, var(--border-critical, #e65c5c) 10%, transparent); + border-radius: 0; + color: var(--text-primary); + font-size: var(--font-size-sm); +} + +.settings-scope-badge { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.6rem; + border-radius: 0; + background: color-mix(in oklab, var(--surface-secondary) 75%, var(--surface-base)); + color: var(--text-secondary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.settings-scope-badge-server { + background: color-mix(in oklab, var(--accent-primary) 12%, var(--surface-base)); + color: var(--accent-primary); +} + +.settings-choice-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.875rem; +} + +.settings-choice { + display: flex; + align-items: center; + gap: 0.875rem; + width: 100%; + padding: 0.95rem; + border-radius: 0; + border: 1px solid var(--border-base); + background: var(--surface-base); + color: var(--text-primary); + text-align: left; + transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; + outline: none; + cursor: pointer; +} + +.settings-choice:hover { + border-color: color-mix(in oklab, var(--accent-primary) 28%, var(--border-base)); + background: var(--surface-hover); +} + +.settings-choice:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +.settings-choice[data-selected="true"] { + border-color: color-mix(in oklab, var(--accent-primary) 45%, var(--border-base)); + background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-base)); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent-primary) 20%, transparent); + transform: translateY(-1px); +} + +.settings-choice-icon-wrap { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 0; + background: color-mix(in oklab, var(--surface-secondary) 76%, var(--surface-base)); + color: var(--accent-primary); + flex-shrink: 0; +} + +.settings-choice-icon { + width: 1rem; + height: 1rem; +} + +.settings-choice-copy { + display: flex; + flex-direction: column; + min-width: 0; +} + +.settings-choice-label { + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); +} + +.settings-choice-description { + margin-top: 0.15rem; + font-size: var(--font-size-xs); + color: var(--text-muted); +} + +.settings-choice-check { + margin-left: auto; + color: var(--accent-primary); + opacity: 0; +} + +.settings-choice[data-selected="true"] .settings-choice-check { + opacity: 1; +} + +.settings-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 0; + border-top: 1px solid color-mix(in oklab, var(--border-base) 78%, transparent); +} + +.settings-toggle-row:first-child { + border-top: none; + padding-top: 0; +} + +.settings-toggle-row-compact { + align-items: flex-start; +} + +.settings-toggle-title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-primary); +} + +.settings-toggle-caption, +.settings-inline-note { + margin-top: 0.2rem; + font-size: var(--font-size-xs); + color: var(--text-muted); +} + +.settings-checkbox-toggle { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + color: var(--text-secondary); + font-size: var(--font-size-sm); +} + +.settings-checkbox-toggle input { + accent-color: var(--accent-primary); +} + +.settings-toolbar-inline { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +@media (max-width: 900px) { + .modal-surface.settings-screen-shell { + min-height: min(760px, calc(100vh - 1rem)); + grid-template-columns: 1fr; + } + + .settings-screen-nav { + gap: 0.75rem; + padding: 1rem; + border-right: none; + border-bottom: 1px solid var(--border-base); + } + + .settings-screen-nav-list { + flex-direction: row; + overflow-x: auto; + padding-bottom: 0.25rem; + } + + .settings-nav-button { + width: auto; + flex-shrink: 0; + } +} + +@media (max-width: 640px) { + .settings-screen-frame { + padding: 0; + } + + .modal-surface.settings-screen-shell { + width: 100%; + height: 100%; + max-height: none; + min-height: 100%; + border-radius: 0; + } + + .settings-screen-content-header, + .settings-screen-scroll { + padding: 0.75rem; + } + + .settings-card-header, + .settings-toggle-row { + flex-direction: column; + align-items: stretch; + } + + .settings-toolbar-inline { + justify-content: flex-start; + } + + .settings-choice-grid { + grid-template-columns: 1fr; + } +} diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index 44f02851..e7862d0a 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -1,5 +1,6 @@ @import "./components/buttons.css"; @import "./components/badges.css"; +@import "./components/folder-drop.css"; @import "./components/folder-loading.css"; @import "./components/dropdown.css"; @import "./components/selector.css"; @@ -7,3 +8,4 @@ @import "./components/directory-browser.css"; @import "./components/remote-access.css"; @import "./components/permission-notification.css"; +@import "./components/settings-screen.css"; diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index c8a92642..0ec9d15a 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -2,6 +2,7 @@ color-scheme: light; /* Surface tokens */ --surface-base: #ffffff; + --surface-primary: var(--surface-base); --surface-secondary: #f5f5f5; --surface-muted: #f8fafc; --surface-code: #f1f5f9; @@ -178,6 +179,7 @@ color-scheme: dark; /* Surface tokens */ --surface-base: #1a1a1a; + --surface-primary: var(--surface-base); --surface-secondary: #2a2a2a; --surface-muted: #212529; --surface-code: #1a1a1a; @@ -347,6 +349,7 @@ color-scheme: dark; /* Surface tokens */ --surface-base: #1a1a1a; + --surface-primary: var(--surface-base); --surface-secondary: #2a2a2a; --surface-muted: #212529; --surface-code: #1a1a1a; diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index e0558004..e0e6c93b 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -27,11 +27,26 @@ declare global { getCliStatus?: () => Promise restartCli?: () => Promise openDialog?: (options: ElectronDialogOptions) => Promise + getDirectoryPaths?: (paths: string[]) => Promise + getPathForFile?: (file: File) => string | null setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }> showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }> } + interface File { + path?: string + } + + interface FileSystemEntry { + isDirectory: boolean + isFile: boolean + } + + interface DataTransferItem { + webkitGetAsEntry?: () => FileSystemEntry | null + } + interface TauriDialogModule { open?: (options: Record) => Promise save?: (options: Record) => Promise @@ -40,6 +55,9 @@ declare global { interface TauriBridge { invoke?: (cmd: string, args?: Record) => Promise dialog?: TauriDialogModule + event?: { + listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void> + } } interface Window {