diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2cc541b..b8b1a014 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,9 @@ jobs: - name: Type-check web UI run: pnpm run type-check:web + - name: Lint web UI + run: pnpm run lint:web + - name: Build web UI run: pnpm run build:web diff --git a/src/web-ui/eslint.config.mjs b/src/web-ui/eslint.config.mjs index 38004f29..15aa6ded 100644 --- a/src/web-ui/eslint.config.mjs +++ b/src/web-ui/eslint.config.mjs @@ -1,3 +1,4 @@ +import js from '@eslint/js'; import globals from 'globals'; import tseslint from 'typescript-eslint'; import reactHooks from 'eslint-plugin-react-hooks'; @@ -10,12 +11,24 @@ export default tseslint.config( 'coverage/**', 'node_modules/**', 'public/monaco-editor/**', + 'src/**/*.test.ts', + 'src/**/*.test.tsx', + 'src/**/*.spec.ts', + 'src/**/*.spec.tsx', + 'src/**/*.example.tsx', 'src/component-library/components/registry.tsx', + 'src/component-library/preview/**', 'src/shared/context-system/core/types/**', + 'src/shared/context-menu-system/examples/**', + 'src/tools/mermaid-editor/examples/**', ], }, { files: ['src/**/*.{ts,tsx}'], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + linterOptions: { + reportUnusedDisableDirectives: 'warn', + }, languageOptions: { ecmaVersion: 2022, sourceType: 'module', @@ -37,13 +50,46 @@ export default tseslint.config( }, rules: { ...reactHooks.configs.recommended.rules, + 'react-hooks/exhaustive-deps': 'error', + 'no-undef': 'off', + 'no-unused-vars': 'off', + 'no-use-before-define': 'off', + 'no-case-declarations': 'warn', + 'no-cond-assign': 'warn', + 'no-control-regex': 'warn', + 'no-empty': ['warn', { allowEmptyCatch: true }], + 'no-useless-escape': 'warn', + 'prefer-const': 'warn', 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + '@typescript-eslint/no-empty-object-type': 'warn', '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-namespace': 'warn', + '@typescript-eslint/no-use-before-define': [ + 'error', + { + functions: false, + classes: true, + variables: true, + enums: true, + typedefs: true, + ignoreTypeReferences: true, + }, + ], + '@typescript-eslint/no-unused-expressions': 'warn', '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }, { files: ['*.{ts,mts,cts}', '*.config.{ts,mts,cts}', 'vite.config.ts'], + extends: [js.configs.recommended, ...tseslint.configs.recommended], languageOptions: { ecmaVersion: 2022, sourceType: 'module', diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 44c98e2d..2959e9d8 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useCallback, useState, useRef } from 'react'; import { ChatProvider, useAIInitialization } from '../infrastructure'; -import { ViewModeProvider } from '../infrastructure/contexts/ViewModeContext'; +import { ViewModeProvider } from '../infrastructure/contexts/ViewModeProvider'; import { SSHRemoteProvider } from '../features/ssh-remote'; import AppLayout from './layout/AppLayout'; import { useCurrentModelConfig } from '../hooks/useModelConfigs'; diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index b2c801c9..dc25d3f5 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -103,7 +103,11 @@ const MainNav: React.FC = ({ const toggleSection = useCallback((id: string) => { setExpandedSections(prev => { const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } return next; }); }, []); diff --git a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx index 34d79ce8..94e53d86 100644 --- a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx @@ -73,12 +73,6 @@ export const BranchQuickSwitch: React.FC = ({ } }, [isOpen, anchorRef]); - useEffect(() => { - if (isOpen && repositoryPath) { - loadBranches(); - } - }, [isOpen, repositoryPath]); - useEffect(() => { if (!isOpen) { setSearchTerm(''); @@ -113,7 +107,7 @@ export const BranchQuickSwitch: React.FC = ({ return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, onClose]); - const loadBranches = async () => { + const loadBranches = useCallback(async () => { setIsLoading(true); try { const cachedState = gitStateManager.getState(repositoryPath); @@ -142,7 +136,13 @@ export const BranchQuickSwitch: React.FC = ({ } finally { setIsLoading(false); } - }; + }, [repositoryPath]); + + useEffect(() => { + if (isOpen && repositoryPath) { + void loadBranches(); + } + }, [isOpen, loadBranches, repositoryPath]); const filteredBranches = useMemo(() => { let result = branches; diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index 0ac90872..7302c034 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -28,10 +28,12 @@ import NotificationButton from '../../TitleBar/NotificationButton'; import { AboutDialog } from '../../AboutDialog'; import { RemoteConnectDialog } from '../../RemoteConnectDialog'; import { - getRemoteConnectDisclaimerAgreed, - setRemoteConnectDisclaimerAgreed, RemoteConnectDisclaimerContent, } from '../../RemoteConnectDialog/RemoteConnectDisclaimer'; +import { + getRemoteConnectDisclaimerAgreed, + setRemoteConnectDisclaimerAgreed, +} from '../../RemoteConnectDialog/remoteConnectDisclaimerStorage'; import { MERMAID_INTERACTIVE_EXAMPLE } from '@/flow_chat/constants/mermaidExamples'; const PersistentFooterActions: React.FC = () => { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 24b292ea..62e093b5 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -24,7 +24,7 @@ import { isRemoteWorkspace, type WorkspaceInfo, } from '@/shared/types'; -import { SSHContext } from '@/features/ssh-remote/SSHRemoteProvider'; +import { SSHContext } from '@/features/ssh-remote/SSHRemoteContext'; interface WorkspaceItemProps { workspace: WorkspaceInfo; @@ -213,7 +213,7 @@ const WorkspaceItem: React.FC = ({ } finally { setIsResettingWorkspace(false); } - }, [isActive, isDefaultAssistantWorkspace, isResettingWorkspace, resetAssistantWorkspace, t, workspace.id, workspace.rootPath]); + }, [isActive, isDefaultAssistantWorkspace, isResettingWorkspace, resetAssistantWorkspace, t, workspace.id, workspace.rootPath, workspace.workspaceKind]); const handleReveal = useCallback(async () => { setMenuOpen(false); @@ -274,10 +274,7 @@ const WorkspaceItem: React.FC = ({ }, [ setActiveWorkspace, t, - workspace.id, - workspace.rootPath, - workspace.workspaceKind, - workspace.connectionId, + workspace, ]); const handleCreateCodeSession = useCallback(() => { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx index d7eb8cd2..4d1fcbdb 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx @@ -96,7 +96,6 @@ const WorkspaceListSection: React.FC = ({ variant }) dropTargetRef.current = next; return next; }); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Intentionally empty: reads from refs, not closed-over state const handleDragLeave = useCallback((workspaceId: string) => (event: React.DragEvent) => { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index e3c1b53d..76b11926 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -16,10 +16,12 @@ import { type RemoteConnectStatus, } from '@/infrastructure/api/service-api/RemoteConnectAPI'; import { - getRemoteConnectDisclaimerAgreed, - setRemoteConnectDisclaimerAgreed, RemoteConnectDisclaimerContent, } from './RemoteConnectDisclaimer'; +import { + getRemoteConnectDisclaimerAgreed, + setRemoteConnectDisclaimerAgreed, +} from './remoteConnectDisclaimerStorage'; import './RemoteConnectDialog.scss'; // ── Types ──────────────────────────────────────────────────────────── diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.tsx index 902f2c91..2f205bee 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.tsx @@ -3,24 +3,6 @@ import { Badge } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import './RemoteConnectDisclaimer.scss'; -export const REMOTE_CONNECT_DISCLAIMER_KEY = 'bitfun:remote-connect:disclaimer-agreed:v1'; - -export const getRemoteConnectDisclaimerAgreed = (): boolean => { - try { - return localStorage.getItem(REMOTE_CONNECT_DISCLAIMER_KEY) === 'true'; - } catch { - return false; - } -}; - -export const setRemoteConnectDisclaimerAgreed = (): void => { - try { - localStorage.setItem(REMOTE_CONNECT_DISCLAIMER_KEY, 'true'); - } catch { - // Ignore storage failures and fall back to in-memory state. - } -}; - interface RemoteConnectDisclaimerContentProps { agreed: boolean; onClose: () => void; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/remoteConnectDisclaimerStorage.ts b/src/web-ui/src/app/components/RemoteConnectDialog/remoteConnectDisclaimerStorage.ts new file mode 100644 index 00000000..49cd6fdc --- /dev/null +++ b/src/web-ui/src/app/components/RemoteConnectDialog/remoteConnectDisclaimerStorage.ts @@ -0,0 +1,17 @@ +export const REMOTE_CONNECT_DISCLAIMER_KEY = 'bitfun:remote-connect:disclaimer-agreed:v1'; + +export const getRemoteConnectDisclaimerAgreed = (): boolean => { + try { + return localStorage.getItem(REMOTE_CONNECT_DISCLAIMER_KEY) === 'true'; + } catch { + return false; + } +}; + +export const setRemoteConnectDisclaimerAgreed = (): void => { + try { + localStorage.setItem(REMOTE_CONNECT_DISCLAIMER_KEY, 'true'); + } catch { + // Ignore storage failures and fall back to in-memory state. + } +}; diff --git a/src/web-ui/src/app/components/TitleBar/TitleBar.tsx b/src/web-ui/src/app/components/TitleBar/TitleBar.tsx index 118b0fa1..942eb833 100644 --- a/src/web-ui/src/app/components/TitleBar/TitleBar.tsx +++ b/src/web-ui/src/app/components/TitleBar/TitleBar.tsx @@ -135,7 +135,7 @@ const TitleBar: React.FC = ({ } catch (error) { log.error('Failed to open workspace', error); } - }, [openWorkspace]); + }, [openWorkspace, t]); const handleNewProject = useCallback(() => { setShowNewProjectDialog(true); diff --git a/src/web-ui/src/app/components/panels/BranchSelectModal.tsx b/src/web-ui/src/app/components/panels/BranchSelectModal.tsx index d867345b..2eef370b 100644 --- a/src/web-ui/src/app/components/panels/BranchSelectModal.tsx +++ b/src/web-ui/src/app/components/panels/BranchSelectModal.tsx @@ -61,12 +61,6 @@ export const BranchSelectModal: React.FC = ({ const resolvedTitle = title ?? t('branchSelect.title'); - useEffect(() => { - if (isOpen && repositoryPath) { - loadBranches(); - } - }, [isOpen, repositoryPath]); - useEffect(() => { if (!isOpen) { setSearchTerm(''); @@ -91,7 +85,7 @@ export const BranchSelectModal: React.FC = ({ return () => document.removeEventListener('keydown', handleEscape); }, [isOpen, onClose]); - const loadBranches = async () => { + const loadBranches = useCallback(async () => { setIsLoading(true); setError(null); try { @@ -103,7 +97,13 @@ export const BranchSelectModal: React.FC = ({ } finally { setIsLoading(false); } - }; + }, [repositoryPath, t]); + + useEffect(() => { + if (isOpen && repositoryPath) { + void loadBranches(); + } + }, [isOpen, loadBranches, repositoryPath]); const filteredBranches = useMemo(() => { let result = branches; diff --git a/src/web-ui/src/app/components/panels/FilesPanel.tsx b/src/web-ui/src/app/components/panels/FilesPanel.tsx index f9cb61be..3c3f70c5 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.tsx +++ b/src/web-ui/src/app/components/panels/FilesPanel.tsx @@ -155,7 +155,7 @@ const FilesPanel: React.FC = ({ } } prevWorkspacePathRef.current = workspacePath; - }, [workspacePath, clearSearch]); + }, [workspacePath, clearSearch, onViewModeChange]); // ===== File Operation Handlers ===== @@ -866,6 +866,7 @@ const FilesPanel: React.FC = ({ confirmText={inputDialog.type === 'newFile' ? t('dialog.newFile.confirm') : t('dialog.newFolder.confirm')} cancelText={inputDialog.type === 'newFile' ? t('dialog.newFile.cancel') : t('dialog.newFolder.cancel')} validator={(value) => { + // eslint-disable-next-line no-control-regex -- Windows filename rules explicitly forbid ASCII control characters. if (!/^[^<>:"/\\|?*\x00-\x1F]+$/.test(value)) { return t('validation.invalidFilename'); } diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index a15e21d9..60b1cbe3 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -111,7 +111,7 @@ const FlexiblePanel: React.FC = memo(({ const contentRef = React.useRef(content); React.useEffect(() => { contentRef.current = content; - }, [content]); + }, [content, onInteraction]); // Sync dirty state from MonacoModelManager on component mount React.useEffect(() => { @@ -161,7 +161,7 @@ const FlexiblePanel: React.FC = memo(({ onInteraction('copy', 'failed'); } }); - }, [content]); + }, [content, onInteraction]); const handleDownload = useCallback(() => { if (!content?.data) return; @@ -223,7 +223,7 @@ const FlexiblePanel: React.FC = memo(({ } } }; - }, [content, onContentChange, onDirtyStateChange, onInteraction]); + }, [content, onContentChange, onDirtyStateChange, onInteraction, t]); const renderContent = () => { if (!content || content.type === 'empty') { @@ -239,7 +239,7 @@ const FlexiblePanel: React.FC = memo(({ } switch (content.type) { - case 'code-preview': + case 'code-preview': { const previewData = content.data || {}; const hasFixNeeded = previewData.migrationContext?.hasUpgradePoints || previewData.needsFix || false; @@ -248,6 +248,7 @@ const FlexiblePanel: React.FC = memo(({
{typeof content.data === 'string' ? content.data : t('flexiblePanel.fallback.noCodeContent')}
); + } case 'markdown-viewer': return ( @@ -256,7 +257,7 @@ const FlexiblePanel: React.FC = memo(({ ); - case 'markdown-editor': + case 'markdown-editor': { const markdownEditorData = content.data || {}; const markdownFilePath = markdownEditorData.filePath; const markdownInitialContent = markdownEditorData.initialContent; @@ -297,9 +298,10 @@ const FlexiblePanel: React.FC = memo(({ )} ); + } - case 'mermaid-editor': + case 'mermaid-editor': { const mermaidData = content.data || {}; if (mermaidData.mode || mermaidData.interactive_config || mermaidData.mermaid_code) { @@ -389,6 +391,7 @@ const FlexiblePanel: React.FC = memo(({ ); } + } case 'text-viewer': return ( @@ -397,7 +400,7 @@ const FlexiblePanel: React.FC = memo(({ ); - case 'file-viewer': + case 'file-viewer': { const fileViewerData = content.data || {}; const fileNeedsFix = fileViewerData.migrationContext?.hasUpgradePoints || fileViewerData.needsFix || false; const fileViewerClass = `bitfun-flexible-panel__panel-code-viewer ${fileNeedsFix ? 'needs-fix' : ''}`; @@ -417,8 +420,9 @@ const FlexiblePanel: React.FC = memo(({ /> ); + } - case 'image-viewer': + case 'image-viewer': { const imageViewerData = content.data || {}; return ( @@ -431,8 +435,9 @@ const FlexiblePanel: React.FC = memo(({ /> ); + } - case 'code-viewer': + case 'code-viewer': { const codeData = content.data || {}; const migrationContext = codeData.migrationContext || {}; const needsFix = migrationContext.hasUpgradePoints || codeData.needsFix || false; @@ -455,8 +460,9 @@ const FlexiblePanel: React.FC = memo(({ ); + } - case 'code-editor': + case 'code-editor': { const editorData = content.data || {}; const filePath = editorData.filePath || ''; const fileName = editorData.fileName || content.title; @@ -506,8 +512,9 @@ const FlexiblePanel: React.FC = memo(({ }} /> ); + } - case 'diff-code-editor': + case 'diff-code-editor': { const diffData = content.data || {}; const originalCode = diffData.originalCode || ''; const modifiedCode = diffData.modifiedCode || originalCode; @@ -586,6 +593,7 @@ const FlexiblePanel: React.FC = memo(({ }} /> ); + } case 'git-diff': return ( @@ -682,15 +690,16 @@ const FlexiblePanel: React.FC = memo(({ ); - case 'task-detail': + case 'task-detail': { const taskDetailData = content.data || {}; return ( {t('flexiblePanel.loading.taskDetail')}}> ); + } - case 'plan-viewer': + case 'plan-viewer': { const planViewerData = content.data || {}; const planFilePath = planViewerData.filePath || ''; const planFileName = planViewerData.fileName || content.title; @@ -718,8 +727,9 @@ const FlexiblePanel: React.FC = memo(({ /> ); + } - case 'terminal': + case 'terminal': { // Terminal panel const terminalData = content.data || {}; const sessionId = terminalData.sessionId; @@ -744,6 +754,7 @@ const FlexiblePanel: React.FC = memo(({ ); + } case 'btw-session': return ( diff --git a/src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.tsx b/src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.ts similarity index 85% rename from src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.tsx rename to src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.ts index fac0805d..32aa2c27 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.ts @@ -2,8 +2,7 @@ * CanvasContext - canvas global context. * Provides access to tabs and layout state to reduce props drilling. */ - -import React, { createContext, useContext, useMemo, ReactNode } from 'react'; +import { createContext, useContext } from 'react'; import type { CanvasTab, EditorGroupId, @@ -121,42 +120,6 @@ export interface CanvasContextValue { const CanvasContext = createContext(null); -// ==================== Provider Props ==================== - -export interface CanvasProviderProps { - children: ReactNode; - value: CanvasContextValue; -} - -// ==================== Provider Component ==================== - -export const CanvasProvider: React.FC = ({ - children, - value, -}) => { - // Memoize value to avoid unnecessary re-renders - const memoizedValue = useMemo(() => value, [ - value.primaryGroup, - value.secondaryGroup, - value.activeGroupId, - value.layout, - value.isMissionControlOpen, - value.workspacePath, - value.tabOps, - value.dragOps, - value.layoutOps, - value.missionControlOps, - value.onInteraction, - value.onBeforeClose, - ]); - - return ( - - {children} - - ); -}; - // ==================== Hooks ==================== /** @@ -221,4 +184,5 @@ export const useMissionControl = () => { return { isOpen: isMissionControlOpen, ...missionControlOps }; }; +export { CanvasContext }; export default CanvasContext; diff --git a/src/web-ui/src/app/components/panels/content-canvas/context/CanvasProvider.tsx b/src/web-ui/src/app/components/panels/content-canvas/context/CanvasProvider.tsx new file mode 100644 index 00000000..89277ab7 --- /dev/null +++ b/src/web-ui/src/app/components/panels/content-canvas/context/CanvasProvider.tsx @@ -0,0 +1,20 @@ +import React, { ReactNode, useMemo } from 'react'; +import { CanvasContext, type CanvasContextValue } from './CanvasContext'; + +export interface CanvasProviderProps { + children: ReactNode; + value: CanvasContextValue; +} + +export const CanvasProvider: React.FC = ({ + children, + value, +}) => { + const memoizedValue = useMemo(() => value, [value]); + + return ( + + {children} + + ); +}; diff --git a/src/web-ui/src/app/components/panels/content-canvas/context/index.ts b/src/web-ui/src/app/components/panels/content-canvas/context/index.ts index 2ad0d476..bed4b50e 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/context/index.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/context/index.ts @@ -3,7 +3,6 @@ */ export { - CanvasProvider, useCanvas, useEditorGroup, useCanvasLayout, @@ -12,12 +11,13 @@ export { useMissionControl, default as CanvasContext, } from './CanvasContext'; +export { CanvasProvider } from './CanvasProvider'; export type { CanvasContextValue, - CanvasProviderProps, TabOperations, DragOperations, LayoutOperations, MissionControlOperations, } from './CanvasContext'; +export type { CanvasProviderProps } from './CanvasProvider'; diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts b/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts index 62eeadb7..cc7ee7c1 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts @@ -127,12 +127,13 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) case 'switchToTab5': case 'switchToTab6': case 'switchToTab7': - case 'switchToTab8': + case 'switchToTab8': { const tabIndex = parseInt(action.replace('switchToTab', '')) - 1; if (visibleTabs[tabIndex]) { switchToTab(visibleTabs[tabIndex].id, activeGroupId); } break; + } case 'switchToLastTab': if (visibleTabs.length > 0) { @@ -142,6 +143,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) } }, [ activeGroupId, + handleCloseWithDirtyCheck, primaryGroup, secondaryGroup, layout, diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts b/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts index fbcbdc4a..62762f90 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts @@ -59,7 +59,7 @@ export const usePanelTabCoordinator = (options: UsePanelTabCoordinatorOptions = if (!isInitializedRef.current) { isInitializedRef.current = true; } - }, [state?.layout?.rightPanelCollapsed, toggleRightPanel]); + }, [state?.layout, toggleRightPanel]); /** * Expand right panel (with debounce and state checks). diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx index 2e5a6686..3b2a8a71 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx @@ -194,7 +194,7 @@ export const TabBar: React.FC = ({ const finalCount = Math.max(1, Math.min(count, visibleTabs.length)); setVisibleTabsCount(finalCount); setLayoutReady(true); - }, [visibleTabs, getTabWidth, getTabCacheKey, onCloseAllTabs]); + }, [visibleTabs, getTabWidth, getTabCacheKey, onCloseAllTabs, onOpenMissionControl]); // Reset to render all tabs when list changes (re-measure) useEffect(() => { diff --git a/src/web-ui/src/app/hooks/useWindowControls.ts b/src/web-ui/src/app/hooks/useWindowControls.ts index ffc9be12..e6c45d7b 100644 --- a/src/web-ui/src/app/hooks/useWindowControls.ts +++ b/src/web-ui/src/app/hooks/useWindowControls.ts @@ -76,7 +76,7 @@ export const useWindowControls = (options?: { isToolbarMode?: boolean }) => { const maximized = await appWindow.isMaximized(); setIsMaximized(maximized); - } catch (error) { + } catch (_error) { // Ignore errors to avoid noise when minimized } }; @@ -97,7 +97,7 @@ export const useWindowControls = (options?: { isToolbarMode?: boolean }) => { await updateWindowState(appWindow); await restoreMacOSOverlayTitlebar(appWindow); }, 300); - } catch (error) { + } catch (_error) { // Ignore errors } } @@ -185,7 +185,7 @@ export const useWindowControls = (options?: { isToolbarMode?: boolean }) => { if (rect.width > 0 && rect.height > 0) { activeElement.focus(); } - } catch (error) { + } catch (_error) { // Ignore focus restore failures } } @@ -271,7 +271,7 @@ export const useWindowControls = (options?: { isToolbarMode?: boolean }) => { if (rect.width > 0 && rect.height > 0) { activeElement.focus(); } - } catch (error) { + } catch (_error) { // Ignore focus restore failures } } diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index e2da431d..8039ee64 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -28,7 +28,7 @@ import { workspaceAPI } from '@/infrastructure/api'; import { createLogger } from '@/shared/utils/logger'; import { useI18n } from '@/infrastructure/i18n'; import { WorkspaceKind } from '@/shared/types'; -import { SSHContext } from '@/features/ssh-remote/SSHRemoteProvider'; +import { SSHContext } from '@/features/ssh-remote/SSHRemoteContext'; import { shortcutManager } from '@/infrastructure/services/ShortcutManager'; import './AppLayout.scss'; @@ -252,6 +252,7 @@ const AppLayout: React.FC = ({ className = '' }) => { initializeFlowChat(); }, [ + currentWorkspace, currentWorkspace?.id, currentWorkspace?.rootPath, currentWorkspace?.workspaceKind, diff --git a/src/web-ui/src/app/layout/WorkspaceBody.tsx b/src/web-ui/src/app/layout/WorkspaceBody.tsx index 5eb67f0e..c0892661 100644 --- a/src/web-ui/src/app/layout/WorkspaceBody.tsx +++ b/src/web-ui/src/app/layout/WorkspaceBody.tsx @@ -72,14 +72,6 @@ const WorkspaceBody: React.FC = ({ document.body.classList.add('bitfun-is-dragging-nav-collapse'); document.body.classList.add('bitfun-is-resizing-nav'); - const cleanup = () => { - document.body.classList.remove('bitfun-is-dragging-nav-collapse'); - document.body.classList.remove('bitfun-is-resizing-nav'); - document.body.classList.remove('bitfun-divider-hovered'); - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - const handleMouseMove = (moveEvent: MouseEvent) => { if (hasCollapsed) return; const deltaX = moveEvent.clientX - startX; @@ -98,6 +90,14 @@ const WorkspaceBody: React.FC = ({ const handleMouseUp = () => cleanup(); + function cleanup() { + document.body.classList.remove('bitfun-is-dragging-nav-collapse'); + document.body.classList.remove('bitfun-is-resizing-nav'); + document.body.classList.remove('bitfun-divider-hovered'); + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + } + window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); }, [isNavCollapsed, navWidth, toggleLeftPanel]); diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx index b1a069b6..9d3dc320 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx @@ -47,7 +47,11 @@ const CreateAgentPage: React.FC = () => { const toggleTool = (tool: string) => { setSelectedTools((prev) => { const next = new Set(prev); - next.has(tool) ? next.delete(tool) : next.add(tool); + if (next.has(tool)) { + next.delete(tool); + } else { + next.add(tool); + } return next; }); }; diff --git a/src/web-ui/src/app/scenes/git/views/BranchesView.tsx b/src/web-ui/src/app/scenes/git/views/BranchesView.tsx index a511c1e6..1e2f0caa 100644 --- a/src/web-ui/src/app/scenes/git/views/BranchesView.tsx +++ b/src/web-ui/src/app/scenes/git/views/BranchesView.tsx @@ -65,7 +65,7 @@ const BranchesView: React.FC = ({ workspacePath }) => { } finally { setBranchLoading(false); } - }, [workspacePath]); + }, [selectedBranchName, workspacePath]); const loadCommits = useCallback( async (branchRef: string | null) => { @@ -160,7 +160,11 @@ const BranchesView: React.FC = ({ workspacePath }) => { const toggleCommitExpand = useCallback((hash: string) => { setExpandedCommits(prev => { const next = new Set(prev); - next.has(hash) ? next.delete(hash) : next.add(hash); + if (next.has(hash)) { + next.delete(hash); + } else { + next.add(hash); + } return next; }); }, []); @@ -193,7 +197,7 @@ const BranchesView: React.FC = ({ workspacePath }) => { setIsResetting(false); } }, - [workspacePath, notification, t, selectedBranchName, loadCommits] + [workspacePath, notification, t, selectedBranchName, loadBranches, loadCommits] ); if (!workspacePath) { diff --git a/src/web-ui/src/app/scenes/git/views/WorkingCopyView.tsx b/src/web-ui/src/app/scenes/git/views/WorkingCopyView.tsx index ca755b55..5ce331d7 100644 --- a/src/web-ui/src/app/scenes/git/views/WorkingCopyView.tsx +++ b/src/web-ui/src/app/scenes/git/views/WorkingCopyView.tsx @@ -256,7 +256,11 @@ const WorkingCopyView: React.FC = ({ workspacePath }) => { const toggleFileGroup = useCallback((groupId: string) => { setExpandedFileGroups(prev => { const next = new Set(prev); - next.has(groupId) ? next.delete(groupId) : next.add(groupId); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } return next; }); }, []); diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppScene.tsx b/src/web-ui/src/app/scenes/miniapps/MiniAppScene.tsx index 068dc2a3..0225b097 100644 --- a/src/web-ui/src/app/scenes/miniapps/MiniAppScene.tsx +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppScene.tsx @@ -2,7 +2,7 @@ * MiniAppScene — standalone scene tab for a single MiniApp. * Mounts MiniAppRunner; close via SceneBar × (does not stop worker). */ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { RefreshCw, Loader2, AlertTriangle } from 'lucide-react'; import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; import { api } from '@/infrastructure/api/service-api/ApiClient'; @@ -45,7 +45,7 @@ const MiniAppScene: React.FC = ({ appId }) => { }; }, [appId, openApp, closeApp]); - const load = async (id: string) => { + const load = useCallback(async (id: string) => { setLoading(true); setError(null); try { @@ -58,13 +58,13 @@ const MiniAppScene: React.FC = ({ appId }) => { } finally { setLoading(false); } - }; + }, [themeType, workspacePath]); useEffect(() => { if (appId) { void load(appId); } - }, [appId, themeType, workspacePath]); + }, [appId, load]); useEffect(() => { const tabId = `miniapp:${appId}` as SceneTabId; @@ -107,7 +107,7 @@ const MiniAppScene: React.FC = ({ appId }) => { unlistenRestarted(); unlistenDeleted(); }; - }, [appId, closeScene]); + }, [appId, closeScene, load]); const handleReload = () => { if (appId) { diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts index 6abf7cf7..9f462eaa 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts @@ -93,7 +93,7 @@ export function useMiniAppBridge( return () => { window.removeEventListener('message', handler); }; - }, []); + }, [iframeRef]); useEffect(() => { const payload = buildMiniAppThemeVars(currentTheme); @@ -102,5 +102,5 @@ export function useMiniAppBridge( { type: 'bitfun:event', event: 'themeChange', payload }, '*', ); - }, [currentTheme]); + }, [currentTheme, iframeRef]); } diff --git a/src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx b/src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx index 74f91fcf..2ef7c733 100644 --- a/src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx +++ b/src/web-ui/src/app/scenes/my-agent/InsightsScene.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ import React, { useEffect, useCallback, useState, useRef } from 'react'; import { ExternalLink, Copy, Check, ArrowLeft, Loader2, AlertTriangle, diff --git a/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx b/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx index 5ec90f43..25ad1a73 100644 --- a/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx +++ b/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx @@ -31,8 +31,7 @@ type TemplateDetail = | { type: 'mcpServer'; serverId: string } | { type: 'skill'; skill: SkillInfo }; -const MODEL_SLOTS = ['primary', 'fast'] as const; -type ModelSlot = typeof MODEL_SLOTS[number]; +type ModelSlot = 'primary' | 'fast'; // MCP tools are registered as "mcp_{server_id}_{tool_name}" (single underscores) function isMcpTool(name: string): boolean { @@ -350,7 +349,11 @@ const TemplateConfigPage: React.FC = () => { const toggleCollapse = useCallback((id: string) => { setCollapsedGroups((prev) => { const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } return next; }); }, []); diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx index 1f798457..233bab40 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx @@ -90,7 +90,7 @@ function useSettingsSearch( ): SettingsSearchRow[] { const index = useMemo( () => buildSettingsSearchIndex(t, i18n), - [t, i18n, i18n.language] + [t, i18n] ); return useMemo(() => { diff --git a/src/web-ui/src/app/scenes/shell/ShellNav.tsx b/src/web-ui/src/app/scenes/shell/ShellNav.tsx index a8ae1f08..84e8f3dc 100644 --- a/src/web-ui/src/app/scenes/shell/ShellNav.tsx +++ b/src/web-ui/src/app/scenes/shell/ShellNav.tsx @@ -102,12 +102,12 @@ const ShellNav: React.FC = () => { const handleCreateManualTerminal = useCallback(async (shellType?: string) => { setMenuOpen(false); await createManualTerminal(shellType); - }, [createManualTerminal]); + }, [createManualTerminal, setMenuOpen]); const handleToggleCreateMenu = useCallback(() => { setWorkspaceMenuOpen(false); setMenuOpen((prev) => !prev); - }, []); + }, [setMenuOpen, setWorkspaceMenuOpen]); const shellMenuItems = useMemo( () => @@ -130,7 +130,7 @@ const ShellNav: React.FC = () => { setMenuOpen(false); setWorkspaceMenuOpen((prev) => !prev); - }, [hasMultipleWorkspaces]); + }, [hasMultipleWorkspaces, setMenuOpen, setWorkspaceMenuOpen]); const handleSelectWorkspace = useCallback(async (workspaceId: string) => { setWorkspaceMenuOpen(false); @@ -138,7 +138,7 @@ const ShellNav: React.FC = () => { return; } await setActiveWorkspace(workspaceId); - }, [activeWorkspace?.id, setActiveWorkspace]); + }, [activeWorkspace?.id, setActiveWorkspace, setWorkspaceMenuOpen]); const openContextMenu = useCallback(( event: React.MouseEvent, diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx index 81010b5d..5559dcb3 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx @@ -80,7 +80,7 @@ const SkillsScene: React.FC = () => { const refetchSkillsScene = useCallback(async () => { await Promise.all([installed.loadSkills(true), market.refresh()]); - }, [installed.loadSkills, market.refresh]); + }, [installed, market]); useGallerySceneAutoRefresh({ sceneId: 'skills', diff --git a/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts b/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts index edd65f39..02bfed26 100644 --- a/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts +++ b/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts @@ -57,7 +57,7 @@ export function useSkillMarket({ } finally { setMarketLoading(false); } - }, [fetchSkills]); + }, [fetchSkills, pageSize]); useEffect(() => { loadFirstPage(searchQuery || undefined); @@ -127,7 +127,7 @@ export function useSkillMarket({ } finally { setLoadingMore(false); } - }, [currentPage, displayMarketSkills.length, fetchSkills, hasMore, searchQuery]); + }, [currentPage, displayMarketSkills.length, fetchSkills, hasMore, pageSize, searchQuery]); const handleDownload = useCallback(async (skill: SkillMarketItem) => { if (!hasWorkspace) { diff --git a/src/web-ui/src/component-library/components/Card/Card.tsx b/src/web-ui/src/component-library/components/Card/Card.tsx index 1a34d76d..d755c22a 100644 --- a/src/web-ui/src/component-library/components/Card/Card.tsx +++ b/src/web-ui/src/component-library/components/Card/Card.tsx @@ -82,7 +82,7 @@ export const CardHeader = forwardRef(({ CardHeader.displayName = 'CardHeader'; -export interface CardBodyProps extends React.HTMLAttributes {} +export type CardBodyProps = React.HTMLAttributes; export const CardBody = forwardRef(({ children, diff --git a/src/web-ui/src/component-library/components/FlowChatCards/ContextCompressionCard/ContextCompressionCard.tsx b/src/web-ui/src/component-library/components/FlowChatCards/ContextCompressionCard/ContextCompressionCard.tsx index d425ff48..ab26cf4a 100644 --- a/src/web-ui/src/component-library/components/FlowChatCards/ContextCompressionCard/ContextCompressionCard.tsx +++ b/src/web-ui/src/component-library/components/FlowChatCards/ContextCompressionCard/ContextCompressionCard.tsx @@ -27,14 +27,11 @@ export interface ContextCompressionCardProps extends Omit = ({ compressionCount = 1, - hasSummary = true, tokensBefore, tokensAfter, compressionRatio, duration, - summaryContent, trigger = 'manual', - compressionTiers, input, result, status = 'pending', @@ -158,4 +155,3 @@ export const ContextCompressionCard: React.FC = ({ ); }; - diff --git a/src/web-ui/src/component-library/components/FlowChatCards/TodoCard/TodoCard.tsx b/src/web-ui/src/component-library/components/FlowChatCards/TodoCard/TodoCard.tsx index 6f3b3cf2..e4115f11 100644 --- a/src/web-ui/src/component-library/components/FlowChatCards/TodoCard/TodoCard.tsx +++ b/src/web-ui/src/component-library/components/FlowChatCards/TodoCard/TodoCard.tsx @@ -22,7 +22,6 @@ export interface TodoCardProps extends Omit = ({ todos, - action = 'list', input, result, status = 'pending', diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx index 3de1d3dd..75db7518 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx @@ -623,7 +623,7 @@ export const Markdown = React.memo(({ }, []); const components = useMemo(() => ({ - code({ node, className, children, ...props }: any) { + code({ node: _node, className, children, ...props }: any) { const match = /language-(\w+)/.exec(className || ''); const language = match ? match[1] : ''; const code = String(children).replace(/\n$/, ''); @@ -703,7 +703,6 @@ export const Markdown = React.memo(({ let filePath = normalizeFileLikeHref(hrefValue); let lineRange: LineRange | undefined; - let fileName: string; const hashIndex = filePath.indexOf('#'); if (hashIndex !== -1) { @@ -729,7 +728,7 @@ export const Markdown = React.memo(({ filePath = resolveBaseRelativePath(filePath, basePath); - fileName = filePath.split(/[\\/]/).pop() || filePath; + const fileName = filePath.split(/[\\/]/).pop() || filePath; const isFolder = filePath.endsWith('/'); const shouldRevealInExplorer = isComputerLink || !isEditorOpenableFilePath(filePath); @@ -875,7 +874,7 @@ export const Markdown = React.memo(({ ); }, - img({ node, ...props }: any) { + img({ node: _node, ...props }: any) { return ; }, diff --git a/src/web-ui/src/component-library/components/Markdown/MermaidBlock.tsx b/src/web-ui/src/component-library/components/Markdown/MermaidBlock.tsx index eae8820f..7c9dcbd7 100644 --- a/src/web-ui/src/component-library/components/Markdown/MermaidBlock.tsx +++ b/src/web-ui/src/component-library/components/Markdown/MermaidBlock.tsx @@ -110,7 +110,7 @@ export const MermaidBlock: React.FC = ({ setState('error'); } } - }, []); + }, [t]); useEffect(() => { const trimmedCode = code.trim(); @@ -200,7 +200,7 @@ export const MermaidBlock: React.FC = ({ } })); }, 100); - }, [code]); + }, [code, tMermaid]); const renderContent = () => { switch (state) { diff --git a/src/web-ui/src/component-library/components/Markdown/ReproductionStepsBlock.tsx b/src/web-ui/src/component-library/components/Markdown/ReproductionStepsBlock.tsx index fe19c168..367f19b8 100644 --- a/src/web-ui/src/component-library/components/Markdown/ReproductionStepsBlock.tsx +++ b/src/web-ui/src/component-library/components/Markdown/ReproductionStepsBlock.tsx @@ -38,7 +38,7 @@ export const ReproductionStepsBlock: React.FC = ({ const stepList = React.useMemo(() => { const lines = steps.split('\n').filter(line => line.trim()); return lines.map(line => { - const cleaned = line.replace(/^[\d\.\-\*\)\s]+/, '').trim(); + const cleaned = line.replace(/^[\d.*)\s-]+/, '').trim(); return cleaned || line.trim(); }); }, [steps]); diff --git a/src/web-ui/src/component-library/components/NumberInput/NumberInput.tsx b/src/web-ui/src/component-library/components/NumberInput/NumberInput.tsx index 37a8eb84..0b98d018 100644 --- a/src/web-ui/src/component-library/components/NumberInput/NumberInput.tsx +++ b/src/web-ui/src/component-library/components/NumberInput/NumberInput.tsx @@ -56,12 +56,6 @@ export const NumberInput = forwardRef( const inputRef = useRef(null); const containerRef = useRef(null); - useEffect(() => { - if (!isEditing) { - setInputValue(formatValue(value)); - } - }, [value, isEditing, precision]); - const formatValue = useCallback( (val: number) => { return precision > 0 ? val.toFixed(precision) : String(Math.round(val)); @@ -69,6 +63,12 @@ export const NumberInput = forwardRef( [precision] ); + useEffect(() => { + if (!isEditing) { + setInputValue(formatValue(value)); + } + }, [value, isEditing, formatValue]); + const clampValue = useCallback( (val: number) => Math.min(max, Math.max(min, val)), [min, max] @@ -293,4 +293,3 @@ export const NumberInput = forwardRef( ); NumberInput.displayName = 'NumberInput'; - diff --git a/src/web-ui/src/component-library/components/StreamText/StreamText.tsx b/src/web-ui/src/component-library/components/StreamText/StreamText.tsx index 911efaae..9cd0985c 100644 --- a/src/web-ui/src/component-library/components/StreamText/StreamText.tsx +++ b/src/web-ui/src/component-library/components/StreamText/StreamText.tsx @@ -2,7 +2,7 @@ * Streaming text output component */ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import './StreamText.scss'; export type StreamEffect = @@ -59,6 +59,42 @@ const StreamTextComponent: React.FC = ({ const hasStartedRef = useRef(false); const prevTextRef = useRef(text); + const streamCharacter = useCallback((index: number) => { + if (index < text.length) { + setDisplayedText(prev => prev + text[index]); + setCurrentIndex(index + 1); + + const progress = ((index + 1) / text.length) * 100; + onProgress?.(progress); + + let nextSpeed = speed; + if (effect === 'glitch' && Math.random() > 0.7) { + nextSpeed = speed * (Math.random() * 2 + 0.5); + } else if (effect === 'wave') { + nextSpeed = speed + Math.sin(index * 0.5) * 20; + } + + timeoutRef.current = setTimeout(() => { + streamCharacter(index + 1); + }, Math.max(10, nextSpeed)); + } else { + setIsComplete(true); + setIsStreaming(false); + onComplete?.(); + } + }, [effect, onComplete, onProgress, speed, text]); + + const startStreaming = useCallback(() => { + setDisplayedText(''); + setCurrentIndex(0); + setIsComplete(false); + setIsStreaming(true); + + timeoutRef.current = setTimeout(() => { + streamCharacter(0); + }, delay); + }, [delay, streamCharacter]); + useEffect(() => { const isTextChanged = prevTextRef.current !== text; let skipAutoStart = false; @@ -96,7 +132,7 @@ const StreamTextComponent: React.FC = ({ clearTimeout(timeoutRef.current); } }; - }, [text, autoStart, paused]); + }, [text, autoStart, paused, isComplete, startStreaming]); useEffect(() => { if (paused && isStreaming) { @@ -108,44 +144,7 @@ const StreamTextComponent: React.FC = ({ } else if (!paused && pausedIndexRef.current > 0) { streamCharacter(pausedIndexRef.current); } - }, [paused]); - - const startStreaming = () => { - setDisplayedText(''); - setCurrentIndex(0); - setIsComplete(false); - setIsStreaming(true); - - timeoutRef.current = setTimeout(() => { - streamCharacter(0); - }, delay); - }; - - - const streamCharacter = (index: number) => { - if (index < text.length) { - setDisplayedText(prev => prev + text[index]); - setCurrentIndex(index + 1); - - const progress = ((index + 1) / text.length) * 100; - onProgress?.(progress); - - let nextSpeed = speed; - if (effect === 'glitch' && Math.random() > 0.7) { - nextSpeed = speed * (Math.random() * 2 + 0.5); - } else if (effect === 'wave') { - nextSpeed = speed + Math.sin(index * 0.5) * 20; - } - - timeoutRef.current = setTimeout(() => { - streamCharacter(index + 1); - }, Math.max(10, nextSpeed)); - } else { - setIsComplete(true); - setIsStreaming(false); - onComplete?.(); - } - }; + }, [paused, currentIndex, isStreaming, streamCharacter]); const renderText = () => { if (!charAnimation) { diff --git a/src/web-ui/src/component-library/components/Tabs/Tabs.tsx b/src/web-ui/src/component-library/components/Tabs/Tabs.tsx index e421599f..972da531 100644 --- a/src/web-ui/src/component-library/components/Tabs/Tabs.tsx +++ b/src/web-ui/src/component-library/components/Tabs/Tabs.tsx @@ -1,4 +1,4 @@ -import React, { useState, createContext, useContext } from 'react'; +import React, { useState, createContext, useContext, useMemo } from 'react'; import './Tabs.scss'; export interface TabItem { @@ -66,16 +66,20 @@ export const Tabs: React.FC = ({ const activeKey = controlledActiveKey !== undefined ? controlledActiveKey : internalActiveKey; - const tabs: TabItem[] = []; - const panes: { [key: string]: React.ReactNode } = {}; + const { tabs, panes } = useMemo(() => { + const nextTabs: TabItem[] = []; + const nextPanes: { [key: string]: React.ReactNode } = {}; - React.Children.forEach(children, (child) => { - if (React.isValidElement(child) && child.type === TabPane) { - const { tabKey, label, icon, disabled, closable } = child.props; - tabs.push({ key: tabKey, label, icon, disabled, closable }); - panes[tabKey] = child; - } - }); + React.Children.forEach(children, (child) => { + if (React.isValidElement(child) && child.type === TabPane) { + const { tabKey, label, icon, disabled, closable } = child.props; + nextTabs.push({ key: tabKey, label, icon, disabled, closable }); + nextPanes[tabKey] = child; + } + }); + + return { tabs: nextTabs, panes: nextPanes }; + }, [children]); React.useEffect(() => { if (!activeKey && tabs.length > 0) { @@ -84,7 +88,7 @@ export const Tabs: React.FC = ({ setInternalActiveKey(firstKey); } } - }, [tabs.length]); + }, [activeKey, controlledActiveKey, tabs]); const handleTabClick = (key: string, disabled?: boolean) => { if (disabled) return; @@ -151,4 +155,4 @@ export const Tabs: React.FC = ({ ); }; -Tabs.displayName = 'Tabs'; \ No newline at end of file +Tabs.displayName = 'Tabs'; diff --git a/src/web-ui/src/component-library/components/Textarea/Textarea.tsx b/src/web-ui/src/component-library/components/Textarea/Textarea.tsx index 4fe45c21..2a8fc9df 100644 --- a/src/web-ui/src/component-library/components/Textarea/Textarea.tsx +++ b/src/web-ui/src/component-library/components/Textarea/Textarea.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useRef, useImperativeHandle } from 'react'; +import React, { forwardRef, useRef, useImperativeHandle, useCallback } from 'react'; import './Textarea.scss'; export interface TextareaProps extends React.TextareaHTMLAttributes { @@ -37,16 +37,16 @@ export const Textarea = forwardRef( useImperativeHandle(ref, () => textareaRef.current!); - const adjustHeight = () => { + const adjustHeight = useCallback(() => { if (autoResize && textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; } - }; + }, [autoResize]); React.useEffect(() => { adjustHeight(); - }, [value, autoResize]); + }, [value, adjustHeight]); React.useEffect(() => { const count = typeof value === 'string' ? value.length : 0; @@ -109,4 +109,4 @@ export const Textarea = forwardRef( } ); -Textarea.displayName = 'Textarea'; \ No newline at end of file +Textarea.displayName = 'Textarea'; diff --git a/src/web-ui/src/component-library/components/Tooltip/Tooltip.tsx b/src/web-ui/src/component-library/components/Tooltip/Tooltip.tsx index a6aeb681..996e7187 100644 --- a/src/web-ui/src/component-library/components/Tooltip/Tooltip.tsx +++ b/src/web-ui/src/component-library/components/Tooltip/Tooltip.tsx @@ -173,8 +173,8 @@ export const Tooltip: React.FC = ({ const tooltipRect = tooltipRef.current.getBoundingClientRect(); if (followCursor && mousePosition) { - let left = mousePosition.x + CURSOR_OFFSET_X; - let top = mousePosition.y + CURSOR_OFFSET_Y; + const left = mousePosition.x + CURSOR_OFFSET_X; + const top = mousePosition.y + CURSOR_OFFSET_Y; const pos = applyBoundaryConstraints({ top, left }, tooltipRect, viewportPadding); setActualPlacement('bottom'); setPosition(pos); @@ -225,7 +225,7 @@ export const Tooltip: React.FC = ({ }, delay); }; - const hideTooltip = () => { + const hideTooltip = useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; @@ -240,7 +240,7 @@ export const Tooltip: React.FC = ({ latestMousePositionRef.current = null; setMousePosition(null); } - }; + }, [followCursor]); const scheduleHideTooltip = useCallback(() => { if (!interactive) { @@ -256,7 +256,7 @@ export const Tooltip: React.FC = ({ hideTimeoutRef.current = null; hideTooltip(); }, 150); - }, [interactive]); + }, [hideTooltip, interactive]); const handleMouseMove = useCallback( (e: React.MouseEvent) => { @@ -352,7 +352,6 @@ export const Tooltip: React.FC = ({ } }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any const triggerElement = React.cloneElement(children as React.ReactElement, { ref: handleTriggerRef, onMouseEnter: handleMouseEnter, diff --git a/src/web-ui/src/component-library/preview/main.tsx b/src/web-ui/src/component-library/preview/main.tsx index 54df286f..23404def 100644 --- a/src/web-ui/src/component-library/preview/main.tsx +++ b/src/web-ui/src/component-library/preview/main.tsx @@ -6,7 +6,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { PreviewApp } from './PreviewApp'; import { I18nProvider } from '@/infrastructure/i18n'; -import { WorkspaceProvider } from '@/infrastructure/contexts/WorkspaceContext'; +import { WorkspaceProvider } from '@/infrastructure/contexts/WorkspaceProvider'; import { themeService } from '@/infrastructure/theme'; import './preview.css'; import './flowchat-cards-preview.css'; @@ -23,4 +23,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render( -); \ No newline at end of file +); diff --git a/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx b/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx index 34fe9d6d..22c67e22 100644 --- a/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx +++ b/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx @@ -3,7 +3,7 @@ * Used to browse and select remote directory as workspace */ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { Button } from '@/component-library'; import { ConfirmDialog } from './ConfirmDialog'; @@ -112,22 +112,7 @@ export const RemoteFileBrowser: React.FC = ({ const [transferBusy, setTransferBusy] = useState(false); const contextMenuRef = useRef(null); - useEffect(() => { - loadDirectory(currentPath); - }, [currentPath]); - - // Close context menu when clicking outside - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { - setContextMenu({ show: false, x: 0, y: 0, entry: null }); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const loadDirectory = async (path: string) => { + const loadDirectory = useCallback(async (path: string) => { setLoading(true); setError(null); try { @@ -144,7 +129,22 @@ export const RemoteFileBrowser: React.FC = ({ } finally { setLoading(false); } - }; + }, [connectionId]); + + useEffect(() => { + loadDirectory(currentPath); + }, [currentPath, loadDirectory]); + + // Close context menu when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { + setContextMenu({ show: false, x: 0, y: 0, entry: null }); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); const navigateTo = (path: string) => { setCurrentPath(path); @@ -200,6 +200,30 @@ export const RemoteFileBrowser: React.FC = ({ }); }; + const handleDownloadEntry = async (entry: RemoteFileEntry) => { + if (entry.isDir) return; + if (!isTauriDesktop()) { + setError(t('ssh.remote.transferNeedsDesktop')); + return; + } + const { save } = await import('@tauri-apps/plugin-dialog'); + const localPath = await save({ + title: t('ssh.remote.downloadDialogTitle'), + defaultPath: entry.name, + }); + if (localPath === null) return; + + setTransferBusy(true); + setError(null); + try { + await sshApi.downloadToLocalPath(connectionId, entry.path, localPath); + } catch (e) { + setError(e instanceof Error ? e.message : t('ssh.remote.transferFailed')); + } finally { + setTransferBusy(false); + } + }; + const handleContextMenuAction = async (action: string) => { if (!contextMenu.entry) return; @@ -266,30 +290,6 @@ export const RemoteFileBrowser: React.FC = ({ } }; - const handleDownloadEntry = async (entry: RemoteFileEntry) => { - if (entry.isDir) return; - if (!isTauriDesktop()) { - setError(t('ssh.remote.transferNeedsDesktop')); - return; - } - const { save } = await import('@tauri-apps/plugin-dialog'); - const localPath = await save({ - title: t('ssh.remote.downloadDialogTitle'), - defaultPath: entry.name, - }); - if (localPath === null) return; - - setTransferBusy(true); - setError(null); - try { - await sshApi.downloadToLocalPath(connectionId, entry.path, localPath); - } catch (e) { - setError(e instanceof Error ? e.message : t('ssh.remote.transferFailed')); - } finally { - setTransferBusy(false); - } - }; - const handleUploadToCurrentDir = async () => { if (!isTauriDesktop()) { setError(t('ssh.remote.transferNeedsDesktop')); diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx index 7c632a5d..64dc4e0f 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx @@ -5,7 +5,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useI18n } from '@/infrastructure/i18n'; -import { useSSHRemoteContext } from './SSHRemoteProvider'; +import { useSSHRemoteContext } from './SSHRemoteContext'; import { SSHAuthPromptDialog, type SSHAuthPromptSubmitPayload } from './SSHAuthPromptDialog'; import { Modal } from '@/component-library'; import { Button } from '@/component-library'; @@ -52,16 +52,6 @@ export const SSHConnectionDialog: React.FC = ({ const error = localError || connectionError; - // Clear errors when dialog opens - useEffect(() => { - if (open) { - clearError(); - setLocalError(null); - loadSavedConnections(); - loadSSHConfigHosts(); - } - }, [open]); - // Form state const [formData, setFormData] = useState({ name: '', @@ -74,24 +64,34 @@ export const SSHConnectionDialog: React.FC = ({ passphrase: '', }); - const loadSavedConnections = async () => { + async function loadSavedConnections() { setLocalError(null); try { const connections = await sshApi.listSavedConnections(); setSavedConnections(connections); - } catch (e) { + } catch (_error) { setSavedConnections([]); } - }; + } - const loadSSHConfigHosts = async () => { + async function loadSSHConfigHosts() { try { const hosts = await sshApi.listSSHConfigHosts(); setSSHConfigHosts(hosts); - } catch (e) { + } catch (_error) { setSSHConfigHosts([]); } - }; + } + + // Clear errors when dialog opens + useEffect(() => { + if (open) { + clearError(); + setLocalError(null); + void loadSavedConnections(); + void loadSSHConfigHosts(); + } + }, [open, clearError]); // Load SSH config from ~/.ssh/config when host changes useEffect(() => { diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteContext.ts b/src/web-ui/src/features/ssh-remote/SSHRemoteContext.ts new file mode 100644 index 00000000..d0fc0936 --- /dev/null +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteContext.ts @@ -0,0 +1,36 @@ +import { createContext, useContext } from 'react'; +import type { SSHConnectionConfig, RemoteWorkspace } from './types'; + +export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; + +export interface SSHContextValue { + status: ConnectionStatus; + isConnected: boolean; + isConnecting: boolean; + connectionId: string | null; + connectionConfig: SSHConnectionConfig | null; + remoteWorkspace: RemoteWorkspace | null; + connectionError: string | null; + workspaceStatuses: Record; + showConnectionDialog: boolean; + showFileBrowser: boolean; + error: string | null; + remoteFileBrowserInitialPath: string; + connect: (connectionId: string, config: SSHConnectionConfig) => Promise; + disconnect: () => Promise; + openWorkspace: (path: string) => Promise; + closeWorkspace: () => Promise; + setShowConnectionDialog: (show: boolean) => void; + setShowFileBrowser: (show: boolean) => void; + clearError: () => void; +} + +export const SSHContext = createContext(null); + +export const useSSHRemoteContext = () => { + const context = useContext(SSHContext); + if (!context) { + throw new Error('useSSHRemoteContext must be used within SSHRemoteProvider'); + } + return context; +}; diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx index 6dde64c0..05010619 100644 --- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx @@ -1,8 +1,7 @@ /** * SSH Remote Feature - React Context Provider */ - -import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import { createLogger } from '@/shared/utils/logger'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { WorkspaceKind } from '@/shared/types/global-state'; @@ -10,6 +9,11 @@ import type { SSHConnectionConfig, RemoteWorkspace } from './types'; import { sshApi } from './sshApi'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { normalizeRemoteWorkspacePath } from '@/shared/utils/pathUtils'; +import { + SSHContext, + type ConnectionStatus, + type SSHContextValue, +} from './SSHRemoteContext'; const log = createLogger('SSHRemoteProvider'); @@ -28,50 +32,6 @@ function sshHostForRemoteWorkspace(connectionId: string, remotePath: string): st return undefined; } -export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; - -interface SSHContextValue { - // Connection state - status: ConnectionStatus; - isConnected: boolean; - isConnecting: boolean; - connectionId: string | null; - connectionConfig: SSHConnectionConfig | null; - remoteWorkspace: RemoteWorkspace | null; - connectionError: string | null; - - // Per-workspace connection statuses (keyed by connectionId) - workspaceStatuses: Record; - - // UI state - showConnectionDialog: boolean; - showFileBrowser: boolean; - error: string | null; - /** Default path for remote folder picker (`~` or resolved `$HOME` from server). */ - remoteFileBrowserInitialPath: string; - - // Actions - connect: (connectionId: string, config: SSHConnectionConfig) => Promise; - disconnect: () => Promise; - openWorkspace: (path: string) => Promise; - closeWorkspace: () => Promise; - - // UI actions - setShowConnectionDialog: (show: boolean) => void; - setShowFileBrowser: (show: boolean) => void; - clearError: () => void; -} - -export const SSHContext = createContext(null); - -export const useSSHRemoteContext = () => { - const context = useContext(SSHContext); - if (!context) { - throw new Error('useSSHRemoteContext must be used within SSHRemoteProvider'); - } - return context; -}; - interface SSHRemoteProviderProps { children: React.ReactNode; } @@ -92,31 +52,13 @@ export const SSHRemoteProvider: React.FC = ({ children } // Per-workspace connection statuses (keyed by connectionId) const [workspaceStatuses, setWorkspaceStatuses] = useState>({}); const heartbeatInterval = useRef(null); + const startHeartbeatRef = useRef<(connId: string) => void>(() => {}); + const checkRemoteWorkspaceRef = useRef<() => Promise>(async () => {}); const setWorkspaceStatus = useCallback((connId: string, st: ConnectionStatus) => { setWorkspaceStatuses(prev => ({ ...prev, [connId]: st })); }, []); - // Wait for workspace manager to finish loading, then check remote workspaces - useEffect(() => { - const state = workspaceManager.getState(); - if (!state.loading) { - // Already loaded — kick off immediately - void checkRemoteWorkspace(); - return; - } - // Wait for loading to complete - const unsubscribe = workspaceManager.addEventListener(event => { - if (event.type === 'workspace:loading' && !event.loading) { - unsubscribe(); - void checkRemoteWorkspace(); - } - }); - return unsubscribe; - // checkRemoteWorkspace is defined below but stable (no deps change it) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - // Cleanup heartbeat on unmount useEffect(() => { return () => { @@ -131,7 +73,7 @@ export const SSHRemoteProvider: React.FC = ({ children } // Waits RETRY_WAIT_MS between each attempt (fixed, not exponential). const RETRY_WAIT_MS = 10_000; - const tryReconnectWithRetry = async ( + const tryReconnectWithRetry = useCallback(async ( workspace: RemoteWorkspace, maxRetries: number, timeoutMs: number @@ -213,9 +155,44 @@ export const SSHRemoteProvider: React.FC = ({ children } } return false; - }; + }, []); + + const statusRef = useRef(status); + statusRef.current = status; + + const handleConnectionLost = useCallback((connId: string) => { + log.warn('Remote connection lost, attempting auto-reconnect...'); + setStatus('error'); + setWorkspaceStatus(connId, 'error'); + setConnectionError('Connection lost. Attempting to reconnect...'); + setIsConnected(false); + if (heartbeatInterval.current) { + clearInterval(heartbeatInterval.current); + heartbeatInterval.current = null; + } + // Attempt auto-reconnect in background + void checkRemoteWorkspaceRef.current(); + }, [setWorkspaceStatus]); + + const startHeartbeat = useCallback((connId: string) => { + if (heartbeatInterval.current) { + clearInterval(heartbeatInterval.current); + } - const checkRemoteWorkspace = async () => { + heartbeatInterval.current = window.setInterval(async () => { + try { + const connected = await sshApi.isConnected(connId); + if (!connected && statusRef.current === 'connected') { + handleConnectionLost(connId); + } + } catch { + // Ignore heartbeat errors + } + }, 30000); + }, [handleConnectionLost]); + startHeartbeatRef.current = startHeartbeat; + + const checkRemoteWorkspace = useCallback(async () => { try { // ── Collect all remote workspaces to reconnect ────────────────────── const allWorkspaces = Array.from(workspaceManager.getState().openedWorkspaces.values()); @@ -289,7 +266,7 @@ export const SSHRemoteProvider: React.FC = ({ children } setIsConnected(true); setConnectionId(workspace.connectionId); setRemoteWorkspace(workspace); - startHeartbeat(workspace.connectionId); + startHeartbeatRef.current(workspace.connectionId); if (!isAlreadyOpened) { await workspaceManager.openRemoteWorkspace(workspace).catch(() => {}); @@ -320,7 +297,7 @@ export const SSHRemoteProvider: React.FC = ({ children } setIsConnected(true); setConnectionId(result.connectionId); setRemoteWorkspace(result.workspace); - startHeartbeat(result.connectionId); + startHeartbeatRef.current(result.connectionId); if (!isAlreadyOpened) { await workspaceManager.openRemoteWorkspace(result.workspace).catch(() => {}); @@ -350,41 +327,26 @@ export const SSHRemoteProvider: React.FC = ({ children } } catch (e) { log.error('checkRemoteWorkspace failed', e); } - }; - - const statusRef = useRef(status); - statusRef.current = status; + }, [setWorkspaceStatus, tryReconnectWithRetry]); + checkRemoteWorkspaceRef.current = checkRemoteWorkspace; - const startHeartbeat = (connId: string) => { - if (heartbeatInterval.current) { - clearInterval(heartbeatInterval.current); + // Wait for workspace manager to finish loading, then check remote workspaces + useEffect(() => { + const state = workspaceManager.getState(); + if (!state.loading) { + void checkRemoteWorkspace(); + return; } - heartbeatInterval.current = window.setInterval(async () => { - try { - const connected = await sshApi.isConnected(connId); - if (!connected && statusRef.current === 'connected') { - handleConnectionLost(connId); - } - } catch { - // Ignore heartbeat errors + const unsubscribe = workspaceManager.addEventListener(event => { + if (event.type === 'workspace:loading' && !event.loading) { + unsubscribe(); + void checkRemoteWorkspace(); } - }, 30000); - }; + }); - const handleConnectionLost = (connId: string) => { - log.warn('Remote connection lost, attempting auto-reconnect...'); - setStatus('error'); - setWorkspaceStatus(connId, 'error'); - setConnectionError('Connection lost. Attempting to reconnect...'); - setIsConnected(false); - if (heartbeatInterval.current) { - clearInterval(heartbeatInterval.current); - heartbeatInterval.current = null; - } - // Attempt auto-reconnect in background - void checkRemoteWorkspace(); - }; + return unsubscribe; + }, [checkRemoteWorkspace]); const connect = useCallback(async (_connId: string, config: SSHConnectionConfig) => { log.debug('SSH connect called', { host: config.host }); @@ -439,7 +401,7 @@ export const SSHRemoteProvider: React.FC = ({ children } } finally { setIsConnecting(false); } - }, []); + }, [startHeartbeat]); const disconnect = useCallback(async () => { const currentRemoteWorkspace = remoteWorkspace; diff --git a/src/web-ui/src/features/ssh-remote/index.ts b/src/web-ui/src/features/ssh-remote/index.ts index 7f98024b..0a1685ae 100644 --- a/src/web-ui/src/features/ssh-remote/index.ts +++ b/src/web-ui/src/features/ssh-remote/index.ts @@ -8,4 +8,5 @@ export { SSHConnectionDialog } from './SSHConnectionDialog'; export { RemoteFileBrowser } from './RemoteFileBrowser'; export { SSHAuthPromptDialog } from './SSHAuthPromptDialog'; export { ConfirmDialog } from './ConfirmDialog'; -export { SSHRemoteProvider, useSSHRemoteContext } from './SSHRemoteProvider'; +export { SSHRemoteProvider } from './SSHRemoteProvider'; +export { useSSHRemoteContext } from './SSHRemoteContext'; diff --git a/src/web-ui/src/features/ssh-remote/useSSHRemote.ts b/src/web-ui/src/features/ssh-remote/useSSHRemote.ts index 6453e626..b456e395 100644 --- a/src/web-ui/src/features/ssh-remote/useSSHRemote.ts +++ b/src/web-ui/src/features/ssh-remote/useSSHRemote.ts @@ -33,12 +33,7 @@ export function useSSHRemote() { const { recentWorkspaces, switchWorkspace } = useWorkspaceContext(); const previousWorkspaceRef = useRef(null); - // Check for existing remote workspace on mount - useEffect(() => { - checkRemoteWorkspace(); - }, []); - - const checkRemoteWorkspace = async () => { + const checkRemoteWorkspace = useCallback(async () => { try { const workspace = await sshApi.getWorkspaceInfo(); if (workspace) { @@ -48,10 +43,15 @@ export function useSSHRemote() { remoteWorkspace: workspace, })); } - } catch (e) { + } catch (_error) { // Ignore errors on initial check } - }; + }, []); + + // Check for existing remote workspace on mount + useEffect(() => { + void checkRemoteWorkspace(); + }, [checkRemoteWorkspace]); const connect = useCallback( async (connectionId: string, config: SSHConnectionConfig) => { @@ -72,7 +72,7 @@ export function useSSHRemote() { if (state.connectionId) { try { await sshApi.disconnect(state.connectionId); - } catch (e) { + } catch (_error) { // Ignore disconnect errors } } @@ -94,7 +94,7 @@ export function useSSHRemote() { if (localWorkspaces.length > 0) { await switchWorkspace(localWorkspaces[0]); } - } catch (e) { + } catch (_error) { // Ignore errors when switching workspaces } } @@ -132,7 +132,7 @@ export function useSSHRemote() { try { await sshApi.closeWorkspace(); - } catch (e) { + } catch (_error) { // Ignore errors } setState((prev) => ({ @@ -147,7 +147,7 @@ export function useSSHRemote() { if (targetWorkspace && !targetWorkspace.rootPath.startsWith('ssh://')) { await switchWorkspace(targetWorkspace); } - } catch (e) { + } catch (_error) { // Ignore errors when switching workspaces } } diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 3b93349e..599aff77 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -126,8 +126,11 @@ export const ChatInput: React.FC = ({ ? flowChatState.sessions.get(activeBtwSessionId)?.title?.trim() || t('btw.threadLabel') : ''; - // Get input history for current session (after currentSessionId is defined) - const inputHistory = effectiveTargetSessionId ? getSessionHistory(effectiveTargetSessionId) : []; + // Memoize history so keyboard handlers don't see a fresh [] on every render. + const inputHistory = useMemo( + () => (effectiveTargetSessionId ? getSessionHistory(effectiveTargetSessionId) : []), + [effectiveTargetSessionId, getSessionHistory], + ); const derivedState = useSessionDerivedState( effectiveTargetSessionId, inputState.value.trim() @@ -138,7 +141,8 @@ export const ChatInput: React.FC = ({ const [tokenUsage, setTokenUsage] = React.useState({ current: 0, max: 128128 }); const isAssistantWorkspace = workspace?.workspaceKind === WorkspaceKind.Assistant; - const canSwitchModes = !isAssistantWorkspace && modeState.current !== 'Cowork'; + const currentMode = modeState.current; + const canSwitchModes = !isAssistantWorkspace && currentMode !== 'Cowork'; // Session-level mode policy: Cowork sessions are fixed; code sessions should not switch into Cowork. const switchableModes = useMemo( @@ -559,12 +563,12 @@ export const ChatInput: React.FC = ({ }, [effectiveTargetSessionId]); React.useEffect(() => { - if (!isAssistantWorkspace || modeState.current === 'Claw') { + if (!isAssistantWorkspace || currentMode === 'Claw') { return; } dispatchMode({ type: 'SET_CURRENT_MODE', payload: 'Claw' }); - }, [isAssistantWorkspace, modeState.current]); + }, [currentMode, isAssistantWorkspace]); React.useEffect(() => { const queuedInput = derivedState?.queuedInput; @@ -590,7 +594,6 @@ export const ChatInput: React.FC = ({ richTextInputRef.current.focus(); } } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ derivedState?.queuedInput, effectiveTargetSessionId, @@ -698,7 +701,7 @@ export const ChatInput: React.FC = ({ inputElement.removeEventListener('imagePaste', handleImagePaste); } }; - }, [addContext, currentImageCount]); + }, [addContext, currentImageCount, t]); React.useEffect(() => { if (!effectiveTargetSessionId || !workspacePath) { @@ -1079,7 +1082,7 @@ export const ChatInput: React.FC = ({ return; } - if (modeId === modeState.current) { + if (modeId === currentMode) { dispatchMode({ type: 'CLOSE_DROPDOWN' }); return; } @@ -1091,7 +1094,7 @@ export const ChatInput: React.FC = ({ applyModeChange(modeId); dispatchMode({ type: 'CLOSE_DROPDOWN' }); - }, [applyModeChange, modeState.current, canSwitchModes, switchableModes]); + }, [applyModeChange, canSwitchModes, currentMode, switchableModes]); const selectSlashCommandMode = useCallback((modeId: string) => { requestModeChange(modeId); @@ -1491,7 +1494,7 @@ export const ChatInput: React.FC = ({ }; input.click(); - }, [addContext, currentImageCount]); + }, [addContext, currentImageCount, t]); const toggleExpand = useCallback(() => { dispatchInput({ type: 'TOGGLE_EXPAND' }); diff --git a/src/web-ui/src/flow_chat/components/CopyOutputButton.tsx b/src/web-ui/src/flow_chat/components/CopyOutputButton.tsx index 727407b9..b61de300 100644 --- a/src/web-ui/src/flow_chat/components/CopyOutputButton.tsx +++ b/src/web-ui/src/flow_chat/components/CopyOutputButton.tsx @@ -51,7 +51,7 @@ export const CopyOutputButton: React.FC = ({ }); return contentParts.join('\n\n'); - }, []); + }, [t]); const handleCopy = useCallback(async () => { try { @@ -101,7 +101,7 @@ export const CopyOutputButton: React.FC = ({ } catch (error) { log.error('Failed to open editor', error); } - }, [dialogTurn, extractOutputContent]); + }, [dialogTurn, extractOutputContent, t]); const hasContent = dialogTurn.modelRounds.some(round => round.items.some(item => diff --git a/src/web-ui/src/flow_chat/components/ImageAnalysisCard.tsx b/src/web-ui/src/flow_chat/components/ImageAnalysisCard.tsx index b86d3c52..9f020a53 100644 --- a/src/web-ui/src/flow_chat/components/ImageAnalysisCard.tsx +++ b/src/web-ui/src/flow_chat/components/ImageAnalysisCard.tsx @@ -4,7 +4,7 @@ */ import React, { useState } from 'react'; -import { +import { Loader, CheckCircle, AlertCircle, @@ -14,7 +14,6 @@ import { Sparkles } from 'lucide-react'; import type { FlowImageAnalysisItem } from '../types/flow-chat'; -import type { ImageContext } from '@/shared/types/context'; import { Button } from '@/component-library'; import './ImageAnalysisCard.scss'; @@ -154,4 +153,3 @@ export const ImageAnalysisCard: React.FC = ({ }; export default ImageAnalysisCard; - diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 47841c6d..f8b5407a 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -290,7 +290,7 @@ export const ModelSelector: React.FC = ({ } finally { setLoading(false); } - }, [currentMode, loading]); + }, [currentMode, loading, sessionId]); if (availableModels.length === 0) { return null; diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.tsx index fb5923cf..be6940f6 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.tsx @@ -32,6 +32,73 @@ export interface RichTextInputProps { onMentionStateChange?: (state: MentionState) => void; } +function getContextDisplayName(context: ContextItem): string { + switch (context.type) { + case 'file': return context.fileName; + case 'directory': return context.directoryName; + case 'code-snippet': return `${context.fileName}:${context.startLine}-${context.endLine}`; + case 'image': return context.imageName; + case 'terminal-command': return context.command; + case 'git-ref': return context.refValue; + case 'url': return context.title || context.url; + case 'mermaid-node': return context.nodeText; + case 'mermaid-diagram': return context.diagramTitle || 'Mermaid diagram'; + case 'web-element': return context.tagName; + default: { + const exhaustive: never = context; + return String(exhaustive); + } + } +} + +function getContextTagFormat(context: ContextItem): string { + switch (context.type) { + case 'file': return `#file:${context.fileName}`; + case 'directory': return `#dir:${context.directoryName}`; + case 'code-snippet': return `#code:${context.fileName}:${context.startLine}-${context.endLine}`; + case 'image': return `#img:${context.imageName}`; + case 'terminal-command': return `#cmd:${context.command}`; + case 'git-ref': return `#git:${context.refValue}`; + case 'url': return `#link:${context.title || context.url}`; + case 'mermaid-node': return `#chart:${context.nodeText}`; + case 'mermaid-diagram': return `#mermaid:${context.diagramTitle || 'Mermaid diagram'}`; + case 'web-element': return `#element:${context.tagName}`; + default: { + const exhaustive: never = context; + return String(exhaustive); + } + } +} + +function getContextFullPath(context: ContextItem): string { + switch (context.type) { + case 'file': + return context.filePath; + case 'directory': + return context.directoryPath + (context.recursive ? ' (recursive)' : ''); + case 'code-snippet': + return `${context.filePath} (lines ${context.startLine}-${context.endLine})`; + case 'image': + return context.imagePath; + case 'terminal-command': + return context.workingDirectory ? `${context.command} @ ${context.workingDirectory}` : context.command; + case 'git-ref': + return `Git ${context.refType}: ${context.refValue}`; + case 'url': + return context.url; + case 'mermaid-node': + return context.diagramTitle ? `${context.diagramTitle} - ${context.nodeText}` : context.nodeText; + case 'mermaid-diagram': + return `Mermaid diagram${context.diagramTitle ? ': ' + context.diagramTitle : ''} (${context.diagramCode.length} chars)`; + case 'web-element': + return context.path; + default: { + const exhaustive: never = context; + return String(exhaustive); + } + } +} + export const RichTextInput = React.forwardRef(({ value, onChange, @@ -56,78 +123,8 @@ export const RichTextInput = React.forwardRef({ isActive: false, query: '', startOffset: 0 }); const isLocalChangeRef = useRef(false); - // Display name without the # prefix - const getContextDisplayName = (context: ContextItem): string => { - switch (context.type) { - case 'file': return context.fileName; - case 'directory': return context.directoryName; - case 'code-snippet': return `${context.fileName}:${context.startLine}-${context.endLine}`; - case 'image': return context.imageName; - case 'terminal-command': return context.command; - case 'git-ref': return context.refValue; - case 'url': return context.title || context.url; - case 'mermaid-node': return context.nodeText; - case 'mermaid-diagram': return context.diagramTitle || 'Mermaid diagram'; - case 'web-element': return context.tagName; - default: - // TypeScript exhaustiveness check - const _exhaustive: never = context; - return String(_exhaustive); - } - }; - - // # tag format for text extraction - const getContextTagFormat = (context: ContextItem): string => { - switch (context.type) { - case 'file': return `#file:${context.fileName}`; - case 'directory': return `#dir:${context.directoryName}`; - case 'code-snippet': return `#code:${context.fileName}:${context.startLine}-${context.endLine}`; - case 'image': return `#img:${context.imageName}`; - case 'terminal-command': return `#cmd:${context.command}`; - case 'git-ref': return `#git:${context.refValue}`; - case 'url': return `#link:${context.title || context.url}`; - case 'mermaid-node': return `#chart:${context.nodeText}`; - case 'mermaid-diagram': return `#mermaid:${context.diagramTitle || 'Mermaid diagram'}`; - case 'web-element': return `#element:${context.tagName}`; - default: - // TypeScript exhaustiveness check - const _exhaustive: never = context; - return String(_exhaustive); - } - }; - - // Full context path for tooltips - const getContextFullPath = (context: ContextItem): string => { - switch (context.type) { - case 'file': - return context.filePath; - case 'directory': - return context.directoryPath + (context.recursive ? ' (recursive)' : ''); - case 'code-snippet': - return `${context.filePath} (lines ${context.startLine}-${context.endLine})`; - case 'image': - return context.imagePath; - case 'terminal-command': - return context.workingDirectory ? `${context.command} @ ${context.workingDirectory}` : context.command; - case 'git-ref': - return `Git ${context.refType}: ${context.refValue}`; - case 'url': - return context.url; - case 'mermaid-node': - return context.diagramTitle ? `${context.diagramTitle} - ${context.nodeText}` : context.nodeText; - case 'mermaid-diagram': - return `Mermaid diagram${context.diagramTitle ? ': ' + context.diagramTitle : ''} (${context.diagramCode.length} chars)`; - case 'web-element': - return context.path; - default: - // TypeScript exhaustiveness check - const _exhaustive: never = context; - return String(_exhaustive); - } - }; - // Create tag element with pill style - const createTagElement = (context: ContextItem): HTMLSpanElement => { + const createTagElement = useCallback((context: ContextItem): HTMLSpanElement => { const tag = document.createElement('span'); tag.className = 'rich-text-tag-pill'; tag.contentEditable = 'false'; @@ -156,7 +153,7 @@ export const RichTextInput = React.forwardRef { @@ -196,8 +193,16 @@ export const RichTextInput = React.forwardRef { + const extractTextContent = useCallback((): string => { if (!internalRef.current) return ''; let text = ''; @@ -220,7 +225,7 @@ export const RichTextInput = React.forwardRef { @@ -305,13 +310,6 @@ export const RichTextInput = React.forwardRef { - // Strip zero-width and control characters that WebKit/WebView may inject - // (e.g. from dead-key sequences, function keys, arrow keys, etc.) - // Preserve normal whitespace: space (0x20), tab (0x09), newline (0x0A), carriage return (0x0D). - return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\u200B-\u200F\u2028\u2029\uFEFF\u2060\u00AD]/g, ''); - }; - /** Compute the cursor's character offset within the editor. */ const getCursorOffset = useCallback((editor: HTMLElement): number => { const sel = window.getSelection(); @@ -404,7 +402,7 @@ export const RichTextInput = React.forwardRef { detectMention(); }); - }, [contexts, onChange, detectMention, internalRef]); + }, [contexts, detectMention, extractTextContent, getCursorOffset, internalRef, onChange, setCursorOffset]); const handleBeforeInput = useCallback((e: React.FormEvent) => { const inputEvent = e.nativeEvent as InputEvent; @@ -456,7 +454,7 @@ export const RichTextInput = React.forwardRef { isComposingRef.current = false; }); - }, [onLargePaste, onMentionStateChange]); + }, [internalRef, onLargePaste, onMentionStateChange]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const nativeIsComposing = (e.nativeEvent as KeyboardEvent).isComposing; @@ -486,7 +484,7 @@ export const RichTextInput = React.forwardRef { @@ -518,7 +516,7 @@ export const RichTextInput = React.forwardRef { @@ -558,7 +556,7 @@ export const RichTextInput = React.forwardRef { @@ -646,7 +644,7 @@ export const RichTextInput = React.forwardRef { diff --git a/src/web-ui/src/flow_chat/components/TurnHistoryPanel.tsx b/src/web-ui/src/flow_chat/components/TurnHistoryPanel.tsx index 11bb2689..56eb38ae 100644 --- a/src/web-ui/src/flow_chat/components/TurnHistoryPanel.tsx +++ b/src/web-ui/src/flow_chat/components/TurnHistoryPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { snapshotAPI } from '@/infrastructure/api'; import type { TurnSnapshot } from '@/infrastructure/api/service-api/SnapshotAPI'; import { TurnRollbackButton } from './TurnRollbackButton'; @@ -20,11 +20,7 @@ export const TurnHistoryPanel: React.FC = ({ sessionId }) const [loading, setLoading] = useState(false); const [currentTurnIndex, setCurrentTurnIndex] = useState(-1); - useEffect(() => { - loadTurns(); - }, [sessionId]); - - const loadTurns = async () => { + const loadTurns = useCallback(async () => { if (!sessionId) return; setLoading(true); @@ -37,10 +33,14 @@ export const TurnHistoryPanel: React.FC = ({ sessionId }) } finally { setLoading(false); } - }; + }, [sessionId]); + + useEffect(() => { + void loadTurns(); + }, [loadTurns]); const handleRollbackComplete = () => { - loadTurns(); + void loadTurns(); }; if (loading) { @@ -104,4 +104,3 @@ export const TurnHistoryPanel: React.FC = ({ sessionId }) ); }; - diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx index db661262..2f530437 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ /** * Explore group renderer. * Renders merged explore-only rounds as a collapsible region. diff --git a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx index 1df9ea94..4786c115 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx @@ -249,7 +249,7 @@ export const ExportImageButton: React.FC = ({ day: '2-digit', hour: '2-digit', minute: '2-digit', - }).replace(/[\/:\s]/g, '-'); + }).replace(/[/:\s]/g, '-'); const fileName = `${i18nService.t('flow-chat:exportImage.fileNamePrefix')}_${timestampStr}.png`; const downloadsPath = await downloadDir(); const filePath = await join(downloadsPath, fileName); @@ -295,7 +295,7 @@ export const ExportImageButton: React.FC = ({ } finally { setIsExporting(false); } - }, [turnId, getDialogTurn]); + }, [getDialogTurn]); return ( diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index 52df920f..678890a8 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ /** * Model round item component. * Renders mixed FlowItems (text + tools). @@ -235,7 +236,7 @@ export const ModelRoundItem = React.memo( }); return contentParts.join('\n\n---\n\n'); - }, [turnId]); + }, [t, turnId]); const handleCopy = useCallback(async () => { try { diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 68de5084..371eab6f 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -100,7 +100,7 @@ export const ModernFlowChatContainer: React.FC = ( onSwitchToChatPanel, handleToolConfirm, handleToolReject, - activeSession?.sessionId, + activeSession, config, exploreGroupStates, handleExploreGroupToggle, diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx b/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx index 0fd730cc..24920da7 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx @@ -207,7 +207,7 @@ export const SessionFileModificationsBar: React.FC { const timeoutId = setTimeout(() => { diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx index ebc6a518..17490867 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx @@ -71,7 +71,7 @@ export const SessionFilesBadge: React.FC = ({ setFileStats(new Map()); setIsExpanded(false); } - }, [sessionId]); + }, [sessionId, t]); // Close the popover when clicking outside. useEffect(() => { @@ -204,7 +204,7 @@ export const SessionFilesBadge: React.FC = ({ } finally { setLoadingStats(false); } - }, [sessionId]); + }, [sessionId, t]); // Reload stats when the file list changes. useEffect(() => { @@ -299,7 +299,7 @@ export const SessionFilesBadge: React.FC = ({ } catch (error) { log.error('Failed to send review request', { sessionId, fileCount: fileStats.size, error }); } - }, [sessionId, fileStats]); + }, [fileStats, sessionId, t]); const getOperationIcon = (operationType: 'write' | 'edit' | 'delete') => { switch (operationType) { diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index 66675385..a6096359 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -944,8 +944,8 @@ export const VirtualMessageList = forwardRef((_, ref) => }, [ buildPinReservation, applyFooterCompensationNow, + getRenderedUserMessageElement, getTotalBottomCompensationPx, - latestTurnId, resolveTurnPinMetrics, schedulePinReservationReconcile, scheduleVisibleTurnMeasure, @@ -1325,11 +1325,12 @@ export const VirtualMessageList = forwardRef((_, ref) => pendingTurnPin?.pinMode, pendingTurnPin?.turnId, releaseAnchorLock, - restoreAnchorLockNow, scheduleHeightMeasure, + scheduleFollowToLatestWithViewportState, schedulePinReservationReconcile, scheduleVisibleTurnMeasure, scrollerElement, + shouldSuspendAutoFollow, updateBottomReservationState, ]); @@ -1462,7 +1463,7 @@ export const VirtualMessageList = forwardRef((_, ref) => setPendingTurnPin(null); virtuosoRef.current.scrollTo({ top: 999999999, behavior }); } - }, [getTotalBottomCompensationPx, releaseAnchorLock, virtualItems.length]); + }, [releaseAnchorLock, virtualItems.length]); const requestTurnPinToTop = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => { const requestedPinMode = options?.pinMode ?? 'transient'; 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 991a1d53..c5b57bea 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 @@ -154,7 +154,7 @@ export const ToolbarMode: React.FC = () => { } return { isStreaming, toolName, content }; - }, [flowChatState]); + }, [flowChatState, t]); // Window position is initialized in ToolbarModeContext.tsx to avoid conflicts. diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeContext.ts b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeContext.ts new file mode 100644 index 00000000..eb777d8c --- /dev/null +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeContext.ts @@ -0,0 +1,106 @@ +/** + * Toolbar Mode context. + * Manages global state for the single-window morph behavior. + * + * - Full mode: normal main window + * - Toolbar mode: compact floating bar + */ +import { createContext, useContext } from 'react'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('ToolbarModeContext'); + +// Toolbar window state for internal UI rendering. +export interface ToolbarModeState { + sessionId: string | null; + sessionTitle: string | null; // Current session title. + isProcessing: boolean; + latestContent: string; + latestToolName: string | null; + hasPendingConfirmation: boolean; + pendingToolId: string | null; + hasError: boolean; + todoProgress: { + completed: number; + total: number; + current: string; + } | null; +} + +export interface ToolbarModeContextType { + /** Whether toolbar mode is active. */ + isToolbarMode: boolean; + /** Whether expanded FlowChat view is active. */ + isExpanded: boolean; + /** Whether the window is pinned. */ + isPinned: boolean; + /** Enter toolbar mode. */ + enableToolbarMode: () => Promise; + /** Exit toolbar mode. */ + disableToolbarMode: () => Promise; + /** Toggle toolbar mode. */ + toggleToolbarMode: () => Promise; + /** Toggle expanded/compact view. */ + toggleExpanded: () => Promise; + /** Set pinned state. */ + setPinned: (pinned: boolean) => void; + /** Toggle pinned state. */ + togglePinned: () => void; + /** Toolbar render state. */ + toolbarState: ToolbarModeState; + /** Update toolbar state. */ + updateToolbarState: (state: Partial) => void; +} + +export const TOOLBAR_COMPACT_SIZE = { width: 700, height: 140 }; +export const TOOLBAR_COMPACT_MIN = { width: 400, height: 100 }; +export const TOOLBAR_EXPANDED_SIZE = { width: 700, height: 1400 }; +export const TOOLBAR_EXPANDED_MIN = { width: 400, height: 500 }; + +export const ToolbarModeContext = createContext(undefined); + +// Saved window state for restoring full mode. +export interface SavedWindowState { + x: number; + y: number; + width: number; + height: number; + isMaximized: boolean; + isDecorated?: boolean; +} + +// Default values for calls outside the provider. +const defaultContextValue: ToolbarModeContextType = { + isToolbarMode: false, + isExpanded: false, + isPinned: false, + enableToolbarMode: async () => { log.warn('Provider not found'); }, + disableToolbarMode: async () => { log.warn('Provider not found'); }, + toggleToolbarMode: async () => { log.warn('Provider not found'); }, + toggleExpanded: async () => { log.warn('Provider not found'); }, + setPinned: () => { log.warn('Provider not found'); }, + togglePinned: () => { log.warn('Provider not found'); }, + toolbarState: { + sessionId: null, + sessionTitle: null, + isProcessing: false, + latestContent: '', + latestToolName: null, + hasPendingConfirmation: false, + pendingToolId: null, + hasError: false, + todoProgress: null + }, + updateToolbarState: () => { log.warn('Provider not found'); } +}; + +export const useToolbarModeContext = (): ToolbarModeContextType => { + const context = useContext(ToolbarModeContext); + if (!context) { + log.warn('useToolbarModeContext called outside of ToolbarModeProvider, using default values'); + return defaultContextValue; + } + return context; +}; + +export default ToolbarModeContext; diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeContext.tsx b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx similarity index 57% rename from src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeContext.tsx rename to src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx index 8f985a13..80d2e254 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeContext.tsx +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx @@ -1,85 +1,24 @@ -/** - * Toolbar Mode context. - * Manages global state for the single-window morph behavior. - * - * - Full mode: normal main window - * - Toolbar mode: compact floating bar - */ - -import React, { createContext, useContext, useState, useCallback, useRef, useMemo, ReactNode } from 'react'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { PhysicalSize, PhysicalPosition } from '@tauri-apps/api/dpi'; -import { currentMonitor } from '@tauri-apps/api/window'; +import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { currentMonitor, getCurrentWindow } from '@tauri-apps/api/window'; +import { PhysicalPosition, PhysicalSize } from '@tauri-apps/api/dpi'; import { createLogger } from '@/shared/utils/logger'; +import { + TOOLBAR_COMPACT_MIN, + TOOLBAR_COMPACT_SIZE, + TOOLBAR_EXPANDED_MIN, + TOOLBAR_EXPANDED_SIZE, + ToolbarModeContext, + type SavedWindowState, + type ToolbarModeContextType, + type ToolbarModeState, +} from './ToolbarModeContext'; const log = createLogger('ToolbarModeContext'); -// Toolbar window state for internal UI rendering. -export interface ToolbarModeState { - sessionId: string | null; - sessionTitle: string | null; // Current session title. - isProcessing: boolean; - latestContent: string; - latestToolName: string | null; - hasPendingConfirmation: boolean; - pendingToolId: string | null; - hasError: boolean; - todoProgress: { - completed: number; - total: number; - current: string; - } | null; -} - -export interface ToolbarModeContextType { - /** Whether toolbar mode is active. */ - isToolbarMode: boolean; - /** Whether expanded FlowChat view is active. */ - isExpanded: boolean; - /** Whether the window is pinned. */ - isPinned: boolean; - /** Enter toolbar mode. */ - enableToolbarMode: () => Promise; - /** Exit toolbar mode. */ - disableToolbarMode: () => Promise; - /** Toggle toolbar mode. */ - toggleToolbarMode: () => Promise; - /** Toggle expanded/compact view. */ - toggleExpanded: () => Promise; - /** Set pinned state. */ - setPinned: (pinned: boolean) => void; - /** Toggle pinned state. */ - togglePinned: () => void; - /** Toolbar render state. */ - toolbarState: ToolbarModeState; - /** Update toolbar state. */ - updateToolbarState: (state: Partial) => void; -} - -// Window size config (physical pixels). -// Compact mode uses a fixed two-row layout. -const TOOLBAR_COMPACT_SIZE = { width: 700, height: 140 }; -const TOOLBAR_COMPACT_MIN = { width: 400, height: 100 }; -// Expanded mode shows the full FlowChat UI. -const TOOLBAR_EXPANDED_SIZE = { width: 700, height: 1400 }; -const TOOLBAR_EXPANDED_MIN = { width: 400, height: 500 }; - -const ToolbarModeContext = createContext(undefined); - interface ToolbarModeProviderProps { children: ReactNode; } -// Saved window state for restoring full mode. -interface SavedWindowState { - x: number; - y: number; - width: number; - height: number; - isMaximized: boolean; - isDecorated?: boolean; -} - export const ToolbarModeProvider: React.FC = ({ children }) => { const [isToolbarMode, setIsToolbarMode] = useState(false); const [isExpanded, setIsExpanded] = useState(false); @@ -93,17 +32,13 @@ export const ToolbarModeProvider: React.FC = ({ childr hasPendingConfirmation: false, pendingToolId: null, hasError: false, - todoProgress: null + todoProgress: null, }); - - // Persist full-mode window state for restore. + const savedWindowStateRef = useRef(null); - + const enableToolbarMode = useCallback(async () => { try { - // Signal all native webview overlays (browser panel/scene) to hide themselves - // immediately, before any window resize operations, to prevent them from - // covering the toolbar UI during the transition. window.dispatchEvent(new CustomEvent('toolbar-mode-activating')); const win = getCurrentWindow(); @@ -113,8 +48,7 @@ export const ToolbarModeProvider: React.FC = ({ childr typeof navigator !== 'undefined' && typeof navigator.platform === 'string' && navigator.platform.toUpperCase().includes('MAC'); - - // Capture current window state. + const [position, size, isMaximized, isDecorated] = await Promise.all([ win.outerPosition(), win.outerSize(), @@ -129,7 +63,7 @@ export const ToolbarModeProvider: React.FC = ({ childr return undefined; })(), ]); - + savedWindowStateRef.current = { x: position.x, y: position.y, @@ -138,30 +72,27 @@ export const ToolbarModeProvider: React.FC = ({ childr isMaximized, isDecorated, }; - - // Update state first so React renders the toolbar UI. + setIsToolbarMode(true); - setIsExpanded(true); // Enter expanded FlowChat by default. - + setIsExpanded(true); + if (isMaximized) { await win.unmaximize(); } - + let x = 100; let y = 100; - + const monitor = await currentMonitor(); if (monitor) { const scaleFactor = await win.scaleFactor(); const margin = Math.round(20 * scaleFactor); - const taskbarHeight = Math.round(50 * scaleFactor); // Estimated taskbar height. - + const taskbarHeight = Math.round(50 * scaleFactor); + x = monitor.size.width - TOOLBAR_EXPANDED_SIZE.width - margin; y = monitor.size.height - TOOLBAR_EXPANDED_SIZE.height - margin - taskbarHeight; } - - // Apply window props after toolbar UI renders. - // macOS: avoid decorations toggles to preserve overlay title bar behavior. + const toolbarWindowOps: Array> = [ win.setAlwaysOnTop(true), win.setSize(new PhysicalSize(TOOLBAR_EXPANDED_SIZE.width, TOOLBAR_EXPANDED_SIZE.height)), @@ -178,21 +109,19 @@ export const ToolbarModeProvider: React.FC = ({ childr } } await Promise.all(toolbarWindowOps); - + await win.setMinSize(new PhysicalSize(TOOLBAR_EXPANDED_MIN.width, TOOLBAR_EXPANDED_MIN.height)); - } catch (error) { log.error('Failed to enable toolbar mode', error); setIsToolbarMode(false); } }, []); - + const disableToolbarMode = useCallback(async () => { try { - // Update state first so React renders the full UI. setIsToolbarMode(false); setIsExpanded(false); - + const win = getCurrentWindow(); const isMacOS = typeof window !== 'undefined' && @@ -201,7 +130,7 @@ export const ToolbarModeProvider: React.FC = ({ childr typeof navigator.platform === 'string' && navigator.platform.toUpperCase().includes('MAC'); const saved = savedWindowStateRef.current; - + await win.setMinSize(null); if (isMacOS) { @@ -218,17 +147,17 @@ export const ToolbarModeProvider: React.FC = ({ childr log.debug('Failed to restore window decorations (ignored)', error); } } - + await Promise.all([ win.setAlwaysOnTop(false), win.setResizable(true), - win.setSkipTaskbar(false) + win.setSkipTaskbar(false), ]); - + if (saved) { await win.setSize(new PhysicalSize(saved.width, saved.height)); await win.setPosition(new PhysicalPosition(saved.x, saved.y)); - + if (saved.isMaximized) { await win.maximize(); } @@ -237,7 +166,6 @@ export const ToolbarModeProvider: React.FC = ({ childr await win.center(); } - // macOS: re-apply overlay after resize/maximize in case it was interrupted. if (isMacOS) { try { await win.setTitleBarStyle('overlay'); @@ -247,62 +175,63 @@ export const ToolbarModeProvider: React.FC = ({ childr log.debug('Failed to re-apply macOS overlay title bar (ignored)', error); } } - + await win.setFocus(); - } catch (error) { log.error('Failed to disable toolbar mode', error); } }, []); - + const toggleToolbarMode = useCallback(async () => { if (isToolbarMode) { await disableToolbarMode(); } else { await enableToolbarMode(); } - }, [isToolbarMode, enableToolbarMode, disableToolbarMode]); - + }, [disableToolbarMode, enableToolbarMode, isToolbarMode]); + const toggleExpanded = useCallback(async () => { if (!isToolbarMode) return; - + const newIsExpanded = !isExpanded; - + try { const win = getCurrentWindow(); - const targetSize = newIsExpanded ? TOOLBAR_EXPANDED_SIZE : TOOLBAR_COMPACT_SIZE; const minSize = newIsExpanded ? TOOLBAR_EXPANDED_MIN : TOOLBAR_COMPACT_MIN; - const currentPosition = await win.outerPosition(); const currentSize = await win.outerSize(); - const heightDiff = targetSize.height - currentSize.height; const newY = currentPosition.y - heightDiff; - + setIsExpanded(newIsExpanded); - + await win.setMinSize(new PhysicalSize(minSize.width, minSize.height)); await win.setSize(new PhysicalSize(targetSize.width, targetSize.height)); await win.setPosition(new PhysicalPosition(currentPosition.x, Math.max(0, newY))); - } catch (error) { log.error('Failed to toggle expanded state', { newIsExpanded, error }); } - }, [isToolbarMode, isExpanded]); - + }, [isExpanded, isToolbarMode]); + const setPinned = useCallback((pinned: boolean) => { setIsPinned(pinned); }, []); - + const togglePinned = useCallback(() => { - setIsPinned(prev => !prev); + setIsPinned((prev) => !prev); }, []); - + const updateToolbarState = useCallback((updates: Partial) => { - setToolbarState(prev => ({ ...prev, ...updates })); + setToolbarState((prev) => ({ ...prev, ...updates })); }, []); - + + useEffect(() => { + return () => { + // No background timers to clean up here; window state is restored by user actions. + }; + }, []); + const value: ToolbarModeContextType = useMemo(() => ({ isToolbarMode, isExpanded, @@ -328,46 +257,6 @@ export const ToolbarModeProvider: React.FC = ({ childr toolbarState, updateToolbarState, ]); - - return ( - - {children} - - ); -}; - -// Default values for calls outside the provider. -const defaultContextValue: ToolbarModeContextType = { - isToolbarMode: false, - isExpanded: false, - isPinned: false, - enableToolbarMode: async () => { log.warn('Provider not found'); }, - disableToolbarMode: async () => { log.warn('Provider not found'); }, - toggleToolbarMode: async () => { log.warn('Provider not found'); }, - toggleExpanded: async () => { log.warn('Provider not found'); }, - setPinned: () => { log.warn('Provider not found'); }, - togglePinned: () => { log.warn('Provider not found'); }, - toolbarState: { - sessionId: null, - sessionTitle: null, - isProcessing: false, - latestContent: '', - latestToolName: null, - hasPendingConfirmation: false, - pendingToolId: null, - hasError: false, - todoProgress: null - }, - updateToolbarState: () => { log.warn('Provider not found'); } -}; -export const useToolbarModeContext = (): ToolbarModeContextType => { - const context = useContext(ToolbarModeContext); - if (!context) { - log.warn('useToolbarModeContext called outside of ToolbarModeProvider, using default values'); - return defaultContextValue; - } - return context; + return {children}; }; - -export default ToolbarModeContext; diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/index.ts b/src/web-ui/src/flow_chat/components/toolbar-mode/index.ts index e4043869..07c8cb68 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/index.ts +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/index.ts @@ -5,10 +5,9 @@ export { ToolbarMode, type ToolbarModeProps } from './ToolbarMode'; export { - ToolbarModeProvider, useToolbarModeContext, type ToolbarModeContextType, type ToolbarModeState } from './ToolbarModeContext'; - +export { ToolbarModeProvider } from './ToolbarModeProvider'; diff --git a/src/web-ui/src/flow_chat/hooks/useFlowChat.ts b/src/web-ui/src/flow_chat/hooks/useFlowChat.ts index 586baf46..a3c9baa7 100644 --- a/src/web-ui/src/flow_chat/hooks/useFlowChat.ts +++ b/src/web-ui/src/flow_chat/hooks/useFlowChat.ts @@ -203,25 +203,25 @@ export const useFlowChat = () => { } }, []); - // Avoid useCallback to always read the latest state. - const getActiveSession = (): Session | null => { + const getActiveSession = useCallback((): Session | null => { + const currentState = flowChatStore.getState(); const session = flowChatStore.getActiveSession(); if (!session) { - log.warn('No active session', { activeSessionId: state.activeSessionId }); + log.warn('No active session', { activeSessionId: currentState.activeSessionId }); } return session; - }; + }, []); - // Avoid useCallback to always read the latest state. - const getLatestDialogTurn = (sessionId?: string): DialogTurn | null => { - const targetSessionId = sessionId || state.activeSessionId; + const getLatestDialogTurn = useCallback((sessionId?: string): DialogTurn | null => { + const currentState = flowChatStore.getState(); + const targetSessionId = sessionId || currentState.activeSessionId; if (!targetSessionId) return null; - const session = state.sessions.get(targetSessionId); + const session = currentState.sessions.get(targetSessionId); if (!session || session.dialogTurns.length === 0) return null; return session.dialogTurns[session.dialogTurns.length - 1]; - }; + }, []); const deleteSession = useCallback(async (sessionId: string) => { try { @@ -406,7 +406,7 @@ export const useFlowChat = () => { } processingLock.current = true; - }, [state.activeSessionId, state.sessions]); + }, [state.activeSessionId]); const endMessageProcessing = useCallback(() => { processingLock.current = false; diff --git a/src/web-ui/src/flow_chat/services/BtwThreadService.ts b/src/web-ui/src/flow_chat/services/BtwThreadService.ts index b6f91ad5..94a31c9b 100644 --- a/src/web-ui/src/flow_chat/services/BtwThreadService.ts +++ b/src/web-ui/src/flow_chat/services/BtwThreadService.ts @@ -12,7 +12,6 @@ const log = createLogger('BtwThreadService'); function safeUuid(prefix = 'btw'): string { try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const fn = (globalThis as any)?.crypto?.randomUUID as (() => string) | undefined; if (fn) return fn(); } catch { diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.ts b/src/web-ui/src/flow_chat/services/EventBatcher.ts index f2d7459b..00af41f9 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.ts @@ -207,7 +207,7 @@ interface BaseToolEvent { tool_name: string; } -export interface EarlyDetectedToolEvent extends BaseToolEvent<'EarlyDetected'> {} +export type EarlyDetectedToolEvent = BaseToolEvent<'EarlyDetected'>; export interface ParamsPartialToolEvent extends BaseToolEvent<'ParamsPartial'> { params: string; @@ -242,9 +242,9 @@ export interface ConfirmationNeededToolEvent extends BaseToolEvent<'Confirmation params: unknown; } -export interface ConfirmedToolEvent extends BaseToolEvent<'Confirmed'> {} +export type ConfirmedToolEvent = BaseToolEvent<'Confirmed'>; -export interface RejectedToolEvent extends BaseToolEvent<'Rejected'> {} +export type RejectedToolEvent = BaseToolEvent<'Rejected'>; export interface CompletedToolEvent extends BaseToolEvent<'Completed'> { result: unknown; @@ -449,4 +449,3 @@ export function parseEventKey(key: string): { return null; } - diff --git a/src/web-ui/src/flow_chat/state-machine/derivedState.ts b/src/web-ui/src/flow_chat/state-machine/derivedState.ts index b65e125c..aaf5bc8e 100644 --- a/src/web-ui/src/flow_chat/state-machine/derivedState.ts +++ b/src/web-ui/src/flow_chat/state-machine/derivedState.ts @@ -189,19 +189,21 @@ function getProgressBarLabel( case ProcessingPhase.THINKING: return 'Thinking...'; - case ProcessingPhase.STREAMING: + case ProcessingPhase.STREAMING: { const chars = context.stats.textCharsGenerated; const duration = context.stats.startTime ? ((Date.now() - context.stats.startTime) / 1000).toFixed(1) : '0'; return `Generating response (${chars} chars) · ${duration}s`; + } case ProcessingPhase.FINALIZING: return 'Finalizing response...'; - case ProcessingPhase.TOOL_CALLING: + case ProcessingPhase.TOOL_CALLING: { const toolsExecuted = context.stats.toolsExecuted; return `Executing tools... (${toolsExecuted} completed)`; + } case ProcessingPhase.TOOL_CONFIRMING: return 'Waiting for tool confirmation...'; @@ -253,4 +255,3 @@ function detectErrorType(errorMessage: string): SessionDerivedState['errorType'] return 'unknown'; } - diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 3da1a318..14e55ace 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -1069,18 +1069,9 @@ export class FlowChatStore { } public updateModelRoundItem(sessionId: string, dialogTurnId: string, itemId: string, updates: Partial): void { - let foundItemsCount = 0; - this.updateDialogTurn(sessionId, dialogTurnId, turn => { let updated = false; - turn.modelRounds.forEach(modelRound => { - const hasItem = modelRound.items.some((item: any) => item.id === itemId); - if (hasItem) { - foundItemsCount++; - } - }); - const updatedModelRounds = turn.modelRounds.map(modelRound => { if (updated) return modelRound; diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx index 5106fa41..c756c20f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx @@ -66,7 +66,7 @@ export const AskUserQuestionCard: React.FC = ({ const paramsSource = partialParams || toolCall?.input; const questions = useMemo( () => normalizeQuestionsFromParams(paramsSource), - [partialParams, toolCall?.input] + [paramsSource] ); const awaitingPayload = isAwaitingQuestionPayload( @@ -195,6 +195,19 @@ export const AskUserQuestionCard: React.FC = ({ return t('toolCards.askUser.waitingAnswer'); }; + const getEffectiveAnswer = useCallback((questionIndex: number): string | string[] | undefined => { + const localAnswer = answers[questionIndex]; + if (localAnswer !== undefined) return localAnswer; + + if (status === 'completed' && toolResult?.result) { + const result = typeof toolResult.result === 'string' + ? JSON.parse(toolResult.result) + : toolResult.result; + return result?.answers?.[String(questionIndex)]; + } + return undefined; + }, [answers, status, toolResult]); + const renderQuestion = (q: QuestionData, questionIndex: number) => { const answer = getEffectiveAnswer(questionIndex); const otherInput = otherInputs[questionIndex] || ''; @@ -330,19 +343,6 @@ export const AskUserQuestionCard: React.FC = ({ ); }; - const getEffectiveAnswer = useCallback((questionIndex: number): string | string[] | undefined => { - const localAnswer = answers[questionIndex]; - if (localAnswer !== undefined) return localAnswer; - - if (status === 'completed' && toolResult?.result) { - const result = typeof toolResult.result === 'string' - ? JSON.parse(toolResult.result) - : toolResult.result; - return result?.answers?.[String(questionIndex)]; - } - return undefined; - }, [answers, status, toolResult]); - const getAnswerDisplay = (questionIndex: number): string => { const answer = getEffectiveAnswer(questionIndex); const otherInput = otherInputs[questionIndex] || ''; diff --git a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx index f1ed9895..2c7a4d40 100644 --- a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx @@ -2,9 +2,14 @@ * Common tool card component * Provides unified card styles and interaction logic */ - -import React, { ReactNode, createContext, useContext } from 'react'; +import React, { ReactNode } from 'react'; import { ChevronDown, ChevronRight } from 'lucide-react'; +import { + ToolCardHeaderLayoutContext, + useToolCardHeaderLayout, + type ToolCardHeaderAffordanceKind, + type ToolCardHeaderLayoutContextValue, +} from './ToolCardHeaderLayoutContext'; import './BaseToolCard.scss'; const LOADING_SHIMMER_STATUSES = new Set([ @@ -18,28 +23,6 @@ function statusUsesLoadingShimmer(status: string): boolean { return LOADING_SHIMMER_STATUSES.has(status); } -/** Hover swap on the left tool icon: inline expand vs open in right panel. */ -export type ToolCardHeaderAffordanceKind = 'expand' | 'open-panel-right'; - -/** Layout hints for ToolCardHeader (icon rail + expand affordance). */ -export interface ToolCardHeaderLayoutContextValue { - /** When true, header icon swaps to chevron on row hover (down = inline expand, right = open right). */ - headerExpandAffordance: boolean; - /** Which hint icon to show when headerExpandAffordance is true. */ - headerAffordanceKind: ToolCardHeaderAffordanceKind; - isExpanded: boolean; -} - -export const ToolCardHeaderLayoutContext = createContext({ - headerExpandAffordance: false, - headerAffordanceKind: 'expand', - isExpanded: false, -}); - -export function useToolCardHeaderLayout(): ToolCardHeaderLayoutContextValue { - return useContext(ToolCardHeaderLayoutContext); -} - export interface BaseToolCardProps { /** Tool status */ status: 'pending' | 'preparing' | 'streaming' | 'running' | 'completed' | 'error' | 'cancelled' | 'analyzing' | 'pending_confirmation' | 'confirmed'; @@ -218,4 +201,3 @@ export const ToolCardHeader: React.FC = ({ ); }; - diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx index c6c425b9..87e85188 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx @@ -354,7 +354,7 @@ export const CodeReviewToolCard: React.FC = React.memo(({ )} ); - }, [reviewData, isExpanded, t]); + }, [reviewData, t]); const normalizedStatus = status === 'analyzing' ? 'running' : status; diff --git a/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx index e06f5713..47c375fb 100644 --- a/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx @@ -399,15 +399,20 @@ export const MCPToolDisplay: React.FC = ({ // Set up response listener before emitting const responsePromise = new Promise((resolve) => { + let handleResponse: ((response: McpAppMessageResponseEvent) => void) | null = null; const timeout = setTimeout(() => { - globalEventBus.off('mcp-app:message-response', handleResponse); + if (handleResponse) { + globalEventBus.off('mcp-app:message-response', handleResponse); + } resolve({ isError: true }); }, 5000); // 5 second timeout - const handleResponse = (response: McpAppMessageResponseEvent) => { + handleResponse = (response: McpAppMessageResponseEvent) => { if (response.requestId === requestId) { clearTimeout(timeout); - globalEventBus.off('mcp-app:message-response', handleResponse); + if (handleResponse) { + globalEventBus.off('mcp-app:message-response', handleResponse); + } resolve(response.result); } }; diff --git a/src/web-ui/src/flow_chat/tool-cards/MermaidInteractiveDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/MermaidInteractiveDisplay.tsx index 75e37574..1116c075 100644 --- a/src/web-ui/src/flow_chat/tool-cards/MermaidInteractiveDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/MermaidInteractiveDisplay.tsx @@ -50,7 +50,7 @@ export const MermaidInteractiveDisplay: React.FC = ({ const { t } = useTranslation('flow-chat'); const { status, toolCall, toolResult } = toolItem; - const getInputData = () => { + const getInputData = useCallback(() => { if (!toolCall?.input) return null; const isEarlyDetection = toolCall.input._early_detection === true; @@ -64,9 +64,9 @@ export const MermaidInteractiveDisplay: React.FC = ({ if (inputKeys.length === 0) return null; return toolCall.input; - }; + }, [toolCall?.input]); - const getResultData = () => { + const getResultData = useCallback(() => { if (!toolResult?.result) return null; try { @@ -78,7 +78,7 @@ export const MermaidInteractiveDisplay: React.FC = ({ log.error('Failed to parse result', e); return null; } - }; + }, [toolResult?.result]); const handleOpenMermaid = useCallback(() => { // Read the latest data from store first, fallback to props if unavailable. @@ -139,7 +139,7 @@ export const MermaidInteractiveDisplay: React.FC = ({ detail: eventData })); }, 100); - }, [toolCall, toolResult]); + }, [getInputData, getResultData, t, toolCall, toolItem.id]); const inputData = getInputData(); diff --git a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx index 0aa4f79d..bd090fb0 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx @@ -42,7 +42,7 @@ export const ReadFileDisplay: React.FC = React.memo(({ } return path; - }, [toolCall?.input]); + }, [t, toolCall?.input]); const handleOpenInEditor = () => { if (filePath !== t('toolCards.readFile.noFileSpecified') && filePath !== t('toolCards.readFile.parsingParams')) { @@ -55,7 +55,7 @@ export const ReadFileDisplay: React.FC = React.memo(({ return filePath || t('toolCards.readFile.noFileSpecified'); } return filePath.split('/').pop() || filePath.split('\\').pop() || filePath; - }, [filePath]); + }, [filePath, t]); const lineRange = useMemo(() => { const start_line = toolCall?.input?.start_line; diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx index f95bb76b..30d060eb 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx @@ -54,7 +54,7 @@ export const TaskToolDisplay: React.FC = ({ reason: 'manual' | 'auto' = 'manual', ) => { applyExpandedState(isExpanded, nextExpanded, setIsExpanded, { reason }); - }, [applyExpandedState, isExpanded, isRunning, status, toolId]); + }, [applyExpandedState, isExpanded]); useEffect(() => { const prevStatus = prevStatusRef.current; diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index 9fcb7252..8b105b01 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -293,7 +293,7 @@ export const TerminalToolCard: React.FC = ({ } finally { setIsExecuting(false); } - }, [command, editedCommand, isEditingCommand, toolCall?.input, onConfirm]); + }, [applyExpandedState, command, editedCommand, isEditingCommand, onConfirm, toolCall?.input]); const handleReject = useCallback((e: React.MouseEvent) => { e.stopPropagation(); diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolCardHeaderLayoutContext.ts b/src/web-ui/src/flow_chat/tool-cards/ToolCardHeaderLayoutContext.ts new file mode 100644 index 00000000..dbb6665b --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ToolCardHeaderLayoutContext.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; + +export type ToolCardHeaderAffordanceKind = 'expand' | 'open-panel-right'; + +export interface ToolCardHeaderLayoutContextValue { + headerExpandAffordance: boolean; + headerAffordanceKind: ToolCardHeaderAffordanceKind; + isExpanded: boolean; +} + +export const ToolCardHeaderLayoutContext = createContext({ + headerExpandAffordance: false, + headerAffordanceKind: 'expand', + isExpanded: false, +}); + +export function useToolCardHeaderLayout(): ToolCardHeaderLayoutContextValue { + return useContext(ToolCardHeaderLayoutContext); +} diff --git a/src/web-ui/src/flow_chat/tool-cards/index.ts b/src/web-ui/src/flow_chat/tool-cards/index.ts index 9ea45b4c..e8a76f26 100644 --- a/src/web-ui/src/flow_chat/tool-cards/index.ts +++ b/src/web-ui/src/flow_chat/tool-cards/index.ts @@ -448,15 +448,19 @@ export function getAllToolNames(): string[] { export { BaseToolCard, ToolCardHeader, +} from './BaseToolCard'; +export { ToolCardHeaderLayoutContext, useToolCardHeaderLayout, -} from './BaseToolCard'; +} from './ToolCardHeaderLayoutContext'; export type { BaseToolCardProps, ToolCardHeaderProps, +} from './BaseToolCard'; +export type { ToolCardHeaderLayoutContextValue, ToolCardHeaderAffordanceKind, -} from './BaseToolCard'; +} from './ToolCardHeaderLayoutContext'; export { PlanDisplay } from './CreatePlanDisplay'; export type { PlanDisplayProps } from './CreatePlanDisplay'; diff --git a/src/web-ui/src/hooks/useFileSearch.ts b/src/web-ui/src/hooks/useFileSearch.ts index 072e6514..d42b87f5 100644 --- a/src/web-ui/src/hooks/useFileSearch.ts +++ b/src/web-ui/src/hooks/useFileSearch.ts @@ -216,7 +216,7 @@ export function useFileSearch(options: UseFileSearchOptions = {}): UseFileSearch if (query.trim().length >= minSearchLength) { triggerSearch(query); } - }, [searchOptions]); + }, [searchOptions, minSearchLength, query, triggerSearch]); useEffect(() => { return () => { diff --git a/src/web-ui/src/hooks/useModelConfigs.ts b/src/web-ui/src/hooks/useModelConfigs.ts index d7956b95..960cb1bc 100644 --- a/src/web-ui/src/hooks/useModelConfigs.ts +++ b/src/web-ui/src/hooks/useModelConfigs.ts @@ -114,7 +114,7 @@ export const useCurrentModelConfig = (initialConfigId?: string) => { setCurrentConfig(updatedConfig); } } - }, [configs, initialConfigId]); + }, [configs, currentConfig, initialConfigId]); return { currentConfig, diff --git a/src/web-ui/src/hooks/useToolExecution.ts b/src/web-ui/src/hooks/useToolExecution.ts index eb0a279d..b8c6c2bb 100644 --- a/src/web-ui/src/hooks/useToolExecution.ts +++ b/src/web-ui/src/hooks/useToolExecution.ts @@ -55,7 +55,7 @@ export const useToolExecution = ( const service = ToolExecutionService.getInstance(); setActiveExecutions(service.getActiveExecutions()); } - }, []); + }, [maxMessages]); useEffect(() => { if (!autoConnect) return; diff --git a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts index e047864a..49363209 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts @@ -165,7 +165,7 @@ export class ApiClient implements IApiClient { await this.invoke('ping', {}, { timeout: 5000, retries: 1 }); return true; - } catch (error) { + } catch (_error) { return false; } } diff --git a/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts b/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts index 6ccd862a..95b3f760 100644 --- a/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts @@ -76,7 +76,7 @@ export interface OpenRemoteWorkspaceRequest { sshHost?: string; } -export interface CreateAssistantWorkspaceRequest {} +export type CreateAssistantWorkspaceRequest = Record; export interface CloseWorkspaceRequest { workspaceId: string; diff --git a/src/web-ui/src/infrastructure/api/service-api/MCPAPI.ts b/src/web-ui/src/infrastructure/api/service-api/MCPAPI.ts index 40723801..a0a8b953 100644 --- a/src/web-ui/src/infrastructure/api/service-api/MCPAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/MCPAPI.ts @@ -79,13 +79,13 @@ export interface McpUiResourceCsp { /** Sandbox permissions requested by the UI resource (aligned with VSCode). */ export interface McpUiResourcePermissions { /** Request camera access. */ - camera?: {}; + camera?: Record; /** Request microphone access. */ - microphone?: {}; + microphone?: Record; /** Request geolocation access. */ - geolocation?: {}; + geolocation?: Record; /** Request clipboard write access. */ - clipboardWrite?: {}; + clipboardWrite?: Record; } // ==================== MCP App ui/message types ==================== diff --git a/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.tsx index 9c855b7c..9442c048 100644 --- a/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.tsx @@ -43,11 +43,7 @@ const AIFeaturesConfig: React.FC = () => { const [models, setModels] = useState([]); const [funcAgentModels, setFuncAgentModels] = useState>({}); - useEffect(() => { - loadAllData(); - }, []); - - const loadAllData = async () => { + const loadAllData = useCallback(async () => { setIsLoading(true); try { @@ -70,7 +66,11 @@ const AIFeaturesConfig: React.FC = () => { } finally { setIsLoading(false); } - }; + }, []); + + useEffect(() => { + void loadAllData(); + }, [loadAllData]); const getModelName = useCallback((modelId: string | null | undefined): string | undefined => { @@ -99,6 +99,11 @@ const AIFeaturesConfig: React.FC = () => { }; + function getFeatureIdByAgent(agentName: string): string { + const feature = FEATURE_CONFIGS.find(f => f.agentName === agentName); + return feature?.id || agentName; + } + const handleAgentSelectionChange = async ( agentName: string, modelId: string @@ -135,11 +140,6 @@ const AIFeaturesConfig: React.FC = () => { }; - const getFeatureIdByAgent = (agentName: string): string => { - const feature = FEATURE_CONFIGS.find(f => f.agentName === agentName); - return feature?.id || agentName; - }; - const enabledModels = models.filter(m => m.enabled); diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index 9e7ad7a9..8cfa34e2 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Plus, SquarePen, Trash2, Wifi, Loader, AlertTriangle, X, Settings, ExternalLink, Eye, EyeOff } from 'lucide-react'; import { Button, Switch, Select, IconButton, NumberInput, Card, Checkbox, Modal, Input, Textarea, type SelectOption } from '@/component-library'; @@ -273,11 +273,7 @@ const AIModelConfig: React.FC = () => { ); - useEffect(() => { - loadConfig(); - }, []); - - const loadConfig = async () => { + const loadConfig = useCallback(async () => { try { const models = await configManager.getConfig('ai.models') || []; const proxy = await configManager.getConfig('ai.proxy'); @@ -288,10 +284,17 @@ const AIModelConfig: React.FC = () => { } catch (error) { log.error('Failed to load AI config', error); } - }; + }, []); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); // Provider options with translations (must be at top level, before any conditional returns) - const providerOrder = ['openbitfun', 'zhipu', 'qwen', 'deepseek', 'volcengine', 'minimax', 'moonshot', 'gemini', 'anthropic']; + const providerOrder = useMemo( + () => ['openbitfun', 'zhipu', 'qwen', 'deepseek', 'volcengine', 'minimax', 'moonshot', 'gemini', 'anthropic'], + [] + ); const providers = useMemo(() => { const sorted = Object.values(PROVIDER_TEMPLATES).sort((a, b) => { const indexA = providerOrder.indexOf(a.id); @@ -305,7 +308,7 @@ const AIModelConfig: React.FC = () => { name: t(`providers.${provider.id}.name`), description: t(`providers.${provider.id}.description`) })); - }, [t]); + }, [providerOrder, t]); // Current template with translations (must be at top level, before any conditional returns) const currentTemplate = useMemo(() => { diff --git a/src/web-ui/src/infrastructure/config/components/AIRulesMemoryConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIRulesMemoryConfig.tsx index 87eb4485..8a7561a5 100644 --- a/src/web-ui/src/infrastructure/config/components/AIRulesMemoryConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIRulesMemoryConfig.tsx @@ -1,10 +1,11 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ /** * AIRulesMemoryConfig — merged Rules & Memory settings page. * Two sections (Rules / Memory), each with inner tabs: User | Project. * Rules: full CRUD for user/project. Memory: user-level CRUD; project-level placeholder. */ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Plus, Edit2, Trash2, X, Eye, EyeOff } from 'lucide-react'; import { Select, Input, Textarea, Button, IconButton, Switch, Tooltip, Modal } from '@/component-library'; @@ -338,7 +339,7 @@ function MemoryPanel() { const [editingMemory, setEditingMemory] = useState(null); const [scopeTab, setScopeTab] = useState('user'); - const loadMemories = async () => { + const loadMemories = useCallback(async () => { try { setLoading(true); const data = await getAllMemories(); @@ -348,11 +349,11 @@ function MemoryPanel() { } finally { setLoading(false); } - }; + }, [notification, t]); React.useEffect(() => { if (scopeTab === 'user') loadMemories(); - }, [scopeTab]); + }, [scopeTab, loadMemories]); const memoryTypeMap: Record = { tech_preference: { label: t('memoryTypes.tech_preference'), color: '#60a5fa' }, diff --git a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx index 7c43d848..8515444c 100644 --- a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx @@ -45,19 +45,19 @@ function BasicsAppearanceSection() { await setTheme(newThemeId); }; - const getThemeDisplayName = (theme: ThemeMetadata) => { + const getThemeDisplayName = useCallback((theme: ThemeMetadata) => { const i18nKey = `appearance.presets.${theme.id}`; return theme.builtin ? t(`${i18nKey}.name`, { defaultValue: theme.name }) : theme.name; - }; + }, [t]); - const getThemeDisplayDescription = (theme: ThemeMetadata) => { + const getThemeDisplayDescription = useCallback((theme: ThemeMetadata) => { const i18nKey = `appearance.presets.${theme.id}`; return theme.builtin ? t(`${i18nKey}.description`, { defaultValue: theme.description || '' }) : theme.description || ''; - }; + }, [t]); const themeSelectOptions = useMemo( () => [ @@ -72,7 +72,7 @@ function BasicsAppearanceSection() { description: getThemeDisplayDescription(theme), })), ], - [themes, t] + [themes, t, getThemeDisplayDescription, getThemeDisplayName] ); return ( diff --git a/src/web-ui/src/infrastructure/config/components/EditorConfig.tsx b/src/web-ui/src/infrastructure/config/components/EditorConfig.tsx index 840ce45d..0e5df189 100644 --- a/src/web-ui/src/infrastructure/config/components/EditorConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/EditorConfig.tsx @@ -21,7 +21,7 @@ const log = createLogger('EditorConfig'); const AUTO_SAVE_DELAY = 500; -export interface EditorConfigProps {} +export type EditorConfigProps = Record; const fontFamilyOptions = [ @@ -227,11 +227,7 @@ const EditorConfig: React.FC = () => { }, [config]); - useEffect(() => { - loadConfig(); - }, []); - - const loadConfig = async () => { + const loadConfig = useCallback(async () => { try { setIsLoading(true); isInitialLoadRef.current = true; @@ -253,7 +249,11 @@ const EditorConfig: React.FC = () => { } finally { setIsLoading(false); } - }; + }, [t]); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); const doSave = useCallback(async (configToSave: EditorConfigType) => { diff --git a/src/web-ui/src/infrastructure/config/components/LspConfig.tsx b/src/web-ui/src/infrastructure/config/components/LspConfig.tsx index 1d316503..b1eb987e 100644 --- a/src/web-ui/src/infrastructure/config/components/LspConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/LspConfig.tsx @@ -27,16 +27,16 @@ const LspConfig: React.FC = () => { const [settings, setSettings] = useState(DEFAULT_LSP_SETTINGS); const [hasSettingsChanges, setHasSettingsChanges] = useState(false); - useEffect(() => { loadSettings(); }, []); - - const loadSettings = () => { + function loadSettings() { try { const saved = localStorage.getItem(LSP_SETTINGS_KEY); if (saved) setSettings({ ...DEFAULT_LSP_SETTINGS, ...JSON.parse(saved) }); } catch (error) { log.error('Failed to load settings', error); } - }; + } + + useEffect(() => { loadSettings(); }, []); const saveSettings = () => { try { diff --git a/src/web-ui/src/infrastructure/config/components/MCPResourceBrowser.tsx b/src/web-ui/src/infrastructure/config/components/MCPResourceBrowser.tsx index bc068ddf..a24109c6 100644 --- a/src/web-ui/src/infrastructure/config/components/MCPResourceBrowser.tsx +++ b/src/web-ui/src/infrastructure/config/components/MCPResourceBrowser.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { FileText, FileImage, FileJson, FileCode, File, Search as SearchIcon, ArrowLeft } from 'lucide-react'; import { MCPResource } from '../../api/service-api/MCPAPI'; @@ -25,15 +25,7 @@ export const MCPResourceBrowser: React.FC = ({ serverId const [resourceContent, setResourceContent] = useState(null); const [loadingContent, setLoadingContent] = useState(false); - useEffect(() => { - loadResources(); - }, [serverId]); - - useEffect(() => { - filterResources(); - }, [searchQuery, resources]); - - const loadResources = async () => { + const loadResources = useCallback(async () => { setLoading(true); @@ -64,9 +56,9 @@ export const MCPResourceBrowser: React.FC = ({ serverId log.error('Failed to load resources', error); setLoading(false); } - }; + }, []); - const filterResources = () => { + const filterResources = useCallback(() => { if (!searchQuery.trim()) { setFilteredResources(resources); return; @@ -79,7 +71,15 @@ export const MCPResourceBrowser: React.FC = ({ serverId (resource.description && resource.description.toLowerCase().includes(query)) ); setFilteredResources(filtered); - }; + }, [resources, searchQuery]); + + useEffect(() => { + loadResources(); + }, [serverId, loadResources]); + + useEffect(() => { + filterResources(); + }, [filterResources]); const loadResourceContent = async (resource: MCPResource) => { setSelectedResource(resource); diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index 1e1a6cd1..64e499d9 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -72,10 +72,6 @@ const SessionConfig: React.FC = () => { const [expandedTemplates, setExpandedTemplates] = useState>(new Set()); const [isTemplatesModalOpen, setIsTemplatesModalOpen] = useState(false); - useEffect(() => { - loadAllData(); - }, []); - const refreshComputerUseStatus = useCallback(async (): Promise => { if (!IS_TAURI_DESKTOP) return false; try { @@ -92,7 +88,7 @@ const SessionConfig: React.FC = () => { } }, []); - const loadAllData = async () => { + const loadAllData = useCallback(async () => { setIsLoading(true); try { const [ @@ -135,7 +131,11 @@ const SessionConfig: React.FC = () => { } finally { setIsLoading(false); } - }; + }, [refreshComputerUseStatus]); + + useEffect(() => { + loadAllData(); + }, [loadAllData]); // ── Session config handlers ────────────────────────────────────────────── @@ -364,7 +364,11 @@ const SessionConfig: React.FC = () => { const toggleTemplateExpand = useCallback((language: string) => { setExpandedTemplates(prev => { const next = new Set(prev); - next.has(language) ? next.delete(language) : next.add(language); + if (next.has(language)) { + next.delete(language); + } else { + next.add(language); + } return next; }); }, []); diff --git a/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx b/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx index 72865c35..ffac70be 100644 --- a/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Plus, Trash2, RefreshCw, FolderOpen, X, Download, CheckCircle2, TrendingUp } from 'lucide-react'; diff --git a/src/web-ui/src/infrastructure/config/components/form-controls/ConfigCheckbox.tsx b/src/web-ui/src/infrastructure/config/components/form-controls/ConfigCheckbox.tsx index 0043992f..e429f075 100644 --- a/src/web-ui/src/infrastructure/config/components/form-controls/ConfigCheckbox.tsx +++ b/src/web-ui/src/infrastructure/config/components/form-controls/ConfigCheckbox.tsx @@ -29,7 +29,6 @@ export const ConfigCheckbox = forwardRef( success, labelIcon, inline = false, - compact = false, className = '', style, children, diff --git a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts index 601f92f1..d6ba33e1 100644 --- a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts +++ b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts @@ -15,6 +15,8 @@ type ProviderConfigLike = { function inferProviderTemplate(config: ProviderConfigLike): ProviderTemplate | undefined { const matchedCatalogItem = matchProviderCatalogItemByBaseUrl(config.base_url); + // Safe module-level forward reference: PROVIDER_TEMPLATES is initialized before this runs. + // eslint-disable-next-line @typescript-eslint/no-use-before-define return matchedCatalogItem ? PROVIDER_TEMPLATES[matchedCatalogItem.id] : undefined; } diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index be0a7f8b..cc0ff8de 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -34,9 +34,8 @@ export interface AppLoggingConfig { level: BackendLogLevel; } -export interface AppSessionConfig { - // Reserved; legacy `default_mode` in saved JSON is ignored by the app. -} +// Reserved; legacy `default_mode` in saved JSON is ignored by the app. +export type AppSessionConfig = Record; export interface SidebarConfig { width: number; @@ -561,5 +560,4 @@ export interface DefaultModels { } -export interface OptionalCapabilityModels { -} +export type OptionalCapabilityModels = Record; diff --git a/src/web-ui/src/infrastructure/contexts/ChatContext.ts b/src/web-ui/src/infrastructure/contexts/ChatContext.ts new file mode 100644 index 00000000..e9df6731 --- /dev/null +++ b/src/web-ui/src/infrastructure/contexts/ChatContext.ts @@ -0,0 +1,43 @@ + +import { createContext, useContext } from 'react'; + +export type ChatMessage = { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: Date; + metadata?: Record; +} + +export interface ChatState { + messages: ChatMessage[]; + input: string; + isProcessing: boolean; + error: string | null; +} + +export interface ChatActions { + addMessage: (message: ChatMessage) => void; + updateMessage: (messageId: string, updater: (message: ChatMessage) => ChatMessage) => void; + setInput: (input: string) => void; + setProcessing: (processing: boolean) => void; + setError: (error: string | null) => void; + clearChat: () => void; + clearError: () => void; +} + +export interface ChatContextType { + state: ChatState; + actions: ChatActions; +} + +const ChatContext = createContext(undefined); + +export const useChat = () => { + const context = useContext(ChatContext); + if (!context) { + throw new Error('useChat must be used within a ChatProvider'); + } + return context; +}; +export { ChatContext }; diff --git a/src/web-ui/src/infrastructure/contexts/ChatContext.tsx b/src/web-ui/src/infrastructure/contexts/ChatContext.tsx deleted file mode 100644 index 5f6a0f10..00000000 --- a/src/web-ui/src/infrastructure/contexts/ChatContext.tsx +++ /dev/null @@ -1,130 +0,0 @@ - - -import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; - -export type ChatMessage = { - id: string; - role: 'user' | 'assistant'; - content: string; - timestamp: Date; - metadata?: Record; -} - -interface ChatState { - messages: ChatMessage[]; - input: string; - isProcessing: boolean; - error: string | null; -} - -interface ChatActions { - addMessage: (message: ChatMessage) => void; - updateMessage: (messageId: string, updater: (message: ChatMessage) => ChatMessage) => void; - setInput: (input: string) => void; - setProcessing: (processing: boolean) => void; - setError: (error: string | null) => void; - clearChat: () => void; - clearError: () => void; -} - -interface ChatContextType { - state: ChatState; - actions: ChatActions; -} - -const ChatContext = createContext(undefined); - -export const useChat = () => { - const context = useContext(ChatContext); - if (!context) { - throw new Error('useChat must be used within a ChatProvider'); - } - return context; -}; - -interface ChatProviderProps { - children: ReactNode; -} - -export const ChatProvider: React.FC = ({ children }) => { - const [state, setState] = useState({ - messages: [], - input: '', - isProcessing: false, - error: null - }); - - const addMessage = useCallback((message: ChatMessage) => { - setState(prev => ({ - ...prev, - messages: [...prev.messages, message] - })); - }, []); - - const updateMessage = useCallback((messageId: string, updater: (message: ChatMessage) => ChatMessage) => { - setState(prev => ({ - ...prev, - messages: prev.messages.map(msg => - msg.id === messageId ? updater(msg) : msg - ) - })); - }, []); - - const setInput = useCallback((input: string) => { - setState(prev => ({ - ...prev, - input - })); - }, []); - - const setProcessing = useCallback((processing: boolean) => { - setState(prev => ({ - ...prev, - isProcessing: processing - })); - }, []); - - const setError = useCallback((error: string | null) => { - setState(prev => ({ - ...prev, - error - })); - }, []); - - const clearChat = useCallback(() => { - setState(prev => ({ - ...prev, - messages: [], - input: '', - error: null - })); - }, []); - - const clearError = useCallback(() => { - setState(prev => ({ - ...prev, - error: null - })); - }, []); - - const actions: ChatActions = { - addMessage, - updateMessage, - setInput, - setProcessing, - setError, - clearChat, - clearError - }; - - const contextValue: ChatContextType = { - state, - actions - }; - - return ( - - {children} - - ); -}; diff --git a/src/web-ui/src/infrastructure/contexts/ChatProvider.tsx b/src/web-ui/src/infrastructure/contexts/ChatProvider.tsx new file mode 100644 index 00000000..3af5594b --- /dev/null +++ b/src/web-ui/src/infrastructure/contexts/ChatProvider.tsx @@ -0,0 +1,89 @@ +import React, { ReactNode, useCallback, useState } from 'react'; +import { + ChatContext, + type ChatActions, + type ChatContextType, + type ChatMessage, + type ChatState, +} from './ChatContext'; + +interface ChatProviderProps { + children: ReactNode; +} + +export const ChatProvider: React.FC = ({ children }) => { + const [state, setState] = useState({ + messages: [], + input: '', + isProcessing: false, + error: null, + }); + + const addMessage = useCallback((message: ChatMessage) => { + setState((prev) => ({ + ...prev, + messages: [...prev.messages, message], + })); + }, []); + + const updateMessage = useCallback((messageId: string, updater: (message: ChatMessage) => ChatMessage) => { + setState((prev) => ({ + ...prev, + messages: prev.messages.map((msg) => (msg.id === messageId ? updater(msg) : msg)), + })); + }, []); + + const setInput = useCallback((input: string) => { + setState((prev) => ({ + ...prev, + input, + })); + }, []); + + const setProcessing = useCallback((processing: boolean) => { + setState((prev) => ({ + ...prev, + isProcessing: processing, + })); + }, []); + + const setError = useCallback((error: string | null) => { + setState((prev) => ({ + ...prev, + error, + })); + }, []); + + const clearChat = useCallback(() => { + setState((prev) => ({ + ...prev, + messages: [], + input: '', + error: null, + })); + }, []); + + const clearError = useCallback(() => { + setState((prev) => ({ + ...prev, + error: null, + })); + }, []); + + const actions: ChatActions = { + addMessage, + updateMessage, + setInput, + setProcessing, + setError, + clearChat, + clearError, + }; + + const contextValue: ChatContextType = { + state, + actions, + }; + + return {children}; +}; diff --git a/src/web-ui/src/infrastructure/contexts/ViewModeContext.ts b/src/web-ui/src/infrastructure/contexts/ViewModeContext.ts new file mode 100644 index 00000000..df7d2906 --- /dev/null +++ b/src/web-ui/src/infrastructure/contexts/ViewModeContext.ts @@ -0,0 +1,21 @@ + +import { createContext, useContext } from 'react'; + +export type ViewMode = 'cowork' | 'coder'; + +export interface ViewModeContextType { + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; + isCoworkMode: boolean; + isCoderMode: boolean; +} + +export const ViewModeContext = createContext(undefined); + +export const useViewMode = (): ViewModeContextType => { + const context = useContext(ViewModeContext); + if (!context) { + throw new Error('useViewMode must be used within ViewModeProvider'); + } + return context; +}; diff --git a/src/web-ui/src/infrastructure/contexts/ViewModeContext.tsx b/src/web-ui/src/infrastructure/contexts/ViewModeContext.tsx deleted file mode 100644 index 322d0307..00000000 --- a/src/web-ui/src/infrastructure/contexts/ViewModeContext.tsx +++ /dev/null @@ -1,56 +0,0 @@ - - -import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('ViewModeContext'); - -export type ViewMode = 'cowork' | 'coder'; - -interface ViewModeContextType { - viewMode: ViewMode; - setViewMode: (mode: ViewMode) => void; - isCoworkMode: boolean; - isCoderMode: boolean; -} - -const ViewModeContext = createContext(undefined); - -interface ViewModeProviderProps { - children: ReactNode; - defaultMode?: ViewMode; -} - -export const ViewModeProvider: React.FC = ({ - children, - defaultMode = 'coder' -}) => { - const [viewMode, setViewModeState] = useState(defaultMode); - - const setViewMode = useCallback((mode: ViewMode) => { - log.debug('View mode changed', { to: mode }); - setViewModeState(mode); - }, []); - - const value: ViewModeContextType = { - viewMode, - setViewMode, - isCoworkMode: viewMode === 'cowork', - isCoderMode: viewMode === 'coder', - }; - - return ( - - {children} - - ); -}; - -export const useViewMode = (): ViewModeContextType => { - const context = useContext(ViewModeContext); - if (!context) { - throw new Error('useViewMode must be used within ViewModeProvider'); - } - return context; -}; - diff --git a/src/web-ui/src/infrastructure/contexts/ViewModeProvider.tsx b/src/web-ui/src/infrastructure/contexts/ViewModeProvider.tsx new file mode 100644 index 00000000..fde4b65d --- /dev/null +++ b/src/web-ui/src/infrastructure/contexts/ViewModeProvider.tsx @@ -0,0 +1,35 @@ +import React, { ReactNode, useCallback, useState } from 'react'; +import { createLogger } from '@/shared/utils/logger'; +import { + ViewModeContext, + type ViewMode, + type ViewModeContextType, +} from './ViewModeContext'; + +const log = createLogger('ViewModeContext'); + +interface ViewModeProviderProps { + children: ReactNode; + defaultMode?: ViewMode; +} + +export const ViewModeProvider: React.FC = ({ + children, + defaultMode = 'coder', +}) => { + const [viewMode, setViewModeState] = useState(defaultMode); + + const setViewMode = useCallback((mode: ViewMode) => { + log.debug('View mode changed', { to: mode }); + setViewModeState(mode); + }, []); + + const value: ViewModeContextType = { + viewMode, + setViewMode, + isCoworkMode: viewMode === 'cowork', + isCoderMode: viewMode === 'coder', + }; + + return {children}; +}; diff --git a/src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts b/src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts new file mode 100644 index 00000000..17334f6e --- /dev/null +++ b/src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts @@ -0,0 +1,100 @@ + +import { createContext, useContext, useEffect } from 'react'; +import { workspaceManager, WorkspaceState, WorkspaceEvent } from '../services/business/workspaceManager'; +import { WorkspaceInfo, WorkspaceKind } from '../../shared/types'; + +export const getWorkspaceDisplayName = (workspace: WorkspaceInfo | null): string => { + if (!workspace) { + return ''; + } + + if (workspace.workspaceKind === WorkspaceKind.Assistant) { + return workspace.identity?.name?.trim() || workspace.name; + } + + return workspace.name; +}; + +export interface WorkspaceContextValue extends WorkspaceState { + activeWorkspace: WorkspaceInfo | null; + openedWorkspacesList: WorkspaceInfo[]; + normalWorkspacesList: WorkspaceInfo[]; + assistantWorkspacesList: WorkspaceInfo[]; + openWorkspace: (path: string) => Promise; + createAssistantWorkspace: () => Promise; + closeWorkspace: () => Promise; + closeWorkspaceById: (workspaceId: string) => Promise; + deleteAssistantWorkspace: (workspaceId: string) => Promise; + resetAssistantWorkspace: (workspaceId: string) => Promise; + switchWorkspace: (workspace: WorkspaceInfo) => Promise; + setActiveWorkspace: (workspaceId: string) => Promise; + reorderOpenedWorkspacesInSection: ( + section: 'assistants' | 'projects', + sourceWorkspaceId: string, + targetWorkspaceId: string, + position: 'before' | 'after' + ) => Promise; + scanWorkspaceInfo: () => Promise; + refreshRecentWorkspaces: () => Promise; + removeWorkspaceFromRecent: (workspaceId: string) => Promise; + hasWorkspace: boolean; + workspaceName: string; + workspacePath: string; +} + +const WorkspaceContext = createContext(null); + +export const useWorkspaceContext = (): WorkspaceContextValue => { + const context = useContext(WorkspaceContext); + + if (!context) { + throw new Error('useWorkspaceContext must be used within a WorkspaceProvider'); + } + + return context; +}; + +export const useCurrentWorkspace = () => { + const { activeWorkspace, loading, error, hasWorkspace, workspaceName, workspacePath } = useWorkspaceContext(); + + return { + workspace: activeWorkspace, + loading, + error, + hasWorkspace, + workspaceName, + workspacePath, + }; +}; + +export const useWorkspaceEvents = ( + onWorkspaceOpened?: (workspace: WorkspaceInfo) => void, + onWorkspaceClosed?: (workspaceId: string) => void, + onWorkspaceSwitched?: (workspace: WorkspaceInfo) => void, + onWorkspaceUpdated?: (workspace: WorkspaceInfo) => void +) => { + useEffect(() => { + const removeListener = workspaceManager.addEventListener((event: WorkspaceEvent) => { + switch (event.type) { + case 'workspace:opened': + onWorkspaceOpened?.(event.workspace); + break; + case 'workspace:closed': + onWorkspaceClosed?.(event.workspaceId); + break; + case 'workspace:switched': + onWorkspaceSwitched?.(event.workspace); + break; + case 'workspace:updated': + onWorkspaceUpdated?.(event.workspace); + break; + case 'workspace:recent-updated': + break; + } + }); + + return removeListener; + }, [onWorkspaceOpened, onWorkspaceClosed, onWorkspaceSwitched, onWorkspaceUpdated]); +}; + +export { WorkspaceContext }; diff --git a/src/web-ui/src/infrastructure/contexts/WorkspaceContext.tsx b/src/web-ui/src/infrastructure/contexts/WorkspaceContext.tsx deleted file mode 100644 index a3b64986..00000000 --- a/src/web-ui/src/infrastructure/contexts/WorkspaceContext.tsx +++ /dev/null @@ -1,272 +0,0 @@ - - -import React, { createContext, useContext, useEffect, useState, useCallback, useRef, ReactNode, useMemo } from 'react'; -import { workspaceManager, WorkspaceState, WorkspaceEvent } from '../services/business/workspaceManager'; -import { WorkspaceInfo, WorkspaceKind } from '../../shared/types'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('WorkspaceProvider'); - -const getWorkspaceDisplayName = (workspace: WorkspaceInfo | null): string => { - if (!workspace) { - return ''; - } - - if (workspace.workspaceKind === WorkspaceKind.Assistant) { - return workspace.identity?.name?.trim() || workspace.name; - } - - return workspace.name; -}; - -interface WorkspaceContextValue extends WorkspaceState { - activeWorkspace: WorkspaceInfo | null; - openedWorkspacesList: WorkspaceInfo[]; - normalWorkspacesList: WorkspaceInfo[]; - assistantWorkspacesList: WorkspaceInfo[]; - openWorkspace: (path: string) => Promise; - createAssistantWorkspace: () => Promise; - closeWorkspace: () => Promise; - closeWorkspaceById: (workspaceId: string) => Promise; - deleteAssistantWorkspace: (workspaceId: string) => Promise; - resetAssistantWorkspace: (workspaceId: string) => Promise; - switchWorkspace: (workspace: WorkspaceInfo) => Promise; - setActiveWorkspace: (workspaceId: string) => Promise; - reorderOpenedWorkspacesInSection: ( - section: 'assistants' | 'projects', - sourceWorkspaceId: string, - targetWorkspaceId: string, - position: 'before' | 'after' - ) => Promise; - scanWorkspaceInfo: () => Promise; - refreshRecentWorkspaces: () => Promise; - removeWorkspaceFromRecent: (workspaceId: string) => Promise; - hasWorkspace: boolean; - workspaceName: string; - workspacePath: string; -} - -const WorkspaceContext = createContext(null); - -interface WorkspaceProviderProps { - children: ReactNode; -} - -export const WorkspaceProvider: React.FC = ({ children }) => { - const [state, setState] = useState(() => { - try { - return workspaceManager.getState(); - } catch (error) { - log.warn('WorkspaceManager not initialized, using default state', error); - return { - currentWorkspace: null, - openedWorkspaces: new Map(), - activeWorkspaceId: null, - lastUsedWorkspaceId: null, - recentWorkspaces: [], - loading: false, - error: null, - }; - } - }); - - const isInitializedRef = useRef(false); - - useEffect(() => { - const removeListener = workspaceManager.addEventListener((_event: WorkspaceEvent) => { - // Workspace metadata such as identity/name can change without affecting ids or list lengths. - // Always sync the latest manager state so React consumers re-render for these updates. - setState(workspaceManager.getState()); - }); - - return () => { - removeListener(); - }; - }, []); - - useEffect(() => { - const initializeWorkspace = async () => { - if (isInitializedRef.current) { - return; - } - - try { - isInitializedRef.current = true; - setState(prev => ({ ...prev, loading: true })); - await workspaceManager.initialize(); - setState(workspaceManager.getState()); - } catch (error) { - log.error('Failed to initialize workspace state', error); - isInitializedRef.current = false; - setState(prev => ({ ...prev, loading: false, error: String(error) })); - } - }; - - initializeWorkspace(); - }, []); - - const openWorkspace = useCallback(async (path: string): Promise => { - return await workspaceManager.openWorkspace(path); - }, []); - - const createAssistantWorkspace = useCallback(async (): Promise => { - return await workspaceManager.createAssistantWorkspace(); - }, []); - - const closeWorkspace = useCallback(async (): Promise => { - return await workspaceManager.closeWorkspace(); - }, []); - - const closeWorkspaceById = useCallback(async (workspaceId: string): Promise => { - return await workspaceManager.closeWorkspaceById(workspaceId); - }, []); - - const deleteAssistantWorkspace = useCallback(async (workspaceId: string): Promise => { - return await workspaceManager.deleteAssistantWorkspace(workspaceId); - }, []); - - const resetAssistantWorkspace = useCallback(async (workspaceId: string): Promise => { - return await workspaceManager.resetAssistantWorkspace(workspaceId); - }, []); - - const switchWorkspace = useCallback(async (workspace: WorkspaceInfo): Promise => { - return await workspaceManager.switchWorkspace(workspace); - }, []); - - const setActiveWorkspace = useCallback(async (workspaceId: string): Promise => { - return await workspaceManager.setActiveWorkspace(workspaceId); - }, []); - - const reorderOpenedWorkspacesInSection = useCallback(async ( - section: 'assistants' | 'projects', - sourceWorkspaceId: string, - targetWorkspaceId: string, - position: 'before' | 'after' - ): Promise => { - return await workspaceManager.reorderOpenedWorkspacesInSection( - section, - sourceWorkspaceId, - targetWorkspaceId, - position - ); - }, []); - - const scanWorkspaceInfo = useCallback(async (): Promise => { - return await workspaceManager.scanWorkspaceInfo(); - }, []); - - const refreshRecentWorkspaces = useCallback(async (): Promise => { - return await workspaceManager.refreshRecentWorkspaces(); - }, []); - - const removeWorkspaceFromRecent = useCallback(async (workspaceId: string): Promise => { - return await workspaceManager.removeWorkspaceFromRecent(workspaceId); - }, []); - - const activeWorkspace = state.currentWorkspace; - const openedWorkspacesList = useMemo( - () => Array.from(state.openedWorkspaces.values()), - [state.openedWorkspaces] - ); - const normalWorkspacesList = useMemo( - () => - openedWorkspacesList.filter( - workspace => workspace.workspaceKind !== WorkspaceKind.Assistant - ), - [openedWorkspacesList] - ); - const assistantWorkspacesList = useMemo( - () => - openedWorkspacesList.filter( - workspace => workspace.workspaceKind === WorkspaceKind.Assistant - ), - [openedWorkspacesList] - ); - const hasWorkspace = !!activeWorkspace; - const workspaceName = getWorkspaceDisplayName(activeWorkspace); - const workspacePath = activeWorkspace?.rootPath || ''; - - const contextValue: WorkspaceContextValue = { - ...state, - activeWorkspace, - openedWorkspacesList, - normalWorkspacesList, - assistantWorkspacesList, - openWorkspace, - createAssistantWorkspace, - closeWorkspace, - closeWorkspaceById, - deleteAssistantWorkspace, - resetAssistantWorkspace, - switchWorkspace, - setActiveWorkspace, - reorderOpenedWorkspacesInSection, - scanWorkspaceInfo, - refreshRecentWorkspaces, - removeWorkspaceFromRecent, - hasWorkspace, - workspaceName, - workspacePath, - }; - - return ( - - {children} - - ); -}; - -export const useWorkspaceContext = (): WorkspaceContextValue => { - const context = useContext(WorkspaceContext); - - if (!context) { - throw new Error('useWorkspaceContext must be used within a WorkspaceProvider'); - } - - return context; -}; - -export const useCurrentWorkspace = () => { - const { activeWorkspace, loading, error, hasWorkspace, workspaceName, workspacePath } = useWorkspaceContext(); - - return { - workspace: activeWorkspace, - loading, - error, - hasWorkspace, - workspaceName, - workspacePath, - }; -}; - -export const useWorkspaceEvents = ( - onWorkspaceOpened?: (workspace: WorkspaceInfo) => void, - onWorkspaceClosed?: (workspaceId: string) => void, - onWorkspaceSwitched?: (workspace: WorkspaceInfo) => void, - onWorkspaceUpdated?: (workspace: WorkspaceInfo) => void -) => { - useEffect(() => { - const removeListener = workspaceManager.addEventListener((event: WorkspaceEvent) => { - switch (event.type) { - case 'workspace:opened': - onWorkspaceOpened?.(event.workspace); - break; - case 'workspace:closed': - onWorkspaceClosed?.(event.workspaceId); - break; - case 'workspace:switched': - onWorkspaceSwitched?.(event.workspace); - break; - case 'workspace:updated': - onWorkspaceUpdated?.(event.workspace); - break; - case 'workspace:recent-updated': - break; - } - }); - - return removeListener; - }, [onWorkspaceOpened, onWorkspaceClosed, onWorkspaceSwitched, onWorkspaceUpdated]); -}; - -export { WorkspaceContext }; diff --git a/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx b/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx new file mode 100644 index 00000000..3a708e9c --- /dev/null +++ b/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx @@ -0,0 +1,287 @@ +import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { workspaceManager } from '../services/business/workspaceManager'; +import { WorkspaceInfo, WorkspaceKind } from '../../shared/types'; +import { createLogger } from '@/shared/utils/logger'; +import { + WorkspaceContext, + type WorkspaceContextValue, + getWorkspaceDisplayName, +} from './WorkspaceContext'; + +const log = createLogger('WorkspaceProvider'); + +interface WorkspaceProviderProps { + children: ReactNode; +} + +export const WorkspaceProvider: React.FC = ({ children }) => { + const [state, setState] = useState(() => { + try { + const initialState = workspaceManager.getState(); + const activeWorkspace = initialState.currentWorkspace; + const openedWorkspacesList = Array.from(initialState.openedWorkspaces.values()); + + return { + ...initialState, + activeWorkspace, + openedWorkspacesList, + normalWorkspacesList: openedWorkspacesList.filter( + (workspace) => workspace.workspaceKind !== WorkspaceKind.Assistant + ), + assistantWorkspacesList: openedWorkspacesList.filter( + (workspace) => workspace.workspaceKind === WorkspaceKind.Assistant + ), + openWorkspace: async (path: string) => workspaceManager.openWorkspace(path), + createAssistantWorkspace: async () => workspaceManager.createAssistantWorkspace(), + closeWorkspace: async () => workspaceManager.closeWorkspace(), + closeWorkspaceById: async (workspaceId: string) => workspaceManager.closeWorkspaceById(workspaceId), + deleteAssistantWorkspace: async (workspaceId: string) => + workspaceManager.deleteAssistantWorkspace(workspaceId), + resetAssistantWorkspace: async (workspaceId: string) => + workspaceManager.resetAssistantWorkspace(workspaceId), + switchWorkspace: async (workspace: WorkspaceInfo) => workspaceManager.switchWorkspace(workspace), + setActiveWorkspace: async (workspaceId: string) => workspaceManager.setActiveWorkspace(workspaceId), + reorderOpenedWorkspacesInSection: async ( + section: 'assistants' | 'projects', + sourceWorkspaceId: string, + targetWorkspaceId: string, + position: 'before' | 'after' + ) => + workspaceManager.reorderOpenedWorkspacesInSection( + section, + sourceWorkspaceId, + targetWorkspaceId, + position + ), + scanWorkspaceInfo: async () => workspaceManager.scanWorkspaceInfo(), + refreshRecentWorkspaces: async () => workspaceManager.refreshRecentWorkspaces(), + removeWorkspaceFromRecent: async (workspaceId: string) => + workspaceManager.removeWorkspaceFromRecent(workspaceId), + hasWorkspace: !!activeWorkspace, + workspaceName: getWorkspaceDisplayName(activeWorkspace), + workspacePath: activeWorkspace?.rootPath || '', + }; + } catch (error) { + log.warn('WorkspaceManager not initialized, using default state', error); + return { + currentWorkspace: null, + openedWorkspaces: new Map(), + activeWorkspaceId: null, + lastUsedWorkspaceId: null, + recentWorkspaces: [], + loading: false, + error: null, + activeWorkspace: null, + openedWorkspacesList: [], + normalWorkspacesList: [], + assistantWorkspacesList: [], + openWorkspace: async (path: string) => workspaceManager.openWorkspace(path), + createAssistantWorkspace: async () => workspaceManager.createAssistantWorkspace(), + closeWorkspace: async () => workspaceManager.closeWorkspace(), + closeWorkspaceById: async (workspaceId: string) => workspaceManager.closeWorkspaceById(workspaceId), + deleteAssistantWorkspace: async (workspaceId: string) => + workspaceManager.deleteAssistantWorkspace(workspaceId), + resetAssistantWorkspace: async (workspaceId: string) => + workspaceManager.resetAssistantWorkspace(workspaceId), + switchWorkspace: async (workspace: WorkspaceInfo) => workspaceManager.switchWorkspace(workspace), + setActiveWorkspace: async (workspaceId: string) => workspaceManager.setActiveWorkspace(workspaceId), + reorderOpenedWorkspacesInSection: async ( + section: 'assistants' | 'projects', + sourceWorkspaceId: string, + targetWorkspaceId: string, + position: 'before' | 'after' + ) => + workspaceManager.reorderOpenedWorkspacesInSection( + section, + sourceWorkspaceId, + targetWorkspaceId, + position + ), + scanWorkspaceInfo: async () => workspaceManager.scanWorkspaceInfo(), + refreshRecentWorkspaces: async () => workspaceManager.refreshRecentWorkspaces(), + removeWorkspaceFromRecent: async (workspaceId: string) => + workspaceManager.removeWorkspaceFromRecent(workspaceId), + hasWorkspace: false, + workspaceName: '', + workspacePath: '', + }; + } + }); + + const isInitializedRef = useRef(false); + + useEffect(() => { + const removeListener = workspaceManager.addEventListener(() => { + setState((prev) => { + const nextState = workspaceManager.getState(); + const activeWorkspace = nextState.currentWorkspace; + const openedWorkspacesList = Array.from(nextState.openedWorkspaces.values()); + + return { + ...prev, + ...nextState, + activeWorkspace, + openedWorkspacesList, + normalWorkspacesList: openedWorkspacesList.filter( + (workspace) => workspace.workspaceKind !== WorkspaceKind.Assistant + ), + assistantWorkspacesList: openedWorkspacesList.filter( + (workspace) => workspace.workspaceKind === WorkspaceKind.Assistant + ), + hasWorkspace: !!activeWorkspace, + workspaceName: getWorkspaceDisplayName(activeWorkspace), + workspacePath: activeWorkspace?.rootPath || '', + }; + }); + }); + + return () => { + removeListener(); + }; + }, []); + + useEffect(() => { + const initializeWorkspace = async () => { + if (isInitializedRef.current) { + return; + } + + try { + isInitializedRef.current = true; + setState((prev) => ({ ...prev, loading: true })); + await workspaceManager.initialize(); + const nextState = workspaceManager.getState(); + const activeWorkspace = nextState.currentWorkspace; + const openedWorkspacesList = Array.from(nextState.openedWorkspaces.values()); + + setState((prev) => ({ + ...prev, + ...nextState, + activeWorkspace, + openedWorkspacesList, + normalWorkspacesList: openedWorkspacesList.filter( + (workspace) => workspace.workspaceKind !== WorkspaceKind.Assistant + ), + assistantWorkspacesList: openedWorkspacesList.filter( + (workspace) => workspace.workspaceKind === WorkspaceKind.Assistant + ), + hasWorkspace: !!activeWorkspace, + workspaceName: getWorkspaceDisplayName(activeWorkspace), + workspacePath: activeWorkspace?.rootPath || '', + })); + } catch (error) { + log.error('Failed to initialize workspace state', error); + isInitializedRef.current = false; + setState((prev) => ({ ...prev, loading: false, error: String(error) })); + } + }; + + void initializeWorkspace(); + }, []); + + const openWorkspace = useCallback(async (path: string): Promise => { + return await workspaceManager.openWorkspace(path); + }, []); + + const createAssistantWorkspace = useCallback(async (): Promise => { + return await workspaceManager.createAssistantWorkspace(); + }, []); + + const closeWorkspace = useCallback(async (): Promise => { + return await workspaceManager.closeWorkspace(); + }, []); + + const closeWorkspaceById = useCallback(async (workspaceId: string): Promise => { + return await workspaceManager.closeWorkspaceById(workspaceId); + }, []); + + const deleteAssistantWorkspace = useCallback(async (workspaceId: string): Promise => { + return await workspaceManager.deleteAssistantWorkspace(workspaceId); + }, []); + + const resetAssistantWorkspace = useCallback(async (workspaceId: string): Promise => { + return await workspaceManager.resetAssistantWorkspace(workspaceId); + }, []); + + const switchWorkspace = useCallback(async (workspace: WorkspaceInfo): Promise => { + return await workspaceManager.switchWorkspace(workspace); + }, []); + + const setActiveWorkspace = useCallback(async (workspaceId: string): Promise => { + return await workspaceManager.setActiveWorkspace(workspaceId); + }, []); + + const reorderOpenedWorkspacesInSection = useCallback(async ( + section: 'assistants' | 'projects', + sourceWorkspaceId: string, + targetWorkspaceId: string, + position: 'before' | 'after' + ): Promise => { + return await workspaceManager.reorderOpenedWorkspacesInSection( + section, + sourceWorkspaceId, + targetWorkspaceId, + position + ); + }, []); + + const scanWorkspaceInfo = useCallback(async (): Promise => { + return await workspaceManager.scanWorkspaceInfo(); + }, []); + + const refreshRecentWorkspaces = useCallback(async (): Promise => { + return await workspaceManager.refreshRecentWorkspaces(); + }, []); + + const removeWorkspaceFromRecent = useCallback(async (workspaceId: string): Promise => { + return await workspaceManager.removeWorkspaceFromRecent(workspaceId); + }, []); + + const contextValue = useMemo(() => { + const activeWorkspace = state.currentWorkspace; + const openedWorkspacesList = Array.from(state.openedWorkspaces.values()); + + return { + ...state, + activeWorkspace, + openedWorkspacesList, + normalWorkspacesList: openedWorkspacesList.filter( + (workspace) => workspace.workspaceKind !== WorkspaceKind.Assistant + ), + assistantWorkspacesList: openedWorkspacesList.filter( + (workspace) => workspace.workspaceKind === WorkspaceKind.Assistant + ), + openWorkspace, + createAssistantWorkspace, + closeWorkspace, + closeWorkspaceById, + deleteAssistantWorkspace, + resetAssistantWorkspace, + switchWorkspace, + setActiveWorkspace, + reorderOpenedWorkspacesInSection, + scanWorkspaceInfo, + refreshRecentWorkspaces, + removeWorkspaceFromRecent, + hasWorkspace: !!activeWorkspace, + workspaceName: getWorkspaceDisplayName(activeWorkspace), + workspacePath: activeWorkspace?.rootPath || '', + }; + }, [ + state, + openWorkspace, + createAssistantWorkspace, + closeWorkspace, + closeWorkspaceById, + deleteAssistantWorkspace, + resetAssistantWorkspace, + switchWorkspace, + setActiveWorkspace, + reorderOpenedWorkspacesInSection, + scanWorkspaceInfo, + refreshRecentWorkspaces, + removeWorkspaceFromRecent, + ]); + + return {children}; +}; diff --git a/src/web-ui/src/infrastructure/contexts/index.ts b/src/web-ui/src/infrastructure/contexts/index.ts index edcc2e8b..14b3c6c6 100644 --- a/src/web-ui/src/infrastructure/contexts/index.ts +++ b/src/web-ui/src/infrastructure/contexts/index.ts @@ -1,11 +1,8 @@ - - -export { ChatProvider, useChat } from './ChatContext'; - - +export { ChatProvider } from './ChatProvider'; +export { useChat } from './ChatContext'; export type { ChatMessage } from './ChatContext'; - - +export { ViewModeProvider } from './ViewModeProvider'; +export * from './ViewModeContext'; +export { WorkspaceProvider } from './WorkspaceProvider'; export * from './WorkspaceContext'; - diff --git a/src/web-ui/src/infrastructure/event-bus/EventBus.ts b/src/web-ui/src/infrastructure/event-bus/EventBus.ts index 08209c4d..753b395f 100644 --- a/src/web-ui/src/infrastructure/event-bus/EventBus.ts +++ b/src/web-ui/src/infrastructure/event-bus/EventBus.ts @@ -174,10 +174,12 @@ export class EventBus { async waitFor(event: string, timeout?: number): Promise { return new Promise((resolve, reject) => { const timeoutMs = timeout ?? this.options.timeout; - let timeoutId: NodeJS.Timeout; + let timeoutId: ReturnType | null = null; const cleanup = this.once(event, (data: T) => { - clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } resolve(data); }); diff --git a/src/web-ui/src/infrastructure/i18n/hooks/useI18n.ts b/src/web-ui/src/infrastructure/i18n/hooks/useI18n.ts index 1983eb82..1106ddb7 100644 --- a/src/web-ui/src/infrastructure/i18n/hooks/useI18n.ts +++ b/src/web-ui/src/infrastructure/i18n/hooks/useI18n.ts @@ -62,7 +62,7 @@ export function useI18n( const currentLocaleMetadata = useMemo( () => i18nService.getCurrentLocaleMetadata(), - [currentLanguage] + [] ); const supportedLocales = useMemo( @@ -74,33 +74,33 @@ export function useI18n( (date: Date | number, options?: Intl.DateTimeFormatOptions) => { return i18nService.formatDate(date, options); }, - [currentLanguage] + [] ); const formatNumber = useCallback( (number: number, options?: Intl.NumberFormatOptions) => { return i18nService.formatNumber(number, options); }, - [currentLanguage] + [] ); const formatCurrency = useCallback( (amount: number, currency?: string) => { return i18nService.formatCurrency(amount, currency); }, - [currentLanguage] + [] ); const formatRelativeTime = useCallback( (date: Date | number, unit?: Intl.RelativeTimeFormatUnit) => { return i18nService.formatRelativeTime(date, unit); }, - [currentLanguage] + [] ); const isRTL = useMemo( () => i18nService.isRTL(), - [currentLanguage] + [] ); return { diff --git a/src/web-ui/src/infrastructure/index.ts b/src/web-ui/src/infrastructure/index.ts index ffd5c603..a9746a2b 100644 --- a/src/web-ui/src/infrastructure/index.ts +++ b/src/web-ui/src/infrastructure/index.ts @@ -9,8 +9,10 @@ export * from './event-bus'; export * from './api'; // Contexts (explicit exports to avoid name collisions) -export { ChatProvider, useChat } from './contexts/ChatContext'; -export { WorkspaceProvider, useWorkspaceContext } from './contexts/WorkspaceContext'; +export { ChatProvider } from './contexts/ChatProvider'; +export { useChat } from './contexts/ChatContext'; +export { WorkspaceProvider } from './contexts/WorkspaceProvider'; +export { useWorkspaceContext } from './contexts/WorkspaceContext'; // Configuration export * from './config'; diff --git a/src/web-ui/src/infrastructure/providers/CoreContext.ts b/src/web-ui/src/infrastructure/providers/CoreContext.ts new file mode 100644 index 00000000..177b68b7 --- /dev/null +++ b/src/web-ui/src/infrastructure/providers/CoreContext.ts @@ -0,0 +1,11 @@ +import { createContext } from 'react'; +import { globalEventBus } from '../event-bus'; + +export interface CoreContextType { + isInitialized: boolean; + isLoading: boolean; + error: string | null; + eventBus: typeof globalEventBus; +} + +export const CoreContext = createContext(null); diff --git a/src/web-ui/src/infrastructure/providers/CoreProvider.tsx b/src/web-ui/src/infrastructure/providers/CoreProvider.tsx index 84b783bc..56c3a676 100644 --- a/src/web-ui/src/infrastructure/providers/CoreProvider.tsx +++ b/src/web-ui/src/infrastructure/providers/CoreProvider.tsx @@ -1,22 +1,13 @@ - -import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; +import React, { useEffect, useState, ReactNode } from 'react'; import { initializeCore, destroyCore } from '../index'; import { globalEventBus } from '../event-bus'; import { createLogger } from '@/shared/utils/logger'; import { useI18n } from '@/infrastructure/i18n'; +import { CoreContext, type CoreContextType } from './CoreContext'; const log = createLogger('CoreProvider'); -interface CoreContextType { - isInitialized: boolean; - isLoading: boolean; - error: string | null; - eventBus: typeof globalEventBus; -} - -const CoreContext = createContext(null); - interface CoreProviderProps { children: ReactNode; } @@ -120,27 +111,3 @@ export const CoreProvider: React.FC = ({ children }) => { ); }; - -export const useCore = (): CoreContextType => { - const context = useContext(CoreContext); - if (!context) { - throw new Error('useCore must be used within a CoreProvider'); - } - return context; -}; - - -export const useCoreInitialized = (): boolean => { - const { isInitialized } = useCore(); - return isInitialized; -}; - -export const useCoreLoading = (): boolean => { - const { isLoading } = useCore(); - return isLoading; -}; - -export const useCoreError = (): string | null => { - const { error } = useCore(); - return error; -}; diff --git a/src/web-ui/src/infrastructure/providers/index.ts b/src/web-ui/src/infrastructure/providers/index.ts index 2c6ff7e2..ed4b66f9 100644 --- a/src/web-ui/src/infrastructure/providers/index.ts +++ b/src/web-ui/src/infrastructure/providers/index.ts @@ -1,4 +1,3 @@ - export * from './CoreProvider'; - +export * from './useCore'; diff --git a/src/web-ui/src/infrastructure/providers/useCore.ts b/src/web-ui/src/infrastructure/providers/useCore.ts new file mode 100644 index 00000000..d208f121 --- /dev/null +++ b/src/web-ui/src/infrastructure/providers/useCore.ts @@ -0,0 +1,25 @@ +import { useContext } from 'react'; +import { CoreContext, type CoreContextType } from './CoreContext'; + +export const useCore = (): CoreContextType => { + const context = useContext(CoreContext); + if (!context) { + throw new Error('useCore must be used within a CoreProvider'); + } + return context; +}; + +export const useCoreInitialized = (): boolean => { + const { isInitialized } = useCore(); + return isInitialized; +}; + +export const useCoreLoading = (): boolean => { + const { isLoading } = useCore(); + return isLoading; +}; + +export const useCoreError = (): string | null => { + const { error } = useCore(); + return error; +}; diff --git a/src/web-ui/src/infrastructure/services/api/aiService.ts b/src/web-ui/src/infrastructure/services/api/aiService.ts index 865d828e..416aadce 100644 --- a/src/web-ui/src/infrastructure/services/api/aiService.ts +++ b/src/web-ui/src/infrastructure/services/api/aiService.ts @@ -130,7 +130,7 @@ class AIService { } async sendMessage(content: string, options: AIServiceOptions): Promise<{ response: string, sessionId: string }> { - const { onToolExecution } = options; + const { onToolExecution: _onToolExecution } = options; try { @@ -144,11 +144,6 @@ class AIService { context: workspacePath ? { workspacePath } : undefined, }); - - if (onToolExecution) { - - } - return { response: result || '', sessionId: `session_${Date.now()}` diff --git a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts index 5f6faa0b..b621f73e 100644 --- a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts +++ b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts @@ -104,7 +104,7 @@ export class ThemeService { }); log.info('Loaded user themes', { count: themes.length }); } - } catch (error) { + } catch (_error) { } } @@ -121,7 +121,7 @@ export class ThemeService { return SYSTEM_THEME_ID; } return raw || null; - } catch (error) { + } catch (_error) { return null; } } @@ -866,4 +866,3 @@ export class ThemeService { export const themeService = new ThemeService(); - diff --git a/src/web-ui/src/main.tsx b/src/web-ui/src/main.tsx index 1dc5fb9d..5990482d 100644 --- a/src/web-ui/src/main.tsx +++ b/src/web-ui/src/main.tsx @@ -1,7 +1,7 @@ import ReactDOM from "react-dom/client"; import App from "./app/App"; import AppErrorBoundary from "./app/components/AppErrorBoundary"; -import { WorkspaceProvider } from "./infrastructure/contexts/WorkspaceContext"; +import { WorkspaceProvider } from "./infrastructure/contexts/WorkspaceProvider"; import "./app/styles/index.scss"; // Manually import Monaco Editor CSS. diff --git a/src/web-ui/src/shared/context-menu-system/core/ContextResolver.ts b/src/web-ui/src/shared/context-menu-system/core/ContextResolver.ts index 513376ab..8a3303d7 100644 --- a/src/web-ui/src/shared/context-menu-system/core/ContextResolver.ts +++ b/src/web-ui/src/shared/context-menu-system/core/ContextResolver.ts @@ -55,18 +55,16 @@ export class ContextResolver { }; - let context: MenuContext | null = null; - if (context = this.resolveSelection(baseContext)) { - } else if (context = this.resolveTerminal(baseContext)) { - } else if (context = this.resolveFileNode(baseContext)) { - } else if (context = this.resolveEditor(baseContext)) { - } else if (context = this.resolveFlowChat(baseContext)) { - } else if (context = this.resolveTab(baseContext)) { - } else if (context = this.resolvePanelHeader(baseContext)) { - } else if (context = this.resolveCustom(baseContext)) { - } else { - context = this.resolveEmptySpace(baseContext); - } + const context = + this.resolveSelection(baseContext) ?? + this.resolveTerminal(baseContext) ?? + this.resolveFileNode(baseContext) ?? + this.resolveEditor(baseContext) ?? + this.resolveFlowChat(baseContext) ?? + this.resolveTab(baseContext) ?? + this.resolvePanelHeader(baseContext) ?? + this.resolveCustom(baseContext) ?? + this.resolveEmptySpace(baseContext); return context; } @@ -296,7 +294,7 @@ export class ContextResolver { }; } } - } catch (e) { + } catch (_error) { } diff --git a/src/web-ui/src/shared/context-system/drag-drop/ContextDropZone.tsx b/src/web-ui/src/shared/context-system/drag-drop/ContextDropZone.tsx index 132f869b..88b9782d 100644 --- a/src/web-ui/src/shared/context-system/drag-drop/ContextDropZone.tsx +++ b/src/web-ui/src/shared/context-system/drag-drop/ContextDropZone.tsx @@ -26,7 +26,6 @@ export const ContextDropZone: React.FC = ({ const dropZoneRef = useRef(null); const dragCounterRef = useRef(0); const addContext = useContextStore(state => state.addContext); - const setValidating = useContextStore(state => state.setValidating); const updateValidation = useContextStore(state => state.updateValidation); @@ -76,7 +75,7 @@ export const ContextDropZone: React.FC = ({ onDragOver: () => { } - }), [acceptedTypesArray, addContext, setValidating, updateValidation, onContextAdded]); + }), [acceptedTypesArray, addContext, updateValidation, onContextAdded]); const dropTargetRef = useRef(dropTarget); @@ -93,7 +92,7 @@ export const ContextDropZone: React.FC = ({ return () => { unregister(); }; - }, []); + }, [dropTarget]); const handleDragEnter = useCallback((e: React.DragEvent) => { diff --git a/src/web-ui/src/shared/crypto/e2e-encryption.ts b/src/web-ui/src/shared/crypto/e2e-encryption.ts index dd436c39..6e5fad13 100644 --- a/src/web-ui/src/shared/crypto/e2e-encryption.ts +++ b/src/web-ui/src/shared/crypto/e2e-encryption.ts @@ -20,7 +20,7 @@ let _useNobleFallback: boolean | null = null; async function supportsWebCryptoX25519(): Promise { if (_useNobleFallback !== null) return !_useNobleFallback; try { - const kp = await crypto.subtle.generateKey( + await crypto.subtle.generateKey( { name: 'X25519' } as any, true, ['deriveKey'], diff --git a/src/web-ui/src/shared/helpers/MonacoHelper.ts b/src/web-ui/src/shared/helpers/MonacoHelper.ts index d392972c..1b4e2ce2 100644 --- a/src/web-ui/src/shared/helpers/MonacoHelper.ts +++ b/src/web-ui/src/shared/helpers/MonacoHelper.ts @@ -161,7 +161,7 @@ export class MonacoHelper { try { filePath = decodeURIComponent(filePath); - } catch (e) { + } catch (_error) { log.debug('Failed to decode URI', { filePath }); } @@ -228,7 +228,7 @@ export class MonacoHelper { try { return model.getLineContent(lineNumber); - } catch (e) { + } catch (_error) { log.debug('Failed to get line content', { lineNumber }); return null; } @@ -257,7 +257,7 @@ export class MonacoHelper { endLineNumber: contextEnd, endColumn: model.getLineMaxColumn(contextEnd) }); - } catch (e) { + } catch (_error) { log.debug('Failed to get context code'); return null; } diff --git a/src/web-ui/src/shared/notification-system/store/NotificationStore.ts b/src/web-ui/src/shared/notification-system/store/NotificationStore.ts index 89f84594..41905cc7 100644 --- a/src/web-ui/src/shared/notification-system/store/NotificationStore.ts +++ b/src/web-ui/src/shared/notification-system/store/NotificationStore.ts @@ -109,7 +109,7 @@ class NotificationStore { let notificationHistory = [...this.state.notificationHistory]; - let unreadCount = this.state.unreadCount; + const unreadCount = this.state.unreadCount; if (updatedNotification && (updatedNotification.variant === 'progress' || updatedNotification.variant === 'loading')) { diff --git a/src/web-ui/src/shared/utils/eventManager.ts b/src/web-ui/src/shared/utils/eventManager.ts index aaab5e0b..82ef04f6 100644 --- a/src/web-ui/src/shared/utils/eventManager.ts +++ b/src/web-ui/src/shared/utils/eventManager.ts @@ -213,44 +213,35 @@ export class EventManager implements EventManagerInterface { return info; } } -export namespace EventTypes { - - export const SESSION_START = 'session:start'; - export const SESSION_END = 'session:end'; - export const SESSION_ERROR = 'session:error'; - export const SESSION_STATE_CHANGE = 'session:state_change'; - - - export const TOOL_CALL_REQUEST = 'tool:call_request'; - export const TOOL_CALL_RESPONSE = 'tool:call_response'; - export const TOOL_EXECUTION_START = 'tool:execution_start'; - export const TOOL_EXECUTION_UPDATE = 'tool:execution_update'; - export const TOOL_EXECUTION_COMPLETE = 'tool:execution_complete'; - export const TOOL_EXECUTION_ERROR = 'tool:execution_error'; - export const TOOL_BATCH_START = 'tool:batch_start'; - export const TOOL_BATCH_UPDATE = 'tool:batch_update'; - export const TOOL_BATCH_COMPLETE = 'tool:batch_complete'; - - - export const WORKSPACE_OPENED = 'workspace:opened'; - export const WORKSPACE_CLOSED = 'workspace:closed'; - export const WORKSPACE_SWITCHED = 'workspace:switched'; - export const WORKSPACE_UPDATED = 'workspace:updated'; - export const WORKSPACE_ERROR = 'workspace:error'; - - - export const MESSAGE_ADD = 'message:add'; - export const MESSAGE_UPDATE = 'message:update'; - export const MESSAGE_DELETE = 'message:delete'; - export const MESSAGE_STREAM_START = 'message:stream_start'; - export const MESSAGE_STREAM_CHUNK = 'message:stream_chunk'; - export const MESSAGE_STREAM_END = 'message:stream_end'; - - - export const CONFIG_CHANGE = 'config:change'; - export const MODEL_CHANGE = 'model:change'; - export const THEME_CHANGE = 'theme:change'; -} +export const EventTypes = { + SESSION_START: 'session:start', + SESSION_END: 'session:end', + SESSION_ERROR: 'session:error', + SESSION_STATE_CHANGE: 'session:state_change', + TOOL_CALL_REQUEST: 'tool:call_request', + TOOL_CALL_RESPONSE: 'tool:call_response', + TOOL_EXECUTION_START: 'tool:execution_start', + TOOL_EXECUTION_UPDATE: 'tool:execution_update', + TOOL_EXECUTION_COMPLETE: 'tool:execution_complete', + TOOL_EXECUTION_ERROR: 'tool:execution_error', + TOOL_BATCH_START: 'tool:batch_start', + TOOL_BATCH_UPDATE: 'tool:batch_update', + TOOL_BATCH_COMPLETE: 'tool:batch_complete', + WORKSPACE_OPENED: 'workspace:opened', + WORKSPACE_CLOSED: 'workspace:closed', + WORKSPACE_SWITCHED: 'workspace:switched', + WORKSPACE_UPDATED: 'workspace:updated', + WORKSPACE_ERROR: 'workspace:error', + MESSAGE_ADD: 'message:add', + MESSAGE_UPDATE: 'message:update', + MESSAGE_DELETE: 'message:delete', + MESSAGE_STREAM_START: 'message:stream_start', + MESSAGE_STREAM_CHUNK: 'message:stream_chunk', + MESSAGE_STREAM_END: 'message:stream_end', + CONFIG_CHANGE: 'config:change', + MODEL_CHANGE: 'model:change', + THEME_CHANGE: 'theme:change', +} as const; export interface SessionEventData { diff --git a/src/web-ui/src/shared/utils/pathUtils.ts b/src/web-ui/src/shared/utils/pathUtils.ts index d7d33834..1cd9b073 100644 --- a/src/web-ui/src/shared/utils/pathUtils.ts +++ b/src/web-ui/src/shared/utils/pathUtils.ts @@ -45,7 +45,7 @@ export function normalizePath(path: string): string { if (decoded !== normalized) { normalized = decoded; } - } catch (e) { + } catch (_error) { } @@ -79,4 +79,3 @@ export function joinPath(basePath: string, relativePath: string): string { return `${normalizedBase}/${normalizedRelative}`; } - diff --git a/src/web-ui/src/shared/utils/version.ts b/src/web-ui/src/shared/utils/version.ts index d099c3b2..41679c4a 100644 --- a/src/web-ui/src/shared/utils/version.ts +++ b/src/web-ui/src/shared/utils/version.ts @@ -56,7 +56,7 @@ export function formatBuildDate(buildDate: string): string { second: '2-digit', hour12: false }); - } catch (e) { + } catch (_error) { return buildDate; } } diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index 0ec32b6c..f498651d 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -255,6 +255,25 @@ const CodeEditor: React.FC = ({ const largeFileExpansionBlockedLogRef = useRef(false); const pendingModelContentRef = useRef(null); const macosEditorBindingCleanupRef = useRef<(() => void) | null>(null); + const workspacePathRuntimeRef = useRef(workspacePath); + const readOnlyRuntimeRef = useRef(readOnly); + const showLineNumbersRuntimeRef = useRef(showLineNumbers); + const showMinimapRuntimeRef = useRef(showMinimap); + const onContentChangeRef = useRef(onContentChange); + const tRef = useRef(t); + const contentRef = useRef(content); + const loadingRef = useRef(loading); + const editorConfigRuntimeRef = useRef(editorConfig); + + workspacePathRuntimeRef.current = workspacePath; + readOnlyRuntimeRef.current = readOnly; + showLineNumbersRuntimeRef.current = showLineNumbers; + showMinimapRuntimeRef.current = showMinimap; + onContentChangeRef.current = onContentChange; + tRef.current = t; + contentRef.current = content; + loadingRef.current = loading; + editorConfigRuntimeRef.current = editorConfig; const detectLargeFileMode = useCallback((nextContent: string, fileSizeBytes?: number): boolean => { const size = typeof fileSizeBytes === 'number' && fileSizeBytes >= 0 @@ -520,6 +539,7 @@ const CodeEditor: React.FC = ({ return; } + const container = containerRef.current; let editor: monaco.editor.IStandaloneCodeEditor | null = null; let model: monaco.editor.ITextModel | null = null; @@ -531,8 +551,8 @@ const CodeEditor: React.FC = ({ } let createFontSize = 14; - let createFontFamily = editorConfig.font_family || "'Fira Code', 'Noto Sans SC', Consolas, 'Courier New', monospace"; - let createFontWeight = editorConfig.font_weight || 'normal'; + let createFontFamily = editorConfigRuntimeRef.current.font_family || "'Fira Code', 'Noto Sans SC', Consolas, 'Courier New', monospace"; + let createFontWeight = editorConfigRuntimeRef.current.font_weight || 'normal'; let createLineHeight = 0; const applyFontConfig = (c: Partial) => { createFontSize = c.font_size ?? 14; @@ -551,8 +571,8 @@ const CodeEditor: React.FC = ({ model = monacoModelManager.getOrCreateModel( filePath, detectedLanguage, - content || '', - workspacePath + contentRef.current || '', + workspacePathRuntimeRef.current ); modelRef.current = model; @@ -570,14 +590,14 @@ const CodeEditor: React.FC = ({ savedVersionIdRef.current = modelMetadata.savedVersionId; originalContentRef.current = modelMetadata.originalContent; - if (isDirty && onContentChange) { - onContentChange(modelContent, true); + if (isDirty && onContentChangeRef.current) { + onContentChangeRef.current(modelContent, true); } } else { savedVersionIdRef.current = model.getAlternativeVersionId(); } - if (modelContent && modelContent !== content) { + if (modelContent && modelContent !== contentRef.current) { setContent(modelContent); if (!modelMetadata) { originalContentRef.current = modelContent; @@ -602,21 +622,21 @@ const CodeEditor: React.FC = ({ model: model, theme: themeId, automaticLayout: true, - readOnly: readOnly, - lineNumbers: showLineNumbers ? 'on' : (editorConfig.line_numbers as any) || 'on', + readOnly: readOnlyRuntimeRef.current, + lineNumbers: showLineNumbersRuntimeRef.current ? 'on' : (editorConfigRuntimeRef.current.line_numbers as any) || 'on', minimap: { - enabled: showMinimap && !initialLargeFileMode, - side: (editorConfig.minimap?.side as any) || 'right', - size: (editorConfig.minimap?.size as any) || 'proportional' + enabled: showMinimapRuntimeRef.current && !initialLargeFileMode, + side: (editorConfigRuntimeRef.current.minimap?.side as any) || 'right', + size: (editorConfigRuntimeRef.current.minimap?.size as any) || 'proportional' }, fontSize: createFontSize, fontFamily: createFontFamily, fontWeight: createFontWeight, - lineHeight: createLineHeight || (editorConfig.line_height ? Math.round(createFontSize * editorConfig.line_height) : 0), + lineHeight: createLineHeight || (editorConfigRuntimeRef.current.line_height ? Math.round(createFontSize * editorConfigRuntimeRef.current.line_height) : 0), scrollBeyondLastLine: false, - wordWrap: (editorConfig.word_wrap as any) || 'off', - tabSize: editorConfig.tab_size || 2, - insertSpaces: editorConfig.insert_spaces !== undefined ? editorConfig.insert_spaces : true, + wordWrap: (editorConfigRuntimeRef.current.word_wrap as any) || 'off', + tabSize: editorConfigRuntimeRef.current.tab_size || 2, + insertSpaces: editorConfigRuntimeRef.current.insert_spaces !== undefined ? editorConfigRuntimeRef.current.insert_spaces : true, contextmenu: false, links: true, gotoLocation: { @@ -691,7 +711,7 @@ const CodeEditor: React.FC = ({ } }; - editor = monaco.editor.create(containerRef.current, editorOptions); + editor = monaco.editor.create(container, editorOptions); editorRef.current = editor; setEditorInstance(editor); const editTarget = createMonacoEditTarget(editor); @@ -715,7 +735,7 @@ const CodeEditor: React.FC = ({ }; // #endregion - (containerRef.current as any).__monacoEditor = editor; + (container as any).__monacoEditor = editor; if (model) { const { lspDocumentService } = await import('@/tools/lsp/services/LspDocumentService'); @@ -786,7 +806,7 @@ const CodeEditor: React.FC = ({ normalizedPath, targetLine, targetColumn, - { workspacePath } + { workspacePath: workspacePathRuntimeRef.current } ); } catch (error) { log.error('Cross-file jump failed', error); @@ -809,7 +829,7 @@ const CodeEditor: React.FC = ({ setHasChanges(changed); hasChangesRef.current = changed; - onContentChange?.(newContent, changed); + onContentChangeRef.current?.(newContent, changed); }); editor.onDidChangeCursorPosition((e) => { @@ -834,16 +854,16 @@ const CodeEditor: React.FC = ({ }); const updateCursorPosition = (e: monaco.editor.IEditorMouseEvent) => { - if (e.target.position && containerRef.current?.parentElement?.parentElement) { + if (e.target.position && container.parentElement?.parentElement) { // containerRef -> .code-editor-tool__content -> .code-editor-tool (has data-monaco-editor) - const container = containerRef.current.parentElement.parentElement; + const editorContainer = container.parentElement.parentElement; const newLine = String(e.target.position.lineNumber); const newColumn = String(e.target.position.column); - if (container.getAttribute('data-cursor-line') !== newLine || - container.getAttribute('data-cursor-column') !== newColumn) { - container.setAttribute('data-cursor-line', newLine); - container.setAttribute('data-cursor-column', newColumn); + if (editorContainer.getAttribute('data-cursor-line') !== newLine || + editorContainer.getAttribute('data-cursor-column') !== newColumn) { + editorContainer.setAttribute('data-cursor-line', newLine); + editorContainer.setAttribute('data-cursor-column', newColumn); } } }; @@ -871,7 +891,7 @@ const CodeEditor: React.FC = ({ if (ctrlDecorationsRef.current.length > 0) { try { ctrlDecorationsRef.current = editor!.deltaDecorations(ctrlDecorationsRef.current, []); - } catch (err) { + } catch (_err) { ctrlDecorationsRef.current = []; } lastHoverWordRef.current = null; @@ -913,13 +933,13 @@ const CodeEditor: React.FC = ({ log.error('Failed to load EditorReadyManager', err); }); - if (!loading && content) { + if (!loadingRef.current && contentRef.current) { setLspReady(true); } } catch (error) { log.error('Failed to initialize editor', error); - setError(t('editor.codeEditor.initFailedWithMessage', { message: String(error) })); + setError(tRef.current('editor.codeEditor.initFailedWithMessage', { message: String(error) })); } }; @@ -951,9 +971,9 @@ const CodeEditor: React.FC = ({ editorRef.current = null; setEditorInstance(null); } - - if (containerRef.current) { - delete (containerRef.current as any).__monacoEditor; + + if (container) { + delete (container as any).__monacoEditor; } monacoModelManager.releaseModel(filePath); @@ -1403,7 +1423,7 @@ const CodeEditor: React.FC = ({ isLoadingContentRef.current = false; }); } - }, [applyExternalContentToModel, filePath, detectedLanguage, reportFileMissingFromDisk, t, updateLargeFileMode]); + }, [applyExternalContentToModel, filePath, reportFileMissingFromDisk, t, updateLargeFileMode]); // Save file content const saveFileContent = useCallback(async () => { @@ -1972,7 +1992,7 @@ const CodeEditor: React.FC = ({ return () => { unsubscribers.forEach(unsub => unsub()); }; - }, [applyDiskSnapshotToEditor, applyExternalContentToModel, monacoReady, filePath, t]); + }, [applyDiskSnapshotToEditor, monacoReady, filePath, t, workspacePath]); useEffect(() => { userLanguageOverrideRef.current = false; diff --git a/src/web-ui/src/tools/editor/components/DiffEditor.tsx b/src/web-ui/src/tools/editor/components/DiffEditor.tsx index 883164ae..b114f5ca 100644 --- a/src/web-ui/src/tools/editor/components/DiffEditor.tsx +++ b/src/web-ui/src/tools/editor/components/DiffEditor.tsx @@ -109,6 +109,23 @@ export const DiffEditor: React.FC = ({ const isUnmountedRef = useRef(false); const [modifiedEditorInstance, setModifiedEditorInstance] = useState(null); const onSaveRef = useRef(onSave); + const originalContentRuntimeRef = useRef(originalContent); + const modifiedContentRuntimeRef = useRef(modifiedContent); + const editorConfigRuntimeRef = useRef(editorConfig); + const renderIndicatorsRuntimeRef = useRef(renderIndicators); + const onModifiedContentChangeRef = useRef(onModifiedContentChange); + const onDiffChangeRef = useRef(onDiffChange); + const notificationRef = useRef(notification); + const tRef = useRef(t); + + originalContentRuntimeRef.current = originalContent; + modifiedContentRuntimeRef.current = modifiedContent; + editorConfigRuntimeRef.current = editorConfig; + renderIndicatorsRuntimeRef.current = renderIndicators; + onModifiedContentChangeRef.current = onModifiedContentChange; + onDiffChangeRef.current = onDiffChange; + notificationRef.current = notification; + tRef.current = t; useEffect(() => { onSaveRef.current = onSave; @@ -156,6 +173,7 @@ export const DiffEditor: React.FC = ({ useEffect(() => { if (!containerRef.current) return; + const container = containerRef.current; let editor: monaco.editor.IStandaloneDiffEditor | null = null; let originalModel: monaco.editor.ITextModel | null = null; let modifiedModel: monaco.editor.ITextModel | null = null; @@ -164,11 +182,6 @@ export const DiffEditor: React.FC = ({ try { await monacoInitManager.initialize(); - if (!containerRef.current) { - log.error('Container ref is null during initialization'); - return; - } - const timestamp = Date.now(); const originalUri = monaco.Uri.parse(`inmemory://diff-original/${timestamp}/${filePath || 'untitled'}`); const modifiedUri = monaco.Uri.parse(`inmemory://diff-modified/${timestamp}/${filePath || 'untitled'}`); @@ -193,8 +206,8 @@ export const DiffEditor: React.FC = ({ existingModified.dispose(); } - originalModel = monaco.editor.createModel(originalContent, detectedLanguage, originalUri); - modifiedModel = monaco.editor.createModel(modifiedContent, detectedLanguage, modifiedUri); + originalModel = monaco.editor.createModel(originalContentRuntimeRef.current, detectedLanguage, originalUri); + modifiedModel = monaco.editor.createModel(modifiedContentRuntimeRef.current, detectedLanguage, modifiedUri); originalModelRef.current = originalModel; modifiedModelRef.current = modifiedModel; @@ -216,7 +229,7 @@ export const DiffEditor: React.FC = ({ const editorOptions: monaco.editor.IStandaloneDiffEditorConstructionOptions = { renderSideBySide: renderSideBySide, renderOverviewRuler: false, - renderIndicators: renderIndicators, + renderIndicators: renderIndicatorsRuntimeRef.current, renderMarginRevertIcon: true, renderGutterMenu: true, @@ -225,7 +238,7 @@ export const DiffEditor: React.FC = ({ ignoreTrimWhitespace: false, renderWhitespace: 'selection', - diffWordWrap: (editorConfig.word_wrap as any) || 'off', + diffWordWrap: (editorConfigRuntimeRef.current.word_wrap as any) || 'off', diffAlgorithm: 'advanced', hideUnchangedRegions: { @@ -237,16 +250,16 @@ export const DiffEditor: React.FC = ({ theme: themeId, automaticLayout: true, - fontSize: editorConfig.font_size || 14, - fontFamily: editorConfig.font_family || "'Fira Code', 'Noto Sans SC', Consolas, 'Courier New', monospace", - lineHeight: editorConfig.line_height - ? Math.round((editorConfig.font_size || 14) * editorConfig.line_height) + fontSize: editorConfigRuntimeRef.current.font_size || 14, + fontFamily: editorConfigRuntimeRef.current.font_family || "'Fira Code', 'Noto Sans SC', Consolas, 'Courier New', monospace", + lineHeight: editorConfigRuntimeRef.current.line_height + ? Math.round((editorConfigRuntimeRef.current.font_size || 14) * editorConfigRuntimeRef.current.line_height) : 0, - lineNumbers: (editorConfig.line_numbers || 'on') as monaco.editor.LineNumbersType, + lineNumbers: (editorConfigRuntimeRef.current.line_numbers || 'on') as monaco.editor.LineNumbersType, minimap: { enabled: showMinimap, - side: (editorConfig.minimap?.side || 'right') as 'right' | 'left', - size: (editorConfig.minimap?.size || 'proportional') as 'proportional' | 'fill' | 'fit' + side: (editorConfigRuntimeRef.current.minimap?.side || 'right') as 'right' | 'left', + size: (editorConfigRuntimeRef.current.minimap?.size || 'proportional') as 'proportional' | 'fill' | 'fit' }, scrollBeyondLastLine: false, contextmenu: false, @@ -263,7 +276,7 @@ export const DiffEditor: React.FC = ({ enableSplitViewResizing: true, }; - editor = monaco.editor.createDiffEditor(containerRef.current, editorOptions); + editor = monaco.editor.createDiffEditor(container, editorOptions); editor.setModel({ original: originalModel, @@ -295,7 +308,7 @@ export const DiffEditor: React.FC = ({ ]; elementsToFix.forEach(selector => { - const elements = containerRef.current!.querySelectorAll(selector); + const elements = container.querySelectorAll(selector); elements.forEach((element) => { const htmlElement = element as HTMLElement; htmlElement.style.backgroundColor = 'var(--color-bg-primary)'; @@ -317,7 +330,7 @@ export const DiffEditor: React.FC = ({ contentChangeListenerRef.current = modifiedModel.onDidChangeContent(() => { if (!isUnmountedRef.current) { const newContent = modifiedModel!.getValue(); - onModifiedContentChange?.(newContent); + onModifiedContentChangeRef.current?.(newContent); } }); @@ -335,10 +348,10 @@ export const DiffEditor: React.FC = ({ if (lineChanges) { setChanges(lineChanges); - onDiffChange?.(lineChanges); + onDiffChangeRef.current?.(lineChanges); } else { setChanges([]); - onDiffChange?.([]); + onDiffChangeRef.current?.([]); } }); @@ -349,9 +362,9 @@ export const DiffEditor: React.FC = ({ } catch (err) { log.error('Failed to initialize DiffEditor', err); const errorMessage = err instanceof Error ? err.message : String(err); - setError(t('editor.diffEditor.openFailed')); + setError(tRef.current('editor.diffEditor.openFailed')); setLoading(false); - notification.error(t('editor.diffEditor.initFailedWithMessage', { message: errorMessage })); + notificationRef.current.error(tRef.current('editor.diffEditor.initFailedWithMessage', { message: errorMessage })); } }; @@ -629,4 +642,3 @@ export const DiffEditor: React.FC = ({ }; export default DiffEditor; - diff --git a/src/web-ui/src/tools/editor/components/EditorBreadcrumb.tsx b/src/web-ui/src/tools/editor/components/EditorBreadcrumb.tsx index 15e16490..37a89d8d 100644 --- a/src/web-ui/src/tools/editor/components/EditorBreadcrumb.tsx +++ b/src/web-ui/src/tools/editor/components/EditorBreadcrumb.tsx @@ -250,7 +250,7 @@ export const EditorBreadcrumb: React.FC = ({ const segments = useMemo(() => { if (!filePath) return []; - let normalizedPath = filePath.replace(/\\/g, '/'); + const normalizedPath = filePath.replace(/\\/g, '/'); let relativePath = normalizedPath; const normalizedWorkspace = workspacePath ? workspacePath.replace(/\\/g, '/') : ''; diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx index c40ce227..17dddd84 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx @@ -145,9 +145,10 @@ const MarkdownEditor: React.FC = ({ useEffect(() => { isUnmountedRef.current = false; + const editor = editorRef.current; return () => { isUnmountedRef.current = true; - editorRef.current?.destroy(); + editor?.destroy(); }; }, []); diff --git a/src/web-ui/src/tools/editor/components/PlanViewer.tsx b/src/web-ui/src/tools/editor/components/PlanViewer.tsx index bde02224..94e4afd7 100644 --- a/src/web-ui/src/tools/editor/components/PlanViewer.tsx +++ b/src/web-ui/src/tools/editor/components/PlanViewer.tsx @@ -98,7 +98,7 @@ const PlanViewer: React.FC = ({ return normalizedPath.substring(0, lastSlashIndex); } return undefined; - }, [filePath, t]); + }, [filePath]); const displayFileName = useMemo(() => { if (fileName) return fileName; @@ -111,10 +111,12 @@ const PlanViewer: React.FC = ({ useEffect(() => { isUnmountedRef.current = false; + const editor = editorRef.current; + const yamlEditor = yamlEditorRef.current; return () => { isUnmountedRef.current = true; - editorRef.current?.destroy(); - yamlEditorRef.current?.destroy(); + editor?.destroy(); + yamlEditor?.destroy(); }; }, []); @@ -176,7 +178,7 @@ const PlanViewer: React.FC = ({ setLoading(false); } } - }, [filePath]); + }, [filePath, t]); useEffect(() => { loadFileContent(); @@ -482,7 +484,7 @@ const PlanViewer: React.FC = ({ return; } setYamlEditorPlacement('inline'); - }, [isInlineTodoEditing, isTodosExpanded, isTrailingTodoEditing, yamlEditorPlacement]); + }, []); const closeYamlEditor = useCallback(() => { setYamlEditorPlacement('none'); @@ -676,6 +678,7 @@ const PlanViewer: React.FC = ({ trailingTodoDrafts, yamlContent, yamlEditorPlacement, + mEditorTheme, ]); // Build button click handler @@ -714,7 +717,7 @@ ${JSON.stringify(simpleTodos, null, 2)} }, [filePath, buildStatus, planData, planContent, t]); // Get todo status icon - const getTodoIcon = (status?: string) => { + function getTodoIcon(status?: string) { switch (status) { case 'completed': return ; @@ -726,7 +729,7 @@ ${JSON.stringify(simpleTodos, null, 2)} default: return ; } - }; + } // Render loading state if (loading) { diff --git a/src/web-ui/src/tools/editor/core/MonacoDiffCore.tsx b/src/web-ui/src/tools/editor/core/MonacoDiffCore.tsx index a27904b9..933f58a1 100644 --- a/src/web-ui/src/tools/editor/core/MonacoDiffCore.tsx +++ b/src/web-ui/src/tools/editor/core/MonacoDiffCore.tsx @@ -70,8 +70,45 @@ export const MonacoDiffCore = forwardRef const isUnmountedRef = useRef(false); const disposablesRef = useRef([]); const hasRevealedRef = useRef(false); + const filePathRef = useRef(filePath); + const originalContentRef = useRef(originalContent); + const modifiedContentRef = useRef(modifiedContent); + const languageRef = useRef(language); + const presetRef = useRef(preset); + const configRef = useRef(config); + const readOnlyRef = useRef(readOnly); + const themeRef = useRef(theme); + const renderSideBySideRef = useRef(renderSideBySide); + const renderOverviewRulerRef = useRef(renderOverviewRuler); + const renderIndicatorsRef = useRef(renderIndicators); + const originalEditableRef = useRef(originalEditable); + const ignoreTrimWhitespaceRef = useRef(ignoreTrimWhitespace); + const showMinimapRef = useRef(showMinimap); + const onModifiedContentChangeRef = useRef(onModifiedContentChange); + const onDiffChangeRef = useRef(onDiffChange); + const onEditorReadyRef = useRef(onEditorReady); + const onEditorWillDisposeRef = useRef(onEditorWillDispose); const [isReady, setIsReady] = useState(false); + + filePathRef.current = filePath; + originalContentRef.current = originalContent; + modifiedContentRef.current = modifiedContent; + languageRef.current = language; + presetRef.current = preset; + configRef.current = config; + readOnlyRef.current = readOnly; + themeRef.current = theme; + renderSideBySideRef.current = renderSideBySide; + renderOverviewRulerRef.current = renderOverviewRuler; + renderIndicatorsRef.current = renderIndicators; + originalEditableRef.current = originalEditable; + ignoreTrimWhitespaceRef.current = ignoreTrimWhitespace; + showMinimapRef.current = showMinimap; + onModifiedContentChangeRef.current = onModifiedContentChange; + onDiffChangeRef.current = onDiffChange; + onEditorReadyRef.current = onEditorReady; + onEditorWillDisposeRef.current = onEditorWillDispose; useImperativeHandle(ref, () => ({ getDiffEditor: () => diffEditorRef.current, @@ -119,56 +156,75 @@ export const MonacoDiffCore = forwardRef const generateUri = useCallback((type: 'original' | 'modified'): monaco.Uri => { const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 8); - const basePath = filePath || 'untitled'; + const basePath = filePathRef.current || 'untitled'; return monaco.Uri.parse(`inmemory://diff/${type}/${timestamp}/${random}/${basePath}`); - }, [filePath]); - + }, []); + + const registerEventListeners = useCallback(( + diffEditor: monaco.editor.IStandaloneDiffEditor, + modifiedModel: monaco.editor.ITextModel + ) => { + const contentDisposable = modifiedModel.onDidChangeContent(() => { + onModifiedContentChangeRef.current?.(modifiedModel.getValue()); + }); + disposablesRef.current.push(contentDisposable); + + const diffDisposable = diffEditor.onDidUpdateDiff(() => { + const changes = diffEditor.getLineChanges() || []; + changesRef.current = changes; + + onDiffChangeRef.current?.(changes); + }); + disposablesRef.current.push(diffDisposable); + }, []); + useEffect(() => { if (!containerRef.current) return; isUnmountedRef.current = false; hasRevealedRef.current = false; + const container = containerRef.current; const initEditor = async () => { try { await monacoInitManager.initialize(); - if (isUnmountedRef.current || !containerRef.current) return; + if (isUnmountedRef.current) return; themeManager.initialize(); const originalModel = monaco.editor.createModel( - originalContent, - language, + originalContentRef.current, + languageRef.current, generateUri('original') ); const modifiedModel = monaco.editor.createModel( - modifiedContent, - language, + modifiedContentRef.current, + languageRef.current, generateUri('modified') ); originalModelRef.current = originalModel; modifiedModelRef.current = modifiedModel; const overrides: EditorOptionsOverrides = { - readOnly, - minimap: showMinimap, - theme, + readOnly: readOnlyRef.current, + minimap: showMinimapRef.current, + theme: themeRef.current, }; const diffOptions = buildDiffEditorOptions({ - config, - preset, + config: configRef.current, + preset: presetRef.current, overrides, }); - const diffEditor = monaco.editor.createDiffEditor(containerRef.current, { + const diffEditor = monaco.editor.createDiffEditor(container, { ...diffOptions, - renderSideBySide, - renderOverviewRuler, - renderIndicators, - originalEditable, - ignoreTrimWhitespace, + renderSideBySide: renderSideBySideRef.current, + renderOverviewRuler: renderOverviewRulerRef.current, + renderIndicators: renderIndicatorsRef.current, + originalEditable: originalEditableRef.current, + ignoreTrimWhitespace: ignoreTrimWhitespaceRef.current, }); diffEditorRef.current = diffEditor; @@ -181,9 +237,7 @@ export const MonacoDiffCore = forwardRef setIsReady(true); - if (onEditorReady) { - onEditorReady(diffEditor, originalModel, modifiedModel); - } + onEditorReadyRef.current?.(diffEditor, originalModel, modifiedModel); } catch (error) { log.error('Failed to initialize diff editor', error); @@ -195,9 +249,7 @@ export const MonacoDiffCore = forwardRef return () => { isUnmountedRef.current = true; - if (onEditorWillDispose) { - onEditorWillDispose(); - } + onEditorWillDisposeRef.current?.(); disposablesRef.current.forEach(d => d.dispose()); disposablesRef.current = []; @@ -218,29 +270,7 @@ export const MonacoDiffCore = forwardRef setIsReady(false); }; - }, []); - - const registerEventListeners = useCallback(( - diffEditor: monaco.editor.IStandaloneDiffEditor, - modifiedModel: monaco.editor.ITextModel - ) => { - const contentDisposable = modifiedModel.onDidChangeContent(() => { - if (onModifiedContentChange) { - onModifiedContentChange(modifiedModel.getValue()); - } - }); - disposablesRef.current.push(contentDisposable); - - const diffDisposable = diffEditor.onDidUpdateDiff(() => { - const changes = diffEditor.getLineChanges() || []; - changesRef.current = changes; - - if (onDiffChange) { - onDiffChange(changes); - } - }); - disposablesRef.current.push(diffDisposable); - }, [onModifiedContentChange, onDiffChange]); + }, [filePath, generateUri, registerEventListeners]); useEffect(() => { if (!isReady) return; diff --git a/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx b/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx index 7fdfc433..7492332c 100644 --- a/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx +++ b/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx @@ -69,8 +69,43 @@ export const MonacoEditorCore = forwardRef([]); const macosEditorBindingCleanupRef = useRef<(() => void) | null>(null); const hasJumpedRef = useRef(false); + const filePathRef = useRef(filePath); + const workspacePathRef = useRef(workspacePath); + const languageRef = useRef(language); + const initialContentRef = useRef(initialContent); + const presetRef = useRef(preset); + const configRef = useRef(config); + const readOnlyRef = useRef(readOnly); + const themeRef = useRef(theme); + const enableLspRef = useRef(enableLsp); + const showLineNumbersRef = useRef(showLineNumbers); + const showMinimapRef = useRef(showMinimap); + const onContentChangeRef = useRef(onContentChange); + const onCursorChangeRef = useRef(onCursorChange); + const onSelectionChangeRef = useRef(onSelectionChange); + const onEditorReadyRef = useRef(onEditorReady); + const onEditorWillDisposeRef = useRef(onEditorWillDispose); + const onSaveRef = useRef(onSave); const [isReady, setIsReady] = useState(false); + + filePathRef.current = filePath; + workspacePathRef.current = workspacePath; + languageRef.current = language; + initialContentRef.current = initialContent; + presetRef.current = preset; + configRef.current = config; + readOnlyRef.current = readOnly; + themeRef.current = theme; + enableLspRef.current = enableLsp; + showLineNumbersRef.current = showLineNumbers; + showMinimapRef.current = showMinimap; + onContentChangeRef.current = onContentChange; + onCursorChangeRef.current = onCursorChange; + onSelectionChangeRef.current = onSelectionChange; + onEditorReadyRef.current = onEditorReady; + onEditorWillDisposeRef.current = onEditorWillDispose; + onSaveRef.current = onSave; useImperativeHandle(ref, () => ({ getEditor: () => editorRef.current, @@ -98,52 +133,84 @@ export const MonacoEditorCore = forwardRef { + const createExtensionContext = useCallback((overrides?: Partial): EditorExtensionContext => { return { - filePath, - language, - workspacePath, - readOnly, - enableLsp, + filePath: overrides?.filePath ?? filePathRef.current, + language: overrides?.language ?? languageRef.current, + workspacePath: overrides?.workspacePath ?? workspacePathRef.current, + readOnly: overrides?.readOnly ?? readOnlyRef.current, + enableLsp: overrides?.enableLsp ?? enableLspRef.current, }; - }, [filePath, language, workspacePath, readOnly, enableLsp]); + }, []); + + const registerEventListeners = useCallback(( + editor: monaco.editor.IStandaloneCodeEditor, + model: monaco.editor.ITextModel + ) => { + const contentDisposable = model.onDidChangeContent((event) => { + onContentChangeRef.current?.(model.getValue(), event); + + const context = createExtensionContext(); + editorExtensionManager.notifyContentChanged(editor, model, event, context); + }); + disposablesRef.current.push(contentDisposable); + + const cursorDisposable = editor.onDidChangeCursorPosition((e) => { + onCursorChangeRef.current?.(e.position); + }); + disposablesRef.current.push(cursorDisposable); + + const selectionDisposable = editor.onDidChangeCursorSelection((e) => { + onSelectionChangeRef.current?.(e.selection); + }); + disposablesRef.current.push(selectionDisposable); + }, [createExtensionContext]); useEffect(() => { if (!containerRef.current) return; isUnmountedRef.current = false; hasJumpedRef.current = false; + const container = containerRef.current; + const currentFilePath = filePath; + const initialContext = createExtensionContext({ + filePath: currentFilePath, + language: languageRef.current, + workspacePath: workspacePathRef.current, + readOnly: readOnlyRef.current, + enableLsp: enableLspRef.current, + }); const initEditor = async () => { try { await monacoInitManager.initialize(); - if (isUnmountedRef.current || !containerRef.current) return; + if (isUnmountedRef.current) return; themeManager.initialize(); const model = monacoModelManager.getOrCreateModel( - filePath, - language, - initialContent, - workspacePath + currentFilePath, + languageRef.current, + initialContentRef.current, + workspacePathRef.current ); modelRef.current = model; const overrides: EditorOptionsOverrides = { - readOnly, - lineNumbers: showLineNumbers, - minimap: showMinimap, - theme, + readOnly: readOnlyRef.current, + lineNumbers: showLineNumbersRef.current, + minimap: showMinimapRef.current, + theme: themeRef.current, }; const editorOptions = buildEditorOptions({ - config, - preset, + config: configRef.current, + preset: presetRef.current, overrides, }); - const editor = monaco.editor.create(containerRef.current, { + const editor = monaco.editor.create(container, { ...editorOptions, model, }); @@ -170,21 +237,16 @@ export const MonacoEditorCore = forwardRef { - const content = model.getValue(); - onSave(content); - }); - } + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + const content = model.getValue(); + onSaveRef.current?.(content); + }); - const context = createExtensionContext(); - editorIdRef.current = editorExtensionManager.notifyEditorCreated(editor, model, context); + editorIdRef.current = editorExtensionManager.notifyEditorCreated(editor, model, initialContext); setIsReady(true); - if (onEditorReady) { - onEditorReady(editor, model); - } + onEditorReadyRef.current?.(editor, model); } catch (error) { log.error('Failed to initialize editor', error); @@ -196,17 +258,14 @@ export const MonacoEditorCore = forwardRef { isUnmountedRef.current = true; - if (onEditorWillDispose) { - onEditorWillDispose(); - } + onEditorWillDisposeRef.current?.(); if (editorRef.current && modelRef.current && editorIdRef.current) { - const context = createExtensionContext(); editorExtensionManager.notifyEditorWillDispose( editorIdRef.current, editorRef.current, modelRef.current, - context + initialContext ); } @@ -224,42 +283,13 @@ export const MonacoEditorCore = forwardRef { - const contentDisposable = model.onDidChangeContent((event) => { - if (onContentChange) { - onContentChange(model.getValue(), event); - } - - const context = createExtensionContext(); - editorExtensionManager.notifyContentChanged(editor, model, event, context); - }); - disposablesRef.current.push(contentDisposable); - - const cursorDisposable = editor.onDidChangeCursorPosition((e) => { - if (onCursorChange) { - onCursorChange(e.position); - } - }); - disposablesRef.current.push(cursorDisposable); - - const selectionDisposable = editor.onDidChangeCursorSelection((e) => { - if (onSelectionChange) { - onSelectionChange(e.selection); - } - }); - disposablesRef.current.push(selectionDisposable); - }, [onContentChange, onCursorChange, onSelectionChange, createExtensionContext]); + }, [filePath, createExtensionContext, registerEventListeners]); useEffect(() => { if (!isReady || !editorRef.current || hasJumpedRef.current) return; @@ -316,6 +346,16 @@ export const MonacoEditorCore = forwardRef { + if (!isReady || !modelRef.current) { + return; + } + + if (modelRef.current.getLanguageId() !== language) { + monaco.editor.setModelLanguage(modelRef.current, language); + } + }, [isReady, language]); useEffect(() => { const unsubscribe = themeManager.onThemeChange((event) => { diff --git a/src/web-ui/src/tools/editor/meditor/components/MEditor.tsx b/src/web-ui/src/tools/editor/meditor/components/MEditor.tsx index 4a1df270..5c6f7f94 100644 --- a/src/web-ui/src/tools/editor/meditor/components/MEditor.tsx +++ b/src/web-ui/src/tools/editor/meditor/components/MEditor.tsx @@ -13,7 +13,7 @@ import './MEditor.scss' void createLogger('MEditor') let markdownTextareaTargetCounter = 0 -export interface MEditorProps extends EditorOptions {} +export type MEditorProps = EditorOptions; function executeTextareaAction( textarea: HTMLTextAreaElement | null, diff --git a/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.tsx b/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.tsx index 285bd496..53535803 100644 --- a/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.tsx +++ b/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.tsx @@ -32,8 +32,8 @@ import { } from '../extensions/MarkdownTableExtensions'; import { InlineAiPreviewExtension, - inlineAiPreviewPluginKey, } from '../extensions/InlineAiPreviewExtension'; +import { inlineAiPreviewPluginKey } from '../extensions/InlineAiPreviewPluginKey'; import { RawHtmlBlock, RawHtmlInline, RenderOnlyBlock } from '../extensions/RawHtmlExtensions'; import { getBlockIndexForLine } from '../utils/markdownBlocks'; import { @@ -328,7 +328,7 @@ export const TiptapEditor = React.forwardRef(null); - const initialContent = useMemo(() => markdownToTiptapDoc(value), []); + const initialContent = useMemo(() => markdownToTiptapDoc(value), [value]); const inlineAiTriggerHint = t('editor.meditor.inlineAi.triggerHint'); useEffect(() => { @@ -785,6 +785,9 @@ export const TiptapEditor = React.forwardRef void = () => {}; + let unlistenCompleted: () => void = () => {}; + let unlistenFailed: () => void = () => {}; const cleanup = () => { if (isCleanedUp) { @@ -809,7 +812,7 @@ export const TiptapEditor = React.forwardRef { + unlistenChunk = editorAiAPI.onTextChunk(event => { if (event.requestId !== requestId) { return; } @@ -827,7 +830,7 @@ export const TiptapEditor = React.forwardRef { + unlistenCompleted = editorAiAPI.onCompleted(event => { if (event.requestId !== requestId) { return; } @@ -857,7 +860,7 @@ export const TiptapEditor = React.forwardRef { + unlistenFailed = editorAiAPI.onError(event => { if (event.requestId !== requestId) { return; } diff --git a/src/web-ui/src/tools/editor/meditor/extensions/InlineAiPreviewExtension.tsx b/src/web-ui/src/tools/editor/meditor/extensions/InlineAiPreviewExtension.tsx index 1a33d3c9..9edd459d 100644 --- a/src/web-ui/src/tools/editor/meditor/extensions/InlineAiPreviewExtension.tsx +++ b/src/web-ui/src/tools/editor/meditor/extensions/InlineAiPreviewExtension.tsx @@ -1,38 +1,12 @@ import { Extension } from '@tiptap/core'; -import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Plugin } from '@tiptap/pm/state'; import type { EditorState } from '@tiptap/pm/state'; import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; import type { EditorView } from '@tiptap/pm/view'; import { createRoot, type Root } from 'react-dom/client'; import { InlineAiPreviewBlock } from '../components/InlineAiPreviewBlock'; - -type InlineAiPreviewStatus = 'submitting' | 'streaming' | 'ready' | 'error'; - -interface InlineAiPreviewLabels { - title: string; - streaming: string; - ready: string; - error: string; - accept: string; - reject: string; - retry: string; -} - -export interface InlineAiPreviewWidgetState { - blockId: string; - status: InlineAiPreviewStatus; - response: string; - error: string | null; - basePath?: string; - canAccept: boolean; - labels: InlineAiPreviewLabels; - onAccept: () => void; - onReject: () => void; - onRetry: () => void; -} - -export const inlineAiPreviewPluginKey = new PluginKey('meditorInlineAiPreview'); +import { inlineAiPreviewPluginKey, type InlineAiPreviewWidgetState } from './InlineAiPreviewPluginKey'; function getWidgetPosition(doc: ProseMirrorNode, blockId: string): number | null { let position: number | null = null; diff --git a/src/web-ui/src/tools/editor/meditor/extensions/InlineAiPreviewPluginKey.ts b/src/web-ui/src/tools/editor/meditor/extensions/InlineAiPreviewPluginKey.ts new file mode 100644 index 00000000..c41a324f --- /dev/null +++ b/src/web-ui/src/tools/editor/meditor/extensions/InlineAiPreviewPluginKey.ts @@ -0,0 +1,28 @@ +import { PluginKey } from '@tiptap/pm/state'; + +export interface InlineAiPreviewLabels { + title: string; + streaming: string; + ready: string; + error: string; + accept: string; + reject: string; + retry: string; +} + +export interface InlineAiPreviewWidgetState { + blockId: string; + status: 'submitting' | 'streaming' | 'ready' | 'error'; + response: string; + error: string | null; + basePath?: string; + canAccept: boolean; + labels: InlineAiPreviewLabels; + onAccept: () => void; + onReject: () => void; + onRetry: () => void; +} + +export const inlineAiPreviewPluginKey = new PluginKey( + 'meditorInlineAiPreview' +); diff --git a/src/web-ui/src/tools/editor/meditor/extensions/RawHtmlExtensions.ts b/src/web-ui/src/tools/editor/meditor/extensions/RawHtmlExtensions.ts index 63ea3bb3..e7950d32 100644 --- a/src/web-ui/src/tools/editor/meditor/extensions/RawHtmlExtensions.ts +++ b/src/web-ui/src/tools/editor/meditor/extensions/RawHtmlExtensions.ts @@ -350,13 +350,6 @@ function createSourceBackedBlock( setEditing(false); }; - const enterEditing = () => { - setEditing(true, { focus: true }); - const end = textarea.value.length; - textarea.setSelectionRange(end, end); - sync(); - }; - const renderPreview = (markdown: string) => { const kind = typeof currentNode.attrs.kind === 'string' ? currentNode.attrs.kind : null; const detailsSource = kind === 'details' ? parseDetailsSource(markdown) : null; @@ -477,6 +470,13 @@ function createSourceBackedBlock( lastEditableState = editable; }; + const enterEditing = () => { + setEditing(true, { focus: true }); + const end = textarea.value.length; + textarea.setSelectionRange(end, end); + sync(); + }; + const stopPropagation = (event: Event) => { event.stopPropagation(); }; diff --git a/src/web-ui/src/tools/editor/meditor/utils/tiptapMarkdown.ts b/src/web-ui/src/tools/editor/meditor/utils/tiptapMarkdown.ts index fa89d784..3eb85f1b 100644 --- a/src/web-ui/src/tools/editor/meditor/utils/tiptapMarkdown.ts +++ b/src/web-ui/src/tools/editor/meditor/utils/tiptapMarkdown.ts @@ -663,7 +663,7 @@ function applyLinkMarks(markdown: string, marks: Mark[] = []): string { function escapeMarkdownImageText(value: string): string { return value .replace(/\\/g, '\\\\') - .replace(/([\[\]])/g, '\\$1'); + .replace(/[[\]]/g, '\\$&'); } function escapeMarkdownUrl(value: string): string { @@ -1304,7 +1304,7 @@ function closeFormattingMark(type: string, value: string): string { function renderInline(content: JSONContent[] = []): string { let result = ''; - let activeFormatting: string[] = []; + const activeFormatting: string[] = []; const syncFormatting = (nextFormatting: string[]) => { while (activeFormatting.length > 0 && !nextFormatting.includes(activeFormatting[activeFormatting.length - 1])) { diff --git a/src/web-ui/src/tools/file-system/components/FileExplorer.tsx b/src/web-ui/src/tools/file-system/components/FileExplorer.tsx index c92c14b6..efd24121 100644 --- a/src/web-ui/src/tools/file-system/components/FileExplorer.tsx +++ b/src/web-ui/src/tools/file-system/components/FileExplorer.tsx @@ -32,7 +32,7 @@ const ScrollBreadcrumb: React.FC = ({ containerRef, works const expandedDirNodes = treeContainer.querySelectorAll('[data-is-directory="true"][data-is-expanded="true"]'); - let activeDirs: { path: string; top: number }[] = []; + const activeDirs: { path: string; top: number }[] = []; expandedDirNodes.forEach((node) => { const rect = node.getBoundingClientRect(); @@ -515,4 +515,4 @@ function formatDate(date: Date): string { }); } -export default FileExplorer; \ No newline at end of file +export default FileExplorer; diff --git a/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts b/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts index 62014b00..9571deed 100644 --- a/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts +++ b/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts @@ -506,13 +506,13 @@ export function useFileSystem(options: UseFileSystemOptions = {}): UseFileSystem silentRefreshing: false })); } - }, [autoLoad, rootPath, enableLazyLoad]); + }, [autoLoad, rootPath, enableLazyLoad, loadFileTree, loadFileTreeLazy]); useEffect(() => { if (rootPath && state.fileTree.length > 0) { - loadFileTree(); + void loadFileTree(); } - }, [state.options.showHiddenFiles, state.options.excludePatterns]); + }, [state.fileTree.length, state.options.showHiddenFiles, state.options.excludePatterns, rootPath, loadFileTree]); useEffect(() => { if (!rootPath) { diff --git a/src/web-ui/src/tools/file-system/services/FileSystemService.ts b/src/web-ui/src/tools/file-system/services/FileSystemService.ts index ad390749..8ee284d7 100644 --- a/src/web-ui/src/tools/file-system/services/FileSystemService.ts +++ b/src/web-ui/src/tools/file-system/services/FileSystemService.ts @@ -195,19 +195,22 @@ class FileSystemService implements IFileSystemService { case 'name': comparison = a.name.localeCompare(b.name, 'zh-CN', { numeric: true }); break; - case 'size': + case 'size': { comparison = (a.size || 0) - (b.size || 0); break; - case 'lastModified': + } + case 'lastModified': { const aTime = a.lastModified?.getTime() || 0; const bTime = b.lastModified?.getTime() || 0; comparison = aTime - bTime; break; - case 'type': + } + case 'type': { const aExt = a.extension || ''; const bExt = b.extension || ''; comparison = aExt.localeCompare(bExt); break; + } default: comparison = a.name.localeCompare(b.name, 'zh-CN', { numeric: true }); } @@ -222,4 +225,4 @@ class FileSystemService implements IFileSystemService { } } -export const fileSystemService = new FileSystemService(); \ No newline at end of file +export const fileSystemService = new FileSystemService(); diff --git a/src/web-ui/src/tools/git/components/CreateBranchDialog/CreateBranchDialog.tsx b/src/web-ui/src/tools/git/components/CreateBranchDialog/CreateBranchDialog.tsx index ac501ff0..8f2191e4 100644 --- a/src/web-ui/src/tools/git/components/CreateBranchDialog/CreateBranchDialog.tsx +++ b/src/web-ui/src/tools/git/components/CreateBranchDialog/CreateBranchDialog.tsx @@ -63,7 +63,7 @@ export const CreateBranchDialog: React.FC = ({ if (name.includes(' ')) { return t('validation.branchNameSpaces'); } - if (/[\^~:?*\[\\]/.test(name)) { + if (['^', '~', ':', '?', '*', '[', '\\'].some((char) => name.includes(char))) { return t('validation.branchNameSpecialChars'); } if (name.includes('@{')) { diff --git a/src/web-ui/src/tools/git/components/GitGraphView/GitGraphView.tsx b/src/web-ui/src/tools/git/components/GitGraphView/GitGraphView.tsx index f5d35182..70d08f5d 100644 --- a/src/web-ui/src/tools/git/components/GitGraphView/GitGraphView.tsx +++ b/src/web-ui/src/tools/git/components/GitGraphView/GitGraphView.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ /** Git commit graph view (branch graph). */ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; @@ -45,7 +46,7 @@ export const GitGraphView: React.FC = ({ className = '' }) => { const { t } = useTranslation('panels/git'); - const viewConfig = { ...DEFAULT_CONFIG, ...config }; + const viewConfig = useMemo(() => ({ ...DEFAULT_CONFIG, ...config }), [config]); const [graphData, setGraphData] = useState(null); @@ -628,7 +629,7 @@ function drawNodeWithInfo( ctx.textAlign = 'left'; let displayText = node.message; - let textWidth = ctx.measureText(displayText).width; + const textWidth = ctx.measureText(displayText).width; if (textWidth > maxTextWidth) { while (ctx.measureText(displayText + '…').width > maxTextWidth && displayText.length > 0) { @@ -779,4 +780,3 @@ const HitArea = React.memo(({ HitArea.displayName = 'HitArea'; export default GitGraphView; - diff --git a/src/web-ui/src/tools/git/components/GitSettingsView/GitSettingsView.tsx b/src/web-ui/src/tools/git/components/GitSettingsView/GitSettingsView.tsx index dbe1f477..255a55eb 100644 --- a/src/web-ui/src/tools/git/components/GitSettingsView/GitSettingsView.tsx +++ b/src/web-ui/src/tools/git/components/GitSettingsView/GitSettingsView.tsx @@ -100,7 +100,7 @@ const GitSettingsView: React.FC = ({ } finally { setLoading(false); } - }, [repositoryPath, t]); + }, [t]); const saveConfig = useCallback(async () => { setSaving(true); @@ -118,7 +118,7 @@ const GitSettingsView: React.FC = ({ } finally { setSaving(false); } - }, [config, t]); + }, [t]); const updateUserConfig = useCallback((field: 'name' | 'email', value: string) => { setConfig(prev => ({ @@ -440,4 +440,4 @@ const GitSettingsView: React.FC = ({ ); }; -export default GitSettingsView; \ No newline at end of file +export default GitSettingsView; diff --git a/src/web-ui/src/tools/git/hooks/useGitAdvanced.ts b/src/web-ui/src/tools/git/hooks/useGitAdvanced.ts index afbbe2f6..c8d2217b 100644 --- a/src/web-ui/src/tools/git/hooks/useGitAdvanced.ts +++ b/src/web-ui/src/tools/git/hooks/useGitAdvanced.ts @@ -175,7 +175,7 @@ export function useGitAdvanced(options: UseGitAdvancedOptions): UseGitAdvancedRe log.error('Failed to list stashes', { repositoryPath, error: err }); return []; } - }, [repositoryPath, log]); + }, [repositoryPath]); /** * Cherry-pick a commit. diff --git a/src/web-ui/src/tools/git/hooks/useGitOperations.ts b/src/web-ui/src/tools/git/hooks/useGitOperations.ts index 68cdd659..8e67ebb2 100644 --- a/src/web-ui/src/tools/git/hooks/useGitOperations.ts +++ b/src/web-ui/src/tools/git/hooks/useGitOperations.ts @@ -139,7 +139,7 @@ export function useGitOperations(options: UseGitOperationsOptions): UseGitOperat try { - let pushParams = { ...params }; + const pushParams = { ...params }; if (!pushParams.remote || !pushParams.branch) { diff --git a/src/web-ui/src/tools/git/services/GitEventService.ts b/src/web-ui/src/tools/git/services/GitEventService.ts index 992a5413..91c246c5 100644 --- a/src/web-ui/src/tools/git/services/GitEventService.ts +++ b/src/web-ui/src/tools/git/services/GitEventService.ts @@ -160,8 +160,8 @@ export class GitEventService { return new Promise((resolve, reject) => { const timeout = options?.timeout || 10000; - let timeoutId: NodeJS.Timeout; - let unsubscriber: () => void; + let unsubscriber: () => void = () => {}; + let timeoutId: ReturnType | null = null; const cleanup = () => { if (timeoutId) clearTimeout(timeoutId); diff --git a/src/web-ui/src/tools/lsp/components/LspPluginList/LspPluginList.tsx b/src/web-ui/src/tools/lsp/components/LspPluginList/LspPluginList.tsx index fc794f46..60374a66 100644 --- a/src/web-ui/src/tools/lsp/components/LspPluginList/LspPluginList.tsx +++ b/src/web-ui/src/tools/lsp/components/LspPluginList/LspPluginList.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ /** * LSP plugin list UI. */ @@ -233,4 +234,3 @@ const PluginItem: React.FC = ({ plugin, isExpanded, onToggle, o ); }; - diff --git a/src/web-ui/src/tools/lsp/hooks/useMonacoLsp.ts b/src/web-ui/src/tools/lsp/hooks/useMonacoLsp.ts index 0bee00ea..434b5930 100644 --- a/src/web-ui/src/tools/lsp/hooks/useMonacoLsp.ts +++ b/src/web-ui/src/tools/lsp/hooks/useMonacoLsp.ts @@ -79,5 +79,5 @@ export function useMonacoLsp( isRegisteredRef.current = false; } }; - }, [editor, language, filePath, enabled, workspacePath, log]); + }, [editor, language, filePath, enabled, workspacePath]); } diff --git a/src/web-ui/src/tools/lsp/services/LspRefreshManager.ts b/src/web-ui/src/tools/lsp/services/LspRefreshManager.ts index 6642892b..7a740237 100644 --- a/src/web-ui/src/tools/lsp/services/LspRefreshManager.ts +++ b/src/web-ui/src/tools/lsp/services/LspRefreshManager.ts @@ -146,7 +146,7 @@ export class LspRefreshManager { tokenization.flushTokens(); return; } - } catch (e) { + } catch (_error) { // silent } } @@ -188,7 +188,7 @@ export class LspRefreshManager { const currentMarkers = monaco.editor.getModelMarkers({ resource: uri }); monaco.editor.setModelMarkers(model, 'lsp', currentMarkers); } - } catch (error) { + } catch (_error) { // silent } } @@ -281,4 +281,3 @@ export const lspRefreshManager = LspRefreshManager.getInstance(); if (typeof window !== 'undefined') { (window as any).lspRefreshManager = lspRefreshManager; } - diff --git a/src/web-ui/src/tools/lsp/services/MonacoLspAdapter.ts b/src/web-ui/src/tools/lsp/services/MonacoLspAdapter.ts index ddb628de..3657debf 100644 --- a/src/web-ui/src/tools/lsp/services/MonacoLspAdapter.ts +++ b/src/web-ui/src/tools/lsp/services/MonacoLspAdapter.ts @@ -1543,7 +1543,7 @@ export class MonacoLspAdapter { if (action) { action.run(); } - } catch (err) { + } catch (_error) { // silent } }); diff --git a/src/web-ui/src/tools/lsp/services/WorkspaceLspManager.ts b/src/web-ui/src/tools/lsp/services/WorkspaceLspManager.ts index 99e62984..32a89717 100644 --- a/src/web-ui/src/tools/lsp/services/WorkspaceLspManager.ts +++ b/src/web-ui/src/tools/lsp/services/WorkspaceLspManager.ts @@ -522,7 +522,7 @@ export class WorkspaceLspManager { if (callbacks && diagnostics) { callbacks.forEach(cb => cb(diagnostics)); } - } catch (error) { + } catch (_error) { } } diff --git a/src/web-ui/src/tools/mermaid-editor/components/MermaidEditor.tsx b/src/web-ui/src/tools/mermaid-editor/components/MermaidEditor.tsx index c8f57504..1cfac6b3 100644 --- a/src/web-ui/src/tools/mermaid-editor/components/MermaidEditor.tsx +++ b/src/web-ui/src/tools/mermaid-editor/components/MermaidEditor.tsx @@ -36,6 +36,7 @@ const detectDiagramType = (code: string): MermaidDiagramContext['diagramType'] = const SVG_NS = 'http://www.w3.org/2000/svg'; const XHTML_NS = 'http://www.w3.org/1999/xhtml'; +// eslint-disable-next-line no-control-regex -- Exported file names must strip ASCII control characters. const sanitizeFileName = (name: string) => name.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_').trim() || 'diagram'; const createTimestampSuffix = () => { @@ -245,7 +246,7 @@ export const MermaidEditor: React.FC = React.memo(({ } finally { setLoading(false); } - }, [isDirty, onSave, sourceCode, setLoading, setError]); + }, [isDirty, onSave, sourceCode, setLoading, setError, t]); const handleExport = useCallback(async (format: string) => { const loadingCtrl = notificationService.loading({ @@ -390,7 +391,7 @@ export const MermaidEditor: React.FC = React.memo(({ setIsFixing(false); setFixProgress({ current: 0, total: 0 }); } - }, [error, sourceCode, isFixing, setSourceCode, setError, onSave]); + }, [error, sourceCode, isFixing, setSourceCode, setError, onSave, t]); const handleToolbarSave = useCallback((editedData: any) => { if (floatingToolbar.type === 'node') { diff --git a/src/web-ui/src/tools/mermaid-editor/components/MermaidPreview.tsx b/src/web-ui/src/tools/mermaid-editor/components/MermaidPreview.tsx index 1b25d5d8..9c8947b1 100644 --- a/src/web-ui/src/tools/mermaid-editor/components/MermaidPreview.tsx +++ b/src/web-ui/src/tools/mermaid-editor/components/MermaidPreview.tsx @@ -306,7 +306,7 @@ export const MermaidPreview = React.memo(forwardRef { if (lastSourceCodeRef.current !== null && lastSourceCodeRef.current === sourceCode) { diff --git a/src/web-ui/src/tools/mermaid-editor/utils/mermaidHighlight.ts b/src/web-ui/src/tools/mermaid-editor/utils/mermaidHighlight.ts index 9444403d..737163b3 100644 --- a/src/web-ui/src/tools/mermaid-editor/utils/mermaidHighlight.ts +++ b/src/web-ui/src/tools/mermaid-editor/utils/mermaidHighlight.ts @@ -20,7 +20,7 @@ Prism.languages.mermaid = { alias: 'operator' }, 'node-shape': { - pattern: /[\[\](){}><]/, + pattern: /[[\](){}><]/, alias: 'punctuation' }, 'string': { diff --git a/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts b/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts index b15051bc..e2679244 100644 --- a/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts +++ b/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { SnapshotStateManager, SessionState, SnapshotFile } from '../core/SnapshotStateManager'; import { SnapshotEventBus, SNAPSHOT_EVENTS } from '../core/SnapshotEventBus'; @@ -40,7 +40,7 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => const stateManager = SnapshotStateManager.getInstance(); const eventBus = SnapshotEventBus.getInstance(); - const diffEngine = new DiffDisplayEngine(); + const diffEngine = useMemo(() => new DiffDisplayEngine(), []); const refreshSession = useCallback(async () => { if (!sessionId) return; @@ -227,7 +227,7 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => unsubscribeSession(); unsubscribeFile(); }; - }, [sessionId, stateManager]); + }, [sessionId, stateManager, refreshSession]); return { sessionState, diff --git a/src/web-ui/src/tools/terminal/components/ConnectedTerminal.tsx b/src/web-ui/src/tools/terminal/components/ConnectedTerminal.tsx index 5311720b..511d88fc 100644 --- a/src/web-ui/src/tools/terminal/components/ConnectedTerminal.tsx +++ b/src/web-ui/src/tools/terminal/components/ConnectedTerminal.tsx @@ -23,6 +23,7 @@ const MULTILINE_PASTE_THRESHOLD = 1; * ConPTY sends these after resize to reposition the cursor in its own coordinate * system, which diverges from xterm.js coordinates after history replay. */ +// eslint-disable-next-line no-control-regex -- ESC-based cursor reposition sequences are part of terminal protocol parsing. const CURSOR_POS_RE = /^\x1b\[(\d+);(\d+)H$/; export interface ConnectedTerminalProps { @@ -177,7 +178,7 @@ const ConnectedTerminal: React.FC = memo(({ setExitCode(code ?? null); setIsExited(true); onExit?.(code); - }, [sessionId, onExit]); + }, [onExit]); const handleError = useCallback((message: string) => { log.error('Terminal error', { sessionId, message }); @@ -212,7 +213,7 @@ const ConnectedTerminal: React.FC = memo(({ log.error('Write failed', { sessionId, error: err }); }); } - }, [write, isExited]); + }, [write, isExited, sessionId]); const handleResize = useCallback((cols: number, rows: number) => { const lastSize = lastSentSizeRef.current; @@ -238,7 +239,7 @@ const ConnectedTerminal: React.FC = memo(({ log.error('Resize failed', { sessionId, cols, rows, error: err }); lastSentSizeRef.current = null; }); - }, [resize]); + }, [resize, sessionId]); const handleTitleChange = useCallback((newTitle: string) => { setTitle(newTitle); @@ -301,7 +302,7 @@ const ConnectedTerminal: React.FC = memo(({ log.error('Failed to close', { sessionId, error: err }); }); onClose?.(); - }, [close, onClose]); + }, [close, onClose, sessionId]); const handleRetry = useCallback(() => { refresh().catch(err => { diff --git a/src/web-ui/src/tools/terminal/components/Terminal.tsx b/src/web-ui/src/tools/terminal/components/Terminal.tsx index e937df48..c27e165e 100644 --- a/src/web-ui/src/tools/terminal/components/Terminal.tsx +++ b/src/web-ui/src/tools/terminal/components/Terminal.tsx @@ -188,6 +188,13 @@ const Terminal = forwardRef(({ const isVisibleRef = useRef(true); const wasVisibleRef = useRef(false); const lastBackendSizeRef = useRef<{ cols: number; rows: number } | null>(null); + const autoFocusRef = useRef(autoFocus); + const onDataRef = useRef(onData); + const onBinaryRef = useRef(onBinary); + const onTitleChangeRef = useRef(onTitleChange); + const onResizeRef = useRef(onResize); + const onReadyRef = useRef(onReady); + const onPasteRef = useRef(onPaste); const [isReady, setIsReady] = useState(false); const currentTheme = themeService.getCurrentTheme(); const initialFontWeights = getXtermFontWeights(currentTheme.type); @@ -203,6 +210,18 @@ const Terminal = forwardRef(({ ...options.theme, }, }; + const mergedOptionsRef = useRef(mergedOptions); + const initialFontWeightsRef = useRef(initialFontWeights); + + autoFocusRef.current = autoFocus; + onDataRef.current = onData; + onBinaryRef.current = onBinary; + onTitleChangeRef.current = onTitleChange; + onResizeRef.current = onResize; + onReadyRef.current = onReady; + onPasteRef.current = onPaste; + mergedOptionsRef.current = mergedOptions; + initialFontWeightsRef.current = initialFontWeights; // Force refresh for rendering consistency. const forceRefresh = useCallback((terminal: XTerm) => { @@ -244,8 +263,8 @@ const Terminal = forwardRef(({ lastBackendSizeRef.current = { cols, rows }; - onResize?.(cols, rows); - }, [onResize]); + onResizeRef.current?.(cols, rows); + }, []); // Post-resize fixups (refresh and cursor visibility). const handleResizeComplete = useCallback(() => { @@ -308,6 +327,17 @@ const Terminal = forwardRef(({ forceRefresh(terminal); } }, [forceRefresh]); + const doXtermResizeRef = useRef(doXtermResize); + const doBackendResizeRef = useRef(doBackendResize); + const handleResizeCompleteRef = useRef(handleResizeComplete); + const fitRef = useRef(fit); + const forceRefreshRef = useRef(forceRefresh); + + doXtermResizeRef.current = doXtermResize; + doBackendResizeRef.current = doBackendResize; + handleResizeCompleteRef.current = handleResizeComplete; + fitRef.current = fit; + forceRefreshRef.current = forceRefresh; useImperativeHandle(ref, () => ({ write: (data: string) => { @@ -342,19 +372,20 @@ const Terminal = forwardRef(({ useEffect(() => { if (!containerRef.current) return; + const container = containerRef.current; // Let fit() determine size; backend starts at 80x24 and syncs via resize. const terminal = new XTerm({ - fontSize: mergedOptions.fontSize, - fontFamily: mergedOptions.fontFamily, - fontWeight: initialFontWeights.fontWeight, - fontWeightBold: initialFontWeights.fontWeightBold, - lineHeight: mergedOptions.lineHeight, - minimumContrastRatio: mergedOptions.minimumContrastRatio, - cursorStyle: mergedOptions.cursorStyle, - cursorBlink: mergedOptions.cursorBlink, - scrollback: mergedOptions.scrollback, - theme: mergedOptions.theme, + fontSize: mergedOptionsRef.current.fontSize, + fontFamily: mergedOptionsRef.current.fontFamily, + fontWeight: initialFontWeightsRef.current.fontWeight, + fontWeightBold: initialFontWeightsRef.current.fontWeightBold, + lineHeight: mergedOptionsRef.current.lineHeight, + minimumContrastRatio: mergedOptionsRef.current.minimumContrastRatio, + cursorStyle: mergedOptionsRef.current.cursorStyle, + cursorBlink: mergedOptionsRef.current.cursorBlink, + scrollback: mergedOptionsRef.current.scrollback, + theme: mergedOptionsRef.current.theme, // Keep the interactive terminal on the opaque WebGL path. Transparent // glyph atlases use a different blending/clearing strategy and are much // more prone to artifacts on colored cell backgrounds. @@ -401,7 +432,7 @@ const Terminal = forwardRef(({ terminal.loadAddon(fitAddon); terminal.loadAddon(webLinksAddon); - terminal.open(containerRef.current); + terminal.open(container); terminalRef.current = terminal; fitAddonRef.current = fitAddon; @@ -425,24 +456,24 @@ const Terminal = forwardRef(({ const resizeDebouncer = new TerminalResizeDebouncer({ getTerminal: () => terminalRef.current, isVisible: () => isVisibleRef.current, - onXtermResize: doXtermResize, - onBackendResize: doBackendResize, + onXtermResize: (cols, rows) => doXtermResizeRef.current(cols, rows), + onBackendResize: (cols, rows) => doBackendResizeRef.current(cols, rows), onFlush: () => { if (terminalRef.current) { - forceRefresh(terminalRef.current); + forceRefreshRef.current(terminalRef.current); } }, - onResizeComplete: handleResizeComplete, + onResizeComplete: () => handleResizeCompleteRef.current(), }); resizeDebouncerRef.current = resizeDebouncer; requestAnimationFrame(() => { - fit(true); + fitRef.current(true); setIsReady(true); - onReady?.(terminal); + onReadyRef.current?.(terminal); - if (autoFocus) { + if (autoFocusRef.current) { terminal.focus(); } }); @@ -460,11 +491,11 @@ const Terminal = forwardRef(({ if (!terminalRef.current) return; remeasureTerminal(terminalRef.current); - fit(true); + fitRef.current(true); requestAnimationFrame(() => { if (!terminalRef.current) return; - forceRefresh(terminalRef.current); + forceRefreshRef.current(terminalRef.current); scrollToBottomIfNeeded(terminalRef.current); }); }); @@ -473,15 +504,15 @@ const Terminal = forwardRef(({ } const dataDisposable = terminal.onData((data) => { - onData?.(data); + onDataRef.current?.(data); }); const binaryDisposable = terminal.onBinary((data) => { - onBinary?.(data); + onBinaryRef.current?.(data); }); const titleDisposable = terminal.onTitleChange((title) => { - onTitleChange?.(title); + onTitleChangeRef.current?.(title); }); // Intercept paste (Ctrl+V / Ctrl+Shift+V). @@ -494,14 +525,14 @@ const Terminal = forwardRef(({ const text = await navigator.clipboard.readText(); if (!text) return; - if (onPaste) { - const allowed = await onPaste(text); + if (onPasteRef.current) { + const allowed = await onPasteRef.current(text); if (!allowed) { return; } } - onData?.(text); + onDataRef.current?.(text); } catch (err) { log.error('Paste failed', err); } @@ -515,10 +546,10 @@ const Terminal = forwardRef(({ const resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { - fit(false); + fitRef.current(false); }); }); - resizeObserver.observe(containerRef.current); + resizeObserver.observe(container); resizeObserverRef.current = resizeObserver; // On visibility change, flush pending resize and refresh. @@ -532,7 +563,7 @@ const Terminal = forwardRef(({ requestAnimationFrame(() => { resizeDebouncerRef.current?.flush(); - fit(true); + fitRef.current(true); requestAnimationFrame(() => { const term = terminalRef.current; @@ -540,7 +571,7 @@ const Terminal = forwardRef(({ term.refresh(0, term.rows - 1); clearTextureAtlas(term); scrollToBottomIfNeeded(term); - if (autoFocus) { + if (autoFocusRef.current) { term.focus(); } } @@ -551,7 +582,7 @@ const Terminal = forwardRef(({ }, { threshold: 0.1 }); - intersectionObserver.observe(containerRef.current); + intersectionObserver.observe(container); intersectionObserverRef.current = intersectionObserver; return () => { @@ -596,6 +627,7 @@ const Terminal = forwardRef(({ mergedOptions.cursorStyle, mergedOptions.cursorBlink, mergedOptions.scrollback, + mergedOptions.theme, isReady, fit, ]); diff --git a/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx b/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx index ffa7afa7..4210908b 100644 --- a/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx +++ b/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx @@ -27,6 +27,7 @@ function normalizeAbsoluteCursorPositions(content: string): string { // Matches ESC [ ; H|f // e.g. ESC[14;35H ESC[18;1H ESC[5;1H ESC[H ESC[;1H + // eslint-disable-next-line no-control-regex -- ESC sequences are intentional terminal control codes. return content.replace(/\x1b\[\d*;?\d*[Hf]/g, '\r\n'); } diff --git a/src/web-ui/src/tools/terminal/hooks/useTerminal.ts b/src/web-ui/src/tools/terminal/hooks/useTerminal.ts index cdea9578..25085989 100644 --- a/src/web-ui/src/tools/terminal/hooks/useTerminal.ts +++ b/src/web-ui/src/tools/terminal/hooks/useTerminal.ts @@ -172,7 +172,7 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn { unsubscribeRef.current = null; } }; - }, [sessionId, autoConnect]); // Removed handleEvent from deps since it's stable + }, [sessionId, autoConnect, handleEvent]); const write = useCallback(async (data: string) => { try { @@ -234,4 +234,3 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn { refresh, }; } - diff --git a/src/web-ui/vite.config.ts b/src/web-ui/vite.config.ts index 2bda8e7d..96015dee 100644 --- a/src/web-ui/vite.config.ts +++ b/src/web-ui/vite.config.ts @@ -48,7 +48,9 @@ export default defineConfig(({ mode, command }) => { // 2. tauri expects a fixed port, fail if that port is not available server: { port: 1422, - strictPort: false, + // Tauri devUrl is fixed to http://localhost:1422. + // If Vite silently falls back to another port, the desktop webview stays blank. + strictPort: true, host: host || "localhost", hmr: { protocol: "ws",