diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss index b3713c31..bdbb590d 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss @@ -26,40 +26,6 @@ text-align: center; position: relative; overflow: hidden; - - // Background glow effect - &::before { - content: ''; - position: absolute; - top: -60%; - left: 50%; - transform: translateX(-50%); - width: 300px; - height: 200px; - background: radial-gradient(ellipse, rgba(139, 92, 246, 0.1) 0%, transparent 70%); - filter: blur(40px); - pointer-events: none; - animation: about-glow-float 12s ease-in-out infinite; - } - - &::after { - content: ''; - position: absolute; - top: 20%; - right: 5%; - width: 100px; - height: 100px; - background: radial-gradient(circle, rgba(59, 130, 246, 0.06) 0%, transparent 70%); - filter: blur(25px); - pointer-events: none; - animation: about-glow-float 10s ease-in-out infinite reverse; - } -} - -@keyframes about-glow-float { - 0%, 100% { transform: translateX(-50%) translate(0, 0) scale(1); } - 33% { transform: translateX(-50%) translate(15px, -10px) scale(1.03); } - 66% { transform: translateX(-50%) translate(-10px, 8px) scale(0.98); } } .bitfun-about-dialog__title { @@ -69,7 +35,6 @@ margin: 0 0 10px 0; letter-spacing: -0.03em; color: var(--color-text-primary); - text-shadow: 0 0 40px rgba(100, 180, 255, 0.2); position: relative; z-index: 1; animation: about-title-enter 0.5s ease-out 0.1s both; @@ -115,7 +80,7 @@ .bitfun-about-dialog__divider { width: 60px; height: 1px; - background: linear-gradient(90deg, transparent 0%, rgba(139, 92, 246, 0.25) 50%, transparent 100%); + background: var(--border-subtle); margin-top: 18px; position: relative; z-index: 1; @@ -145,13 +110,9 @@ span { width: 3px; height: 3px; - background: linear-gradient(135deg, #8B5CF6, #3B82F6); + background: var(--color-text-muted); border-radius: 50%; - opacity: 0.35; - animation: about-dot-pulse 2.5s ease-in-out infinite; - - &:nth-child(2) { animation-delay: 0.25s; } - &:nth-child(3) { animation-delay: 0.5s; } + opacity: 0.45; } } @@ -160,11 +121,6 @@ to { opacity: 1; } } -@keyframes about-dot-pulse { - 0%, 100% { opacity: 0.25; transform: scale(1); } - 50% { opacity: 0.6; transform: scale(1.3); } -} - // ==================== Scrollable area ==================== .bitfun-about-dialog__scrollable { diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.scss b/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.scss index 71354664..38dd17ff 100644 --- a/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.scss +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.scss @@ -15,8 +15,6 @@ // Small decorative gradient behind icon &__icon { - --gallery-detail-gradient: linear-gradient(135deg, rgba(59,130,246,0.28) 0%, rgba(139,92,246,0.18) 100%); - width: 56px; height: 56px; border-radius: $size-radius-lg; @@ -25,25 +23,9 @@ justify-content: center; flex-shrink: 0; color: var(--color-text-primary); - background: var(--gallery-detail-gradient); - border: 1px solid rgba(255, 255, 255, 0.1); + background: var(--element-bg-soft); + border: 1px solid var(--border-subtle); position: relative; - - // Blurred glow behind icon - &::before { - content: ""; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 80px; - height: 80px; - border-radius: 50%; - background: var(--gallery-detail-gradient); - filter: blur(20px); - z-index: -1; - opacity: 0.6; - } } &__summary { diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index d0dca917..161ae0b1 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -35,14 +35,12 @@ import { useMiniAppCatalogSync } from '../../scenes/miniapps/hooks/useMiniAppCat import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { compareSessionsForDisplay } from '@/flow_chat/utils/sessionOrdering'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; -import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createLogger } from '@/shared/utils/logger'; import { WorkspaceKind } from '@/shared/types'; import { useSSHRemoteContext, SSHConnectionDialog, RemoteFileBrowser } from '@/features/ssh-remote'; -const DEFAULT_MODE_CONFIG_KEY = 'app.session_config.default_mode'; const NAV_DISPLAY_MODE_STORAGE_KEY = 'bitfun.nav.displayMode'; import './NavPanel.scss'; @@ -272,7 +270,6 @@ const MainNav: React.FC = ({ return workspaceSessions[0]?.sessionId; }, [resolvedAssistantWorkspace]); - const [defaultSessionMode, setDefaultSessionMode] = useState<'code' | 'cowork'>('code'); const [navDisplayMode, setNavDisplayMode] = useState(getInitialNavDisplayMode); const [isModeSwitching, setIsModeSwitching] = useState(false); const [modeLogoSrc, setModeLogoSrc] = useState('/panda_1.png'); @@ -281,25 +278,6 @@ const MainNav: React.FC = ({ const modeSwitchTimerRef = useRef(null); const modeSwitchSwapTimerRef = useRef(null); - useEffect(() => { - configManager.getConfig<'code' | 'cowork'>(DEFAULT_MODE_CONFIG_KEY).then(mode => { - if (mode === 'code' || mode === 'cowork') { - setDefaultSessionMode(mode); - setSessionMode(mode); - } - }).catch(() => {}); - - const unwatch = configManager.watch(DEFAULT_MODE_CONFIG_KEY, () => { - configManager.getConfig<'code' | 'cowork'>(DEFAULT_MODE_CONFIG_KEY).then(mode => { - if (mode === 'code' || mode === 'cowork') { - setDefaultSessionMode(mode); - setSessionMode(mode); - } - }).catch(() => {}); - }); - return () => unwatch(); - }, [setSessionMode]); - useEffect(() => () => { if (modeSwitchTimerRef.current !== null) { window.clearTimeout(modeSwitchTimerRef.current); @@ -346,18 +324,12 @@ const MainNav: React.FC = ({ try { await flowChatManager.createChatSession( {}, - mode ?? ( - isAssistantWorkspaceActive - ? 'Claw' - : defaultSessionMode === 'cowork' - ? 'Cowork' - : 'agentic' - ) + mode ?? (isAssistantWorkspaceActive ? 'Claw' : 'agentic') ); } catch (err) { log.error('Failed to create session', err); } - }, [openScene, switchLeftPanelTab, defaultSessionMode, isAssistantWorkspaceActive]); + }, [openScene, switchLeftPanelTab, isAssistantWorkspaceActive]); const handleCreateCodeSession = useCallback(() => { setSessionMode('code'); @@ -897,9 +869,7 @@ const MainNav: React.FC = ({ tab === 'sessions' ? isAssistantWorkspaceActive ? t('nav.sessions.newClawSession') - : defaultSessionMode === 'cowork' - ? t('nav.sessions.newCoworkSession') - : t('nav.sessions.newCodeSession') + : t('nav.sessions.newCodeSession') : undefined } onActionClick={tab === 'sessions' ? handleCreateSession : undefined} diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss index 1aa4f0f9..e7140a05 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss @@ -21,26 +21,6 @@ text-align: center; position: relative; overflow: hidden; - - // Background glow effect - &::before { - content: ''; - position: absolute; - top: -80%; - left: 50%; - transform: translateX(-50%); - width: 250px; - height: 150px; - background: radial-gradient(ellipse, rgba(34, 197, 94, 0.08) 0%, transparent 70%); - filter: blur(30px); - pointer-events: none; - animation: new-project-glow 10s ease-in-out infinite; - } - } - - @keyframes new-project-glow { - 0%, 100% { transform: translateX(-50%) scale(1); opacity: 0.8; } - 50% { transform: translateX(-50%) scale(1.1); opacity: 1; } } &__icon-wrapper { diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx index 9e7a79a4..174f3210 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx @@ -5,7 +5,7 @@ * AI Agent tab shows the current session title as a subtitle. */ -import React, { useCallback, useRef, useState, useEffect } from 'react'; +import React, { useCallback, useRef } from 'react'; import SceneTab from './SceneTab'; import { WindowControls } from '@/component-library'; import { useSceneManager } from '../../hooks/useSceneManager'; @@ -13,12 +13,9 @@ import { useCurrentSessionTitle } from '../../hooks/useCurrentSessionTitle'; import { useCurrentSettingsTabTitle } from '../../hooks/useCurrentSettingsTabTitle'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; -import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { createLogger } from '@/shared/utils/logger'; import './SceneBar.scss'; -const DEFAULT_MODE_CONFIG_KEY = 'app.session_config.default_mode'; - const log = createLogger('SceneBar'); const INTERACTIVE_SELECTOR = @@ -43,20 +40,6 @@ const SceneBar: React.FC = ({ const sessionTitle = useCurrentSessionTitle(); const settingsTabTitle = useCurrentSettingsTabTitle(); const { t } = useI18n('common'); - const [defaultMode, setDefaultMode] = useState<'code' | 'cowork'>('code'); - - useEffect(() => { - configManager.getConfig<'code' | 'cowork'>(DEFAULT_MODE_CONFIG_KEY).then(mode => { - if (mode === 'code' || mode === 'cowork') setDefaultMode(mode); - }).catch(() => {}); - const unwatch = configManager.watch(DEFAULT_MODE_CONFIG_KEY, () => { - configManager.getConfig<'code' | 'cowork'>(DEFAULT_MODE_CONFIG_KEY).then(mode => { - if (mode === 'code' || mode === 'cowork') setDefaultMode(mode); - }).catch(() => {}); - }); - return () => unwatch(); - }, []); - const hasWindowControls = !!(onMinimize && onMaximize && onClose); const sceneBarClassName = `bitfun-scene-bar ${!hasWindowControls ? 'bitfun-scene-bar--no-controls' : ''} ${className}`.trim(); const isSingleTab = openTabs.length <= 1; @@ -122,7 +105,7 @@ const SceneBar: React.FC = ({ const subtitle = (tab.id === 'session' && sessionTitle ? sessionTitle : undefined) ?? (tab.id === 'settings' && settingsTabTitle ? settingsTabTitle : undefined); - const actionTitle = tab.id === 'session' ? (defaultMode === 'cowork' ? t('nav.sessions.newCoworkSession') : t('nav.sessions.newCodeSession')) : undefined; + const actionTitle = tab.id === 'session' ? t('nav.sessions.newCodeSession') : undefined; return ( { - try { - const defaultMode = await configManager.getConfig(DEFAULT_MODE_CONFIG_KEY); - return defaultMode === 'cowork' ? 'Cowork' : 'agentic'; - } catch (error) { - log.warn('Failed to load default session mode, falling back to code', error); - return 'agentic'; - } -} - const AppLayout: React.FC = ({ className = '' }) => { const { t } = useI18n('components'); const { currentWorkspace, hasWorkspace, openWorkspace, recentWorkspaces, loading } = useWorkspaceContext(); @@ -201,7 +187,7 @@ const AppLayout: React.FC = ({ className = '' }) => { const initialSessionMode = currentWorkspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' - : explicitPreferredMode || await resolveDefaultSessionAgentType(); + : explicitPreferredMode || 'agentic'; sessionId = await flowChatManager.createChatSession({}, initialSessionMode); } diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.scss b/src/web-ui/src/app/scenes/settings/SettingsNav.scss index 35fb8bad..624cfc29 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.scss +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.scss @@ -32,6 +32,128 @@ text-overflow: ellipsis; } + // ── Search ─────────────────────────────────────────────── + + &__search { + flex-shrink: 0; + padding: 0 $size-gap-3 $size-gap-2; + } + + /** Component-library `Search` — align with nav tokens + subtle focus scale */ + &__search-field.search { + width: 100%; + + .search__wrapper { + border-color: var(--border-subtle); + background: var(--element-bg-soft); + border-radius: $size-radius-base; + transition: border-color $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard, + transform $motion-fast $easing-standard; + } + + &.search--focused:not(.search--disabled) .search__wrapper { + border-color: var(--color-primary); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 35%, transparent); + transform: scale(1.01); + } + + &.search--focused:not(.search--disabled) .search__prefix .search__icon { + color: var(--color-primary); + } + } + + &__search-results { + display: flex; + flex-direction: column; + padding: 2px $size-gap-2; + gap: 2px; + } + + &__search-result-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + min-height: auto; + padding: $size-gap-2 $size-gap-2 $size-gap-2 $size-gap-3; + border: none; + border-radius: $size-radius-base; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + text-align: left; + font-size: $font-size-sm; + font-weight: 400; + width: 100%; + position: relative; + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + &:hover { + color: var(--color-text-primary); + background: var(--element-bg-soft); + } + + &.is-highlighted { + color: var(--color-text-primary); + background: var(--element-bg-soft); + } + + &.is-active { + color: var(--color-text-primary); + background: color-mix(in srgb, var(--color-primary) 10%, transparent); + font-weight: 500; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 2px; + height: 16px; + background: var(--color-primary); + border-radius: 0 2px 2px 0; + } + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: -1px; + } + } + + &__search-result-line { + width: 100%; + line-height: 1.35; + font-weight: 500; + } + + &__search-result-desc { + width: 100%; + font-size: 11px; + line-height: 1.35; + color: var(--color-text-muted); + font-weight: 400; + } + + &__search-empty { + padding: $size-gap-3 $size-gap-3; + font-size: $font-size-sm; + color: var(--color-text-muted); + text-align: center; + } + + &__search-highlight { + padding: 0; + margin: 0; + background: color-mix(in srgb, var(--color-primary) 28%, transparent); + color: inherit; + font-weight: inherit; + border-radius: 2px; + } + // ── Scrollable sections ──────────────────────────────── &__sections { @@ -135,5 +257,12 @@ @media (prefers-reduced-motion: reduce) { .bitfun-settings-nav { &__item { transition: none; } + &__search-field.search .search__wrapper { + transition: none; + } + &__search-field.search.search--focused:not(.search--disabled) .search__wrapper { + transform: none; + } + &__search-result-item { transition: none; } } } diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx index e77e5285..1f798457 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx @@ -3,69 +3,386 @@ * * Layout: * ┌──────────────────────┐ - * │ ← Settings │ header: back button + title + * │ Settings │ header: title * ├──────────────────────┤ - * │ Category │ - * │ › Tab item │ scrollable nav list - * │ › Tab item │ - * │ ... │ + * │ Search… │ filter config tabs + * ├──────────────────────┤ + * │ Category / results │ * └──────────────────────┘ - * - * Clicking "back" closes the Settings scene (sceneStore.closeScene) which - * automatically restores the previously-active scene and its nav. */ -import React, { useCallback } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { i18n as I18nApi } from 'i18next'; import { useTranslation } from 'react-i18next'; +import { Search } from '@/component-library'; import { useSettingsStore } from './settingsStore'; import { SETTINGS_CATEGORIES } from './settingsConfig'; import type { ConfigTab } from './settingsConfig'; +import { SETTINGS_TAB_SEARCH_CONTENT } from './settingsTabSearchContent'; import './SettingsNav.scss'; -const SettingsNav: React.FC = () => { - const { t } = useTranslation('settings'); - const { activeTab, setActiveTab } = useSettingsStore(); +const SEARCH_DEBOUNCE_MS = 150; + +export interface SettingsSearchRow { + tabId: ConfigTab; + categoryId: string; + categoryLabel: string; + tabLabel: string; + description: string; + haystack: string; +} + +function resolveTabPageContentHaystack(i18n: I18nApi, tabId: ConfigTab): string { + const phrases = SETTINGS_TAB_SEARCH_CONTENT[tabId]; + if (!phrases?.length) return ''; + const lang = i18n.language; + const parts: string[] = []; + for (const { ns, key } of phrases) { + const tNs = i18n.getFixedT(lang, ns); + const text = tNs(key, { defaultValue: '' }); + if (typeof text === 'string' && text.trim()) { + parts.push(text); + } + } + return parts.join(' '); +} + +function buildSettingsSearchIndex( + t: (key: string, options?: Record) => string, + i18n: I18nApi +): SettingsSearchRow[] { + const rows: SettingsSearchRow[] = []; + for (const cat of SETTINGS_CATEGORIES) { + const categoryLabel = t(cat.nameKey, { defaultValue: cat.id }); + for (const tabDef of cat.tabs) { + const tabLabel = t(tabDef.labelKey, { defaultValue: tabDef.id }); + const description = tabDef.descriptionKey + ? t(tabDef.descriptionKey, { defaultValue: '' }) + : ''; + const kw = (tabDef.keywords ?? []).join(' '); + const pageContent = resolveTabPageContentHaystack(i18n, tabDef.id); + const haystack = [categoryLabel, tabLabel, description, kw, tabDef.id, pageContent] + .join(' ') + .toLowerCase(); + rows.push({ + tabId: tabDef.id, + categoryId: cat.id, + categoryLabel, + tabLabel, + description, + haystack, + }); + } + } + return rows; +} + +function useSettingsSearch( + t: (key: string, options?: Record) => string, + i18n: I18nApi, + debouncedQuery: string +): SettingsSearchRow[] { + const index = useMemo( + () => buildSettingsSearchIndex(t, i18n), + [t, i18n, i18n.language] + ); + + return useMemo(() => { + const q = debouncedQuery.trim().toLowerCase(); + if (!q) return []; + return index.filter((row) => row.haystack.includes(q)); + }, [index, debouncedQuery]); +} + +function highlightFirstMatch(text: string, query: string): React.ReactNode { + const q = query.trim(); + if (!q) return text; + const lower = text.toLowerCase(); + const qi = q.toLowerCase(); + const idx = lower.indexOf(qi); + if (idx < 0) return text; + return ( + <> + {text.slice(0, idx)} + + {text.slice(idx, idx + qi.length)} + + {text.slice(idx + qi.length)} + + ); +} + +function useSettingsNav() { + const { t, i18n } = useTranslation('settings'); + const activeTab = useSettingsStore((s) => s.activeTab); + const setActiveTab = useSettingsStore((s) => s.setActiveTab); + const searchQuery = useSettingsStore((s) => s.searchQuery); + const setSearchQuery = useSettingsStore((s) => s.setSearchQuery); + + const [draftQuery, setDraftQuery] = useState(''); + const searchInputRef = useRef(null); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const resultsRef = useRef(null); + + useEffect(() => { + const id = window.setTimeout(() => { + setSearchQuery(draftQuery); + }, SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(id); + }, [draftQuery, setSearchQuery]); + + const results = useSettingsSearch(t, i18n, searchQuery); + const isSearchMode = draftQuery.trim().length > 0; + + useEffect(() => { + setHighlightedIndex((prev) => { + if (results.length === 0) return -1; + if (prev >= results.length) return results.length - 1; + return prev; + }); + }, [results.length]); - const handleTabClick = useCallback((tab: ConfigTab) => { - setActiveTab(tab); - }, [setActiveTab]); + /** Sync store / highlight when library Search clears via button or Escape (after onChange). */ + const handleSearchComponentClear = useCallback(() => { + setSearchQuery(''); + setHighlightedIndex(-1); + }, [setSearchQuery]); + + const clearSearch = useCallback(() => { + setDraftQuery(''); + setSearchQuery(''); + setHighlightedIndex(-1); + searchInputRef.current?.focus(); + }, [setSearchQuery]); + + const activateTab = useCallback( + (tab: ConfigTab) => { + setActiveTab(tab); + clearSearch(); + }, + [setActiveTab, clearSearch] + ); + + const handleTabClick = useCallback( + (tab: ConfigTab) => { + setActiveTab(tab); + }, + [setActiveTab] + ); + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + clearSearch(); + return; + } + if (e.key === 'ArrowDown' && results.length > 0) { + e.preventDefault(); + setHighlightedIndex(0); + queueMicrotask(() => resultsRef.current?.focus()); + return; + } + if (e.key === 'Enter' && results.length === 1) { + e.preventDefault(); + activateTab(results[0].tabId); + } + }, + [clearSearch, results, activateTab, resultsRef] + ); + + const handleResultsKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!isSearchMode || results.length === 0) return; + + if (e.key === 'Escape') { + e.preventDefault(); + clearSearch(); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightedIndex((i) => Math.min(i + 1, results.length - 1)); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightedIndex((i) => { + if (i <= 0) { + searchInputRef.current?.focus(); + return -1; + } + return i - 1; + }); + return; + } + if (e.key === 'Enter' && highlightedIndex >= 0 && highlightedIndex < results.length) { + e.preventDefault(); + activateTab(results[highlightedIndex].tabId); + } + }, + [isSearchMode, results, highlightedIndex, activateTab, clearSearch] + ); + + const displayQuery = searchQuery.trim(); + + return { + t, + activeTab, + handleTabClick, + draftQuery, + setDraftQuery, + searchInputRef, + resultsRef, + results, + isSearchMode, + displayQuery, + highlightedIndex, + setHighlightedIndex, + handleSearchComponentClear, + activateTab, + handleSearchKeyDown, + handleResultsKeyDown, + }; +} + +const SettingsNav: React.FC = () => { + const { + t, + activeTab, + handleTabClick, + draftQuery, + setDraftQuery, + searchInputRef, + resultsRef, + results, + isSearchMode, + displayQuery, + highlightedIndex, + setHighlightedIndex, + handleSearchComponentClear, + activateTab, + handleSearchKeyDown, + handleResultsKeyDown, + } = useSettingsNav(); return (
- {/* Header: title only — back/forward handled by NavBar */}
- {t('configCenter.title', 'Settings')} + {t('configCenter.title', { defaultValue: t('title', { defaultValue: 'Settings' }) })}
- {/* Scrollable category + tab list */} -
- {SETTINGS_CATEGORIES.map(category => ( -
-
- - {t(category.nameKey, category.id)} - -
+
+ +
+ +
0 ? 0 : undefined} + onKeyDown={handleResultsKeyDown} + aria-activedescendant={ + isSearchMode && highlightedIndex >= 0 + ? `settings-nav-result-${results[highlightedIndex]?.tabId}` + : undefined + } + > + {isSearchMode ? ( + <> + {results.length === 0 ? ( +
+ {t('configCenter.searchNoResults')} +
+ ) : ( +
+ {results.map((row, index) => { + const line = `${row.categoryLabel} › ${row.tabLabel}`; + const active = activeTab === row.tabId; + const highlighted = highlightedIndex === index; + return ( + + ); + })} +
+ )} + + ) : ( + SETTINGS_CATEGORIES.map((category) => ( +
+
+ + {t(category.nameKey, { defaultValue: category.id })} + +
-
- {category.tabs.map(tabDef => ( - - ))} +
+ {category.tabs.map((tabDef) => ( + + ))} +
-
- ))} + )) + )}
); diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index bad7a09c..9bf7d587 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -14,27 +14,21 @@ const AIModelConfig = lazy(() => import('../../../infrastructure/config/c const SessionConfig = lazy(() => import('../../../infrastructure/config/components/SessionConfig')); const AIRulesMemoryConfig = lazy(() => import('../../../infrastructure/config/components/AIRulesMemoryConfig')); const McpToolsConfig = lazy(() => import('../../../infrastructure/config/components/McpToolsConfig')); -const LspConfig = lazy(() => import('../../../infrastructure/config/components/LspConfig')); -const DebugConfig = lazy(() => import('../../../infrastructure/config/components/DebugConfig')); -const LoggingConfig = lazy(() => import('../../../infrastructure/config/components/LoggingConfig')); -const TerminalConfig = lazy(() => import('../../../infrastructure/config/components/TerminalConfig')); +// const LspConfig = lazy(() => import('../../../infrastructure/config/components/LspConfig')); const EditorConfig = lazy(() => import('../../../infrastructure/config/components/EditorConfig')); -const ThemeConfigComponent = lazy(() => import('../../../infrastructure/config/components/ThemeConfig').then(m => ({ default: m.ThemeConfig }))); +const BasicsConfig = lazy(() => import('../../../infrastructure/config/components/BasicsConfig')); const SettingsScene: React.FC = () => { const activeTab = useSettingsStore(s => s.activeTab); let Content: React.LazyExoticComponent | null = null; switch (activeTab) { - case 'theme': Content = ThemeConfigComponent; break; + case 'basics': Content = BasicsConfig; break; case 'models': Content = AIModelConfig; break; case 'session-config': Content = SessionConfig; break; case 'ai-context': Content = AIRulesMemoryConfig; break; case 'mcp-tools': Content = McpToolsConfig; break; - case 'lsp': Content = LspConfig; break; - case 'debug': Content = DebugConfig; break; - case 'logging': Content = LoggingConfig; break; - case 'terminal': Content = TerminalConfig; break; + // case 'lsp': Content = LspConfig; break; case 'editor': Content = EditorConfig; break; } diff --git a/src/web-ui/src/app/scenes/settings/settingsConfig.ts b/src/web-ui/src/app/scenes/settings/settingsConfig.ts index 1ae5dd7c..3917afac 100644 --- a/src/web-ui/src/app/scenes/settings/settingsConfig.ts +++ b/src/web-ui/src/app/scenes/settings/settingsConfig.ts @@ -6,20 +6,21 @@ */ export type ConfigTab = - | 'theme' + | 'basics' | 'models' | 'session-config' | 'ai-context' | 'mcp-tools' - | 'lsp' - | 'debug' - | 'logging' - | 'terminal' + // | 'lsp' // temporarily hidden from config center | 'editor'; export interface ConfigTabDef { id: ConfigTab; labelKey: string; + /** i18n key under settings namespace for tab description (search + discoverability). */ + descriptionKey?: string; + /** Language-neutral extra tokens matched by search (ASCII recommended). */ + keywords?: string[]; } export interface ConfigCategoryDef { @@ -33,30 +34,114 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ id: 'general', nameKey: 'configCenter.categories.general', tabs: [ - { id: 'theme', labelKey: 'configCenter.tabs.theme' }, - { id: 'models', labelKey: 'configCenter.tabs.models' }, + { + id: 'basics', + labelKey: 'configCenter.tabs.basics', + descriptionKey: 'configCenter.tabDescriptions.basics', + keywords: [ + 'language', + 'locale', + 'i18n', + 'theme', + 'appearance', + 'logging', + 'log', + 'terminal', + 'shell', + 'pwsh', + 'powershell', + ], + }, + { + id: 'models', + labelKey: 'configCenter.tabs.models', + descriptionKey: 'configCenter.tabDescriptions.models', + keywords: [ + 'api', + 'api key', + 'provider', + 'openai', + 'claude', + 'gpt', + 'base url', + 'proxy', + 'model', + 'temperature', + 'token', + ], + }, ], }, { id: 'smartCapabilities', nameKey: 'configCenter.categories.smartCapabilities', tabs: [ - { id: 'session-config', labelKey: 'configCenter.tabs.sessionConfig' }, - { id: 'ai-context', labelKey: 'configCenter.tabs.aiContext' }, - { id: 'mcp-tools', labelKey: 'configCenter.tabs.mcpTools' }, + { + id: 'session-config', + labelKey: 'configCenter.tabs.sessionConfig', + descriptionKey: 'configCenter.tabDescriptions.sessionConfig', + keywords: [ + 'session', + 'chat', + 'streaming', + 'tool', + 'timeout', + 'confirmation', + 'history', + ], + }, + { + id: 'ai-context', + labelKey: 'configCenter.tabs.aiContext', + descriptionKey: 'configCenter.tabDescriptions.aiContext', + keywords: ['rules', 'memory', 'context', 'rag', 'knowledge'], + }, + { + id: 'mcp-tools', + labelKey: 'configCenter.tabs.mcpTools', + descriptionKey: 'configCenter.tabDescriptions.mcpTools', + keywords: ['mcp', 'server', 'plugin', 'stdio', 'sse', 'tools'], + }, ], }, { id: 'devkit', nameKey: 'configCenter.categories.devkit', tabs: [ - { id: 'editor', labelKey: 'configCenter.tabs.editor' }, - // { id: 'lsp', labelKey: 'configCenter.tabs.lsp' }, - { id: 'debug', labelKey: 'configCenter.tabs.debug' }, - { id: 'terminal',labelKey: 'configCenter.tabs.terminal'}, - { id: 'logging', labelKey: 'configCenter.tabs.logging' }, + { + id: 'editor', + labelKey: 'configCenter.tabs.editor', + descriptionKey: 'configCenter.tabDescriptions.editor', + keywords: [ + 'font', + 'indent', + 'tab', + 'minimap', + 'word wrap', + 'line number', + 'format', + 'save', + ], + }, + // LSP / language server settings — temporarily hidden from nav + // { + // id: 'lsp', + // labelKey: 'configCenter.tabs.lsp', + // descriptionKey: 'configCenter.tabDescriptions.lsp', + // keywords: ['lsp', 'language server', 'typescript', 'intellisense'], + // }, ], }, ]; export const DEFAULT_SETTINGS_TAB: ConfigTab = 'models'; + +const KNOWN_TABS: ConfigTab[] = SETTINGS_CATEGORIES.flatMap((c) => c.tabs.map((t) => t.id)); + +/** Map removed or renamed tabs; used by deep links and IDE actions. */ +export function normalizeSettingsTab(section: string): ConfigTab { + if (section === 'theme' || section === 'logging' || section === 'terminal') return 'basics'; + if (section === 'lsp') return DEFAULT_SETTINGS_TAB; + if ((KNOWN_TABS as readonly string[]).includes(section)) return section as ConfigTab; + return DEFAULT_SETTINGS_TAB; +} diff --git a/src/web-ui/src/app/scenes/settings/settingsStore.ts b/src/web-ui/src/app/scenes/settings/settingsStore.ts index 1c265760..0a468f17 100644 --- a/src/web-ui/src/app/scenes/settings/settingsStore.ts +++ b/src/web-ui/src/app/scenes/settings/settingsStore.ts @@ -12,12 +12,17 @@ import { DEFAULT_SETTINGS_TAB, SETTINGS_CATEGORIES } from './settingsConfig'; interface SettingsState { activeTab: ConfigTab; setActiveTab: (tab: ConfigTab) => void; + /** Debounced from SettingsNav search input; used for filtering index. */ + searchQuery: string; + setSearchQuery: (query: string) => void; } export const useSettingsStore = create((set) => ({ activeTab: DEFAULT_SETTINGS_TAB, + searchQuery: '', setActiveTab: (tab) => set({ activeTab: tab }), + setSearchQuery: (query) => set({ searchQuery: query }), })); /** Resolve the category id for a given tab (for initial scroll / highlight) */ diff --git a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts new file mode 100644 index 00000000..4642c9ac --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts @@ -0,0 +1,97 @@ +/** + * i18n keys for in-page section titles/descriptions (and related copy) per settings tab. + * Used by SettingsNav search so queries match content inside each config page. + * + * Keep in sync when adding ConfigPageSection / page headers on these tabs. + */ + +import type { ConfigTab } from './settingsConfig'; + +export interface SettingsTabSearchPhrase { + ns: string; + key: string; +} + +/** Phrases resolved at runtime with i18n.getFixedT(lang, ns)(key). */ +export const SETTINGS_TAB_SEARCH_CONTENT: Record = { + basics: [ + { ns: 'settings/basics', key: 'title' }, + { ns: 'settings/basics', key: 'subtitle' }, + { ns: 'settings/basics', key: 'appearance.title' }, + { ns: 'settings/basics', key: 'appearance.hint' }, + { ns: 'settings/basics', key: 'logging.sections.logging' }, + { ns: 'settings/basics', key: 'logging.sections.loggingHint' }, + { ns: 'settings/basics', key: 'terminal.sections.terminal' }, + { ns: 'settings/basics', key: 'terminal.sections.terminalHint' }, + ], + + models: [ + { ns: 'settings/ai-model', key: 'title' }, + { ns: 'settings/ai-model', key: 'subtitle' }, + { ns: 'settings/default-model', key: 'tabs.default' }, + { ns: 'settings/default-model', key: 'subtitle' }, + { ns: 'settings/default-model', key: 'tabs.models' }, + { ns: 'settings/ai-model', key: 'subtitle' }, + { ns: 'settings/default-model', key: 'tabs.proxy' }, + { ns: 'settings/ai-model', key: 'proxy.enableHint' }, + ], + + 'session-config': [ + { ns: 'settings/session-config', key: 'title' }, + { ns: 'settings/session-config', key: 'subtitle' }, + { ns: 'settings/session-config', key: 'features.sessionTitle.title' }, + { ns: 'settings/session-config', key: 'features.sessionTitle.subtitle' }, + { ns: 'settings/session-config', key: 'toolExecution.sectionTitle' }, + { ns: 'settings/session-config', key: 'toolExecution.sectionDescription' }, + { ns: 'settings/agentic-tools', key: 'config.autoExecute' }, + { ns: 'settings/agentic-tools', key: 'config.autoExecuteDesc' }, + { ns: 'settings/agentic-tools', key: 'config.confirmTimeout' }, + { ns: 'settings/agentic-tools', key: 'config.confirmTimeoutDesc' }, + { ns: 'settings/agentic-tools', key: 'config.executionTimeout' }, + { ns: 'settings/agentic-tools', key: 'config.executionTimeoutDesc' }, + { ns: 'settings/debug', key: 'sections.combined' }, + { ns: 'settings/debug', key: 'sections.combinedDescription' }, + { ns: 'settings/debug', key: 'settings.logPath.label' }, + { ns: 'settings/debug', key: 'settings.logPath.description' }, + { ns: 'settings/debug', key: 'settings.ingestPort.label' }, + { ns: 'settings/debug', key: 'settings.ingestPort.description' }, + { ns: 'settings/debug', key: 'sections.templates' }, + { ns: 'settings/debug', key: 'templates.description' }, + ], + + 'ai-context': [ + { ns: 'settings/ai-context', key: 'title' }, + { ns: 'settings/ai-context', key: 'subtitle' }, + { ns: 'settings/ai-context', key: 'scope.user' }, + { ns: 'settings/ai-context', key: 'scope.project' }, + { ns: 'settings/ai-context', key: 'memoryProjectPlaceholder' }, + { ns: 'settings/ai-rules', key: 'title' }, + { ns: 'settings/ai-rules', key: 'subtitle' }, + { ns: 'settings/ai-memory', key: 'section.memoryList.title' }, + { ns: 'settings/ai-memory', key: 'section.memoryList.description' }, + ], + + 'mcp-tools': [ + { ns: 'settings/mcp-tools', key: 'title' }, + { ns: 'settings/mcp-tools', key: 'subtitle' }, + { ns: 'settings/mcp', key: 'section.serverList.title' }, + { ns: 'settings/mcp', key: 'section.serverList.description' }, + ], + + editor: [ + { ns: 'settings/editor', key: 'title' }, + { ns: 'settings/editor', key: 'subtitle' }, + { ns: 'settings/editor', key: 'sections.appearance.title' }, + { ns: 'settings/editor', key: 'sections.appearance.description' }, + { ns: 'settings/editor', key: 'sections.behavior.title' }, + { ns: 'settings/editor', key: 'sections.behavior.description' }, + { ns: 'settings/editor', key: 'sections.display.title' }, + { ns: 'settings/editor', key: 'sections.display.description' }, + { ns: 'settings/editor', key: 'sections.advanced.title' }, + { ns: 'settings/editor', key: 'sections.advanced.description' }, + { ns: 'settings/editor', key: 'actions.save' }, + { ns: 'settings/editor', key: 'actions.saveDesc' }, + ], + + // lsp: [ ... ], // nav entry temporarily hidden; omit from search index +}; diff --git a/src/web-ui/src/component-library/components/Modal/Modal.scss b/src/web-ui/src/component-library/components/Modal/Modal.scss index b856cefb..87553801 100644 --- a/src/web-ui/src/component-library/components/Modal/Modal.scss +++ b/src/web-ui/src/component-library/components/Modal/Modal.scss @@ -54,9 +54,7 @@ background: var(--color-bg-primary); border: 1px solid var(--border-subtle); border-radius: 8px; - box-shadow: - 0 24px 48px rgba(0, 0, 0, 0.5), - 0 0 0 1px rgba(255, 255, 255, 0.03); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); max-height: calc(100vh - 48px); overflow: hidden; animation: modal-dialog-enter 0.3s cubic-bezier(0.16, 1, 0.3, 1); @@ -90,16 +88,12 @@ transition: box-shadow 0.2s ease; &:hover { - box-shadow: - 0 28px 56px rgba(0, 0, 0, 0.55), - 0 0 0 1px rgba(255, 255, 255, 0.05); + box-shadow: 0 20px 44px rgba(0, 0, 0, 0.5); } } &--dragging { - box-shadow: - 0 32px 64px rgba(0, 0, 0, 0.6), - 0 0 0 1px rgba(99, 102, 241, 0.2); + box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55); z-index: tokens.$z-modal-active; } @@ -109,6 +103,7 @@ display: flex; align-items: center; justify-content: space-between; + gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--border-subtle); background: transparent; @@ -129,6 +124,7 @@ align-items: center; gap: 6px; min-width: 0; + flex: 1; } &__title { @@ -136,12 +132,15 @@ font-size: 12px; font-weight: 500; color: var(--color-text-secondary); + min-width: 0; + flex-shrink: 1; } &__title-extra { display: flex; align-items: center; flex-shrink: 0; + margin-left: auto; } @@ -282,7 +281,7 @@ &--se { bottom: 0; right: 0; cursor: se-resize; } &:hover { - background-color: rgba(99, 102, 241, 0.15); + background-color: var(--element-bg-subtle); } } diff --git a/src/web-ui/src/component-library/components/Search/Search.tsx b/src/web-ui/src/component-library/components/Search/Search.tsx index 21d7a12a..18d7a8db 100644 --- a/src/web-ui/src/component-library/components/Search/Search.tsx +++ b/src/web-ui/src/component-library/components/Search/Search.tsx @@ -14,6 +14,8 @@ export interface SearchProps { onChange?: (value: string) => void; onSearch?: (value: string) => void; onClear?: () => void; + /** Fired before built-in key handling; call `preventDefault` to skip Enter/Escape behavior. */ + onKeyDown?: React.KeyboardEventHandler; onFocus?: () => void; onBlur?: () => void; size?: 'small' | 'medium' | 'large'; @@ -30,6 +32,10 @@ export interface SearchProps { searchButtonText?: string; showSearchButton?: boolean; suffixContent?: React.ReactNode; + /** Overrides default aria-label on the input. */ + inputAriaLabel?: string; + ariaControls?: string; + ariaExpanded?: boolean; } export const Search = forwardRef(({ @@ -40,6 +46,7 @@ export const Search = forwardRef(({ onChange, onSearch, onClear, + onKeyDown: onKeyDownProp, onFocus: onFocusProp, onBlur: onBlurProp, size = 'medium', @@ -56,6 +63,9 @@ export const Search = forwardRef(({ searchButtonText, showSearchButton = false, suffixContent, + inputAriaLabel, + ariaControls, + ariaExpanded, }, ref) => { const { t } = useI18n('components'); @@ -104,8 +114,8 @@ export const Search = forwardRef(({ } }, [inputValue, onSearch, disabled, loading]); - const handleClear = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); + const handleClear = useCallback((e?: React.SyntheticEvent) => { + e?.stopPropagation(); setInputValue(''); onChange?.(''); onClear?.(); @@ -113,18 +123,22 @@ export const Search = forwardRef(({ }, [onChange, onClear]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + onKeyDownProp?.(e); + if (e.defaultPrevented) { + return; + } if (e.key === 'Enter' && enterToSearch) { e.preventDefault(); handleSearch(); } else if (e.key === 'Escape') { e.preventDefault(); if (inputValue) { - handleClear(e as any); + handleClear(e); } else { inputRef.current?.blur(); } } - }, [inputValue, enterToSearch, handleSearch, handleClear]); + }, [inputValue, enterToSearch, handleSearch, handleClear, onKeyDownProp]); const handleFocus = useCallback(() => { setIsFocused(true); @@ -225,7 +239,9 @@ export const Search = forwardRef(({ onKeyDown={handleKeyDown} onFocus={handleFocus} onBlur={handleBlur} - aria-label={t('search.placeholder')} + aria-label={inputAriaLabel ?? t('search.placeholder')} + aria-controls={ariaControls} + aria-expanded={ariaExpanded} /> {clearable && inputValue && !loading && !disabled && ( diff --git a/src/web-ui/src/flow_chat/components/CurrentSessionTitle.tsx b/src/web-ui/src/flow_chat/components/CurrentSessionTitle.tsx index b755f2b6..30c4642c 100644 --- a/src/web-ui/src/flow_chat/components/CurrentSessionTitle.tsx +++ b/src/web-ui/src/flow_chat/components/CurrentSessionTitle.tsx @@ -4,11 +4,8 @@ import { Plus } from 'lucide-react'; import { flowChatStore } from '../store/FlowChatStore'; import { FlowChatState, Session } from '../types/flow-chat'; import { Tooltip } from '@/component-library'; -import { configManager } from '@/infrastructure/config/services/ConfigManager'; import './CurrentSessionTitle.scss'; -const DEFAULT_MODE_CONFIG_KEY = 'app.session_config.default_mode'; - interface CurrentSessionTitleProps { onCreateSession?: () => void; } @@ -22,8 +19,6 @@ const CurrentSessionTitle: React.FC = ({ onCreateSessi const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState() ); - const [defaultMode, setDefaultMode] = useState<'code' | 'cowork'>('code'); - // Subscribe to FlowChatStore updates to keep the title in sync. useEffect(() => { const unsubscribe = flowChatStore.subscribe((state) => { @@ -32,18 +27,6 @@ const CurrentSessionTitle: React.FC = ({ onCreateSessi return () => unsubscribe(); }, []); - useEffect(() => { - configManager.getConfig<'code' | 'cowork'>(DEFAULT_MODE_CONFIG_KEY).then(mode => { - if (mode === 'code' || mode === 'cowork') setDefaultMode(mode); - }).catch(() => {}); - const unwatch = configManager.watch(DEFAULT_MODE_CONFIG_KEY, () => { - configManager.getConfig<'code' | 'cowork'>(DEFAULT_MODE_CONFIG_KEY).then(mode => { - if (mode === 'code' || mode === 'cowork') setDefaultMode(mode); - }).catch(() => {}); - }); - return () => unwatch(); - }, []); - const activeSession: Session | undefined = flowChatState.activeSessionId ? flowChatState.sessions.get(flowChatState.activeSessionId) : undefined; @@ -64,7 +47,7 @@ const CurrentSessionTitle: React.FC = ({ onCreateSessi } }; - const newSessionLabel = defaultMode === 'cowork' ? t('session.newCowork') : t('session.newCode'); + const newSessionLabel = t('session.newCode'); return (
diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx index ba8714a4..15a3827d 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx @@ -33,11 +33,8 @@ import { createLogger } from '@/shared/utils/logger'; const log = createLogger('ToolbarMode'); import { ModernFlowChatContainer } from '../modern/ModernFlowChatContainer'; import { Tooltip } from '@/component-library'; -import { configManager } from '@/infrastructure/config/services/ConfigManager'; import './ToolbarMode.scss'; -const DEFAULT_MODE_CONFIG_KEY = 'app.session_config.default_mode'; - // Window size config (physical pixels, accounts for Windows DPI scaling). const TOOLBAR_WIDTH = 600; const TOOLBAR_HEIGHT_NORMAL = 120; // Two-row height (32px + ~88px). @@ -59,21 +56,8 @@ export const ToolbarMode: React.FC = () => { const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState() ); - const [defaultMode, setDefaultMode] = useState<'code' | 'cowork'>('code'); const sessionPickerRef = useRef(null); - useEffect(() => { - configManager.getConfig<'code' | 'cowork'>(DEFAULT_MODE_CONFIG_KEY).then(mode => { - if (mode === 'code' || mode === 'cowork') setDefaultMode(mode); - }).catch(() => {}); - const unwatch = configManager.watch(DEFAULT_MODE_CONFIG_KEY, () => { - configManager.getConfig<'code' | 'cowork'>(DEFAULT_MODE_CONFIG_KEY).then(mode => { - if (mode === 'code' || mode === 'cowork') setDefaultMode(mode); - }).catch(() => {}); - }); - return () => unwatch(); - }, []); - useEffect(() => { const unsubscribe = flowChatStore.subscribe((state) => { setFlowChatState(state); @@ -346,7 +330,7 @@ export const ToolbarMode: React.FC = () => { )}
- +
+ ); +} + +function BasicsLoggingSection() { + const { t } = useTranslation('settings/basics'); + const [configLevel, setConfigLevel] = useState('info'); + const [runtimeInfo, setRuntimeInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [openingFolder, setOpeningFolder] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); + + const levelOptions = useMemo( + () => [ + { value: 'trace', label: t('logging.levels.trace') }, + { value: 'debug', label: t('logging.levels.debug') }, + { value: 'info', label: t('logging.levels.info') }, + { value: 'warn', label: t('logging.levels.warn') }, + { value: 'error', label: t('logging.levels.error') }, + { value: 'off', label: t('logging.levels.off') }, + ], + [t] + ); + + const showMessage = useCallback((type: 'success' | 'error' | 'info', text: string) => { + setMessage({ type, text }); + setTimeout(() => setMessage(null), 3000); + }, []); + + const loadData = useCallback(async () => { + try { + setLoading(true); + + const [savedLevel, info] = await Promise.all([ + configManager.getConfig('app.logging.level'), + configAPI.getRuntimeLoggingInfo(), + ]); + + setConfigLevel(savedLevel || info.effectiveLevel || 'info'); + setRuntimeInfo(info); + } catch (error) { + log.error('Failed to load logging config', error); + showMessage('error', t('logging.messages.loadFailed')); + } finally { + setLoading(false); + } + }, [showMessage, t]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleLevelChange = useCallback( + async (value: string) => { + const nextLevel = value as BackendLogLevel; + const previousLevel = configLevel; + setConfigLevel(nextLevel); + setSaving(true); + + try { + await configManager.setConfig('app.logging.level', nextLevel); + configManager.clearCache(); + + const info = await configAPI.getRuntimeLoggingInfo(); + setRuntimeInfo(info); + showMessage('success', t('logging.messages.levelUpdated')); + } catch (error) { + setConfigLevel(previousLevel); + log.error('Failed to update logging level', { nextLevel, error }); + showMessage('error', t('logging.messages.saveFailed')); + } finally { + setSaving(false); + } + }, + [configLevel, showMessage, t] + ); + + const handleRefresh = useCallback(async () => { + await loadData(); + showMessage('info', t('logging.messages.refreshed')); + }, [loadData, showMessage, t]); + + const handleOpenFolder = useCallback(async () => { + const folder = runtimeInfo?.sessionLogDir; + if (!folder) { + showMessage('error', t('logging.messages.pathUnavailable')); + return; + } + + try { + setOpeningFolder(true); + await workspaceAPI.revealInExplorer(folder); + } catch (error) { + log.error('Failed to open log folder', { folder, error }); + showMessage('error', t('logging.messages.openFailed')); + } finally { + setOpeningFolder(false); + } + }, [runtimeInfo?.sessionLogDir, showMessage, t]); + + if (loading) { + return ; + } + + return ( +
+
+ + + + } + > + +
+ handleShellChange(v as string)} + options={shellOptions} + placeholder={t('terminal.controls.placeholder')} + disabled={saving} + /> + ) : ( +
{t('terminal.controls.noShells')}
+ )} +
+
+ + {platform === 'windows' && defaultShell === 'Cmd' && ( +
+ +
+ )} + {platform === 'windows' && defaultShell === 'Bash' && ( +
+ +
+ )} +
+
+
+ ); +} + +interface ThemePreviewThumbnailProps { + theme: ThemeConfigType; +} + +function ThemePreviewThumbnail({ theme }: ThemePreviewThumbnailProps) { + const { colors } = theme; + + return ( +
+
+
+ +
+ +
+ BitFun +
+ +
+ + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+ + +
+ + {[1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+ + +
+ +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ + +
+ ))} +
+
+
+ +
+
+ + +
+ +
+ + + + + + + + + +
+ + +
+
+ ); +} + +const BasicsConfig: React.FC = () => { + const { t } = useTranslation('settings/basics'); + + return ( + + + + + + + + + ); +}; + +export default BasicsConfig; diff --git a/src/web-ui/src/infrastructure/config/components/DebugConfig.scss b/src/web-ui/src/infrastructure/config/components/DebugConfig.scss index 55d30ec7..d10389cb 100644 --- a/src/web-ui/src/infrastructure/config/components/DebugConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/DebugConfig.scss @@ -35,14 +35,59 @@ width: 100%; } - &__templates-list { - border-top: 1px solid var(--border-subtle); - padding: $size-gap-3; + /** Modal header reset: match `.modal__close` size + hover/active */ + &__modal-reset-icon.icon-btn { + width: 22px !important; + height: 22px !important; + min-width: 22px; + min-height: 22px; + padding: 0 !important; + border: 1px solid transparent; + border-radius: 6px; + box-shadow: none; + background: transparent; + color: var(--color-text-muted); + transition: all 0.2s ease; + + svg { + width: 12px !important; + height: 12px !important; + } + + &:hover:not(:disabled) { + background: var(--element-bg-subtle); + border-color: var(--border-subtle); + color: var(--color-text-primary); + } + + &:active:not(:disabled) { + transform: scale(0.92); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: 2px; + } + } + + &__modal-body { + padding: $size-gap-4; display: flex; flex-direction: column; gap: $size-gap-2; } + &__modal-footer { + position: sticky; + bottom: 0; + display: flex; + justify-content: flex-end; + gap: $size-gap-2; + padding: $size-gap-3 $size-gap-4; + border-top: 1px solid var(--border-subtle); + background: var(--color-bg-primary); + } + &__template-card { overflow: hidden; @@ -138,11 +183,11 @@ justify-content: flex-start; flex-wrap: wrap; } + } +} - &__templates-list { - padding: $size-gap-2; - } - +@media (max-width: 520px) { + .bitfun-debug-config { &__template-content { padding: $size-gap-3; } @@ -150,5 +195,9 @@ &__region-inputs { grid-template-columns: 1fr; } + + &__modal-body { + padding: $size-gap-2 $size-gap-3; + } } } diff --git a/src/web-ui/src/infrastructure/config/components/DebugConfig.tsx b/src/web-ui/src/infrastructure/config/components/DebugConfig.tsx deleted file mode 100644 index 5096bee0..00000000 --- a/src/web-ui/src/infrastructure/config/components/DebugConfig.tsx +++ /dev/null @@ -1,397 +0,0 @@ - - -import React, { useState, useEffect, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { FolderOpen, RefreshCw, ChevronDown } from 'lucide-react'; -import { - Button, - NumberInput, - Input, - Switch, - Textarea, - Card, - CardBody, - IconButton, - ConfigPageLoading, - ConfigPageMessage, -} from '@/component-library'; -import { ConfigPageHeader, ConfigPageLayout, ConfigPageContent, ConfigPageSection, ConfigPageRow } from './common'; -import { open } from '@tauri-apps/plugin-dialog'; -import { configManager } from '../services/ConfigManager'; -import type { DebugModeConfig, LanguageDebugTemplate } from '../types'; -import { - LANGUAGE_TEMPLATE_LABELS, - DEFAULT_DEBUG_MODE_CONFIG, - ALL_LANGUAGES, - DEFAULT_LANGUAGE_TEMPLATES, -} from '../types'; -import { createLogger } from '@/shared/utils/logger'; -import './DebugConfig.scss'; - -const log = createLogger('DebugConfig'); - -const DebugConfig: React.FC = () => { - const { t } = useTranslation('settings/debug'); - const [config, setConfig] = useState(DEFAULT_DEBUG_MODE_CONFIG); - const [hasChanges, setHasChanges] = useState(false); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); - const [expandedTemplates, setExpandedTemplates] = useState>(new Set()); - - - useEffect(() => { - loadConfig(); - }, []); - - const loadConfig = async () => { - try { - setLoading(true); - const debugConfig = await configManager.getConfig('ai.debug_mode_config'); - if (debugConfig) { - setConfig(debugConfig); - } - } catch (error) { - log.error('Failed to load config', error); - showMessage('error', t('messages.loadFailed')); - } finally { - setLoading(false); - } - }; - - const saveConfig = async () => { - try { - setSaving(true); - await configManager.setConfig('ai.debug_mode_config', config); - setHasChanges(false); - showMessage('success', t('messages.saveSuccess')); - } catch (error) { - log.error('Failed to save config', error); - showMessage('error', t('messages.saveFailed')); - } finally { - setSaving(false); - } - }; - - const resetConfig = async () => { - try { - - await configManager.resetConfig('ai.debug_mode_config'); - await loadConfig(); - setHasChanges(false); - showMessage('success', t('messages.resetSuccess')); - } catch (error) { - log.error('Failed to reset config', error); - showMessage('error', t('messages.resetFailed')); - } - }; - - const updateConfig = useCallback((updates: Partial) => { - setConfig(prev => ({ ...prev, ...updates })); - setHasChanges(true); - }, []); - - const updateTemplate = useCallback((language: string, updates: Partial) => { - setConfig(prev => ({ - ...prev, - language_templates: { - ...prev.language_templates, - [language]: { - ...prev.language_templates[language], - ...updates - } - } - })); - setHasChanges(true); - }, []); - - - const toggleTemplateEnabled = useCallback(async (language: string, currentEnabled: boolean) => { - const newEnabled = !currentEnabled; - - - const newConfig = { - ...config, - language_templates: { - ...config.language_templates, - [language]: { - ...config.language_templates[language], - enabled: newEnabled - } - } - }; - setConfig(newConfig); - - - try { - await configManager.setConfig('ai.debug_mode_config', newConfig); - const templateName = config.language_templates[language]?.display_name || language; - showMessage('success', newEnabled ? t('messages.templateEnabled', { name: templateName }) : t('messages.templateDisabled', { name: templateName })); - } catch (error) { - log.error('Failed to save template toggle', { language, error }); - - setConfig(config); - showMessage('error', t('messages.saveFailed')); - } - }, [config, t]); - - const showMessage = (type: 'success' | 'error' | 'info', text: string) => { - setMessage({ type, text }); - setTimeout(() => setMessage(null), 3000); - }; - - const toggleTemplate = (language: string) => { - setExpandedTemplates(prev => { - const next = new Set(prev); - if (next.has(language)) { - next.delete(language); - } else { - next.add(language); - } - return next; - }); - }; - - - const handleSelectLogPath = async () => { - try { - const selected = await open({ - multiple: false, - directory: false, - filters: [{ - name: t('fileDialog.logFile'), - extensions: ['log', 'txt', 'ndjson'] - }] - }); - - if (selected) { - updateConfig({ log_path: selected }); - showMessage('success', t('messages.logPathUpdated')); - } - } catch (error) { - showMessage('error', `${t('messages.selectFileFailed')}: ${error instanceof Error ? error.message : String(error)}`); - } - }; - - - const getTemplateEntries = useCallback((): [string, LanguageDebugTemplate][] => { - const entries: [string, LanguageDebugTemplate][] = []; - - for (const lang of ALL_LANGUAGES) { - const userTemplate = config.language_templates?.[lang]; - const defaultTemplate = DEFAULT_LANGUAGE_TEMPLATES[lang]; - - const template = userTemplate || defaultTemplate; - if (template) { - entries.push([lang, template]); - } - } - - return entries; - }, [config.language_templates]); - - const templateEntries = getTemplateEntries(); - - if (loading) { - return ( - - - - - - - ); - } - - return ( - - - - - - - - - - -
- updateConfig({ log_path: e.target.value })} - placeholder={t('settings.logPath.placeholder')} - variant="outlined" - inputSize="small" - /> - - - -
-
- - - updateConfig({ ingest_port: v })} - min={1024} - max={65535} - step={1} - size="small" - /> - - - {hasChanges && ( - -
- - -
-
- )} -
- - - - {t('templates.reset')} - - )} - > -
- {templateEntries.map(([language, template]) => { - const isExpanded = expandedTemplates.has(language); - return ( - -
toggleTemplate(language)} - > -
-
e.stopPropagation()}> - toggleTemplateEnabled(language, template.enabled)} - size="small" - /> -
- - {template.display_name || LANGUAGE_TEMPLATE_LABELS[language] || language} - -
- -
- - {isExpanded && ( - -
-