From fcfc5c96412c795154d286e4d6b75020eb369842 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 25 Mar 2026 20:12:36 +0100 Subject: [PATCH] refactor: decompose large frontend components into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split six monolithic component files into smaller, single-responsibility modules organized by feature domain: - ChatPane (1054→~250 lines): extract InlinePills, ApprovalBanner, ThinkingDots into chat/ directory - SettingsPanel (1027→~280 lines): extract McpServerEditor, LspProfileEditor, ToolingEditorShell into settings/ directory; mutation helpers into lib/settingsHelpers.ts - PatternEditor: use shared ToggleSwitch from ui/ - App.tsx: extract useTheme and useSidecarCapabilities into hooks/ - Sidebar: extracted accessibility improvements inline New shared primitives: - hooks/useClickOutside: replaces 5 duplicated click-outside listeners - components/ui/: ToggleSwitch, PopoverToggleRow, FormField, TextInput, TextareaInput, SelectInput, InfoCallout Accessibility improvements: - NewSessionModal: role=dialog, aria-modal, Escape-to-close - Pill dropdowns: aria-expanded, aria-haspopup, role=listbox/option - Sidebar context menu: role=menu/menuitem, Escape-to-close - SessionItem: Space key activation alongside Enter - ToolbarButton: aria-pressed for toggle state - ApprovalBanner: role=alert - QueuedApprovalsList: aria-expanded on toggle - ThinkingDots: aria-label No behavioral changes. All 137 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/App.tsx | 46 +- src/renderer/components/ChatPane.tsx | 633 +----------------- src/renderer/components/MarkdownComposer.tsx | 1 + src/renderer/components/NewSessionModal.tsx | 15 +- src/renderer/components/PatternEditor.tsx | 19 +- src/renderer/components/SettingsPanel.tsx | 470 +------------ src/renderer/components/Sidebar.tsx | 6 +- .../components/chat/ApprovalBanner.tsx | 154 +++++ src/renderer/components/chat/InlinePills.tsx | 408 +++++++++++ src/renderer/components/chat/ThinkingDots.tsx | 9 + .../components/settings/LspProfileEditor.tsx | 92 +++ .../components/settings/McpServerEditor.tsx | 131 ++++ .../settings/ToolingEditorShell.tsx | 74 ++ src/renderer/components/ui/FormField.tsx | 21 + src/renderer/components/ui/InfoCallout.tsx | 11 + .../components/ui/PopoverToggleRow.tsx | 24 + src/renderer/components/ui/SelectInput.tsx | 23 + src/renderer/components/ui/TextInput.tsx | 23 + src/renderer/components/ui/TextareaInput.tsx | 21 + src/renderer/components/ui/ToggleSwitch.tsx | 24 + src/renderer/components/ui/index.ts | 9 + src/renderer/hooks/useAppHooks.ts | 58 ++ src/renderer/hooks/useClickOutside.ts | 27 + src/renderer/lib/settingsHelpers.ts | 67 ++ 24 files changed, 1209 insertions(+), 1157 deletions(-) create mode 100644 src/renderer/components/chat/ApprovalBanner.tsx create mode 100644 src/renderer/components/chat/InlinePills.tsx create mode 100644 src/renderer/components/chat/ThinkingDots.tsx create mode 100644 src/renderer/components/settings/LspProfileEditor.tsx create mode 100644 src/renderer/components/settings/McpServerEditor.tsx create mode 100644 src/renderer/components/settings/ToolingEditorShell.tsx create mode 100644 src/renderer/components/ui/FormField.tsx create mode 100644 src/renderer/components/ui/InfoCallout.tsx create mode 100644 src/renderer/components/ui/PopoverToggleRow.tsx create mode 100644 src/renderer/components/ui/SelectInput.tsx create mode 100644 src/renderer/components/ui/TextInput.tsx create mode 100644 src/renderer/components/ui/TextareaInput.tsx create mode 100644 src/renderer/components/ui/ToggleSwitch.tsx create mode 100644 src/renderer/components/ui/index.ts create mode 100644 src/renderer/hooks/useAppHooks.ts create mode 100644 src/renderer/hooks/useClickOutside.ts create mode 100644 src/renderer/lib/settingsHelpers.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7ca18df..1ee4815 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -14,7 +14,7 @@ import { import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace'; import { WelcomePane } from '@renderer/components/WelcomePane'; import { getElectronApi } from '@renderer/lib/electronApi'; -import type { SidecarCapabilities } from '@shared/contracts/sidecar'; +import { useTheme, useSidecarCapabilities } from '@renderer/hooks/useAppHooks'; import { buildAvailableModelCatalog, findModel, @@ -84,9 +84,8 @@ export default function App() { const api = getElectronApi(); const [workspace, setWorkspace] = useState(); const [error, setError] = useState(); - const [sidecarCapabilities, setSidecarCapabilities] = useState(); + const { capabilities: sidecarCapabilities, isRefreshing: isRefreshingCapabilities, refresh: refreshCapabilities } = useSidecarCapabilities(api); const [sessionActivities, setSessionActivities] = useState({}); - const [isRefreshingCapabilities, setIsRefreshingCapabilities] = useState(false); const [showSettings, setShowSettings] = useState(false); const [newSessionProjectId, setNewSessionProjectId] = useState(); @@ -99,14 +98,6 @@ export default function App() { .loadWorkspace() .then((ws) => !disposed && setWorkspace(ws)) .catch((e) => !disposed && setError(e instanceof Error ? e.message : String(e))); - void api - .describeSidecarCapabilities() - .then((capabilities) => !disposed && setSidecarCapabilities(capabilities)) - .catch((e) => { - if (!disposed) { - console.warn('Failed to load sidecar capabilities', e); - } - }); const offWorkspace = api.onWorkspaceUpdated((ws) => { setWorkspace(ws); @@ -133,24 +124,7 @@ export default function App() { // Apply theme to the document root const themeSetting: AppearanceTheme = workspace?.settings.theme ?? 'dark'; - useEffect(() => { - function resolveEffective(pref: AppearanceTheme): 'dark' | 'light' { - if (pref === 'dark' || pref === 'light') return pref; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - } - - const apply = () => { - document.documentElement.dataset.theme = resolveEffective(themeSetting); - }; - - apply(); - - if (themeSetting === 'system') { - const mq = window.matchMedia('(prefers-color-scheme: dark)'); - mq.addEventListener('change', apply); - return () => mq.removeEventListener('change', apply); - } - }, [themeSetting]); + useTheme(themeSetting); // Derived state const selectedSession = useMemo( @@ -212,17 +186,7 @@ export default function App() { ); } - const refreshCapabilities = async () => { - setIsRefreshingCapabilities(true); - try { - const capabilities = await api.refreshSidecarCapabilities(); - setSidecarCapabilities(capabilities); - } finally { - setIsRefreshingCapabilities(false); - } - }; - - const handleCreateScratchpad = () => { + const handleCreateScratchpad = useCallback(() => { const singlePatterns = workspace.patterns .filter((p) => p.mode === 'single' && p.availability !== 'unavailable') .sort((a, b) => { @@ -235,7 +199,7 @@ export default function App() { if (defaultPattern) { void api.createSession({ projectId: SCRATCHPAD_PROJECT_ID, patternId: defaultPattern.id }); } - }; + }, [api, workspace?.patterns]); // Determine main content let content: React.ReactNode; diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 628a155..75ec079 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -1,650 +1,29 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { AlertCircle, ArrowUp, Bot, Check, ChevronDown, Circle, GitBranch, Loader2, RotateCcw, Server, ShieldAlert, ShieldCheck, Sparkles, User, X } from 'lucide-react'; +import { AlertCircle, ArrowUp, Bot, Circle, GitBranch, Loader2, ShieldAlert, User } from 'lucide-react'; import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer'; +import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/ApprovalBanner'; +import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills'; +import { ThinkingDots } from '@renderer/components/chat/ThinkingDots'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; -import { ProviderIcon } from '@renderer/components/ProviderIcons'; -import type { ApprovalDecision, PendingApprovalRecord } from '@shared/domain/approval'; +import type { ApprovalDecision } from '@shared/domain/approval'; import { findModel, getSupportedReasoningEfforts, - inferProvider, - providerMeta, resolveReasoningEffort, type ModelDefinition, } from '@shared/domain/models'; -import { reasoningEffortOptions, type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern'; +import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern'; import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; import { resolveSessionToolingSelection, type SessionRecord } from '@shared/domain/session'; import { listApprovalToolDefinitions, - type ApprovalToolDefinition, - type ApprovalToolKind, - type LspProfileDefinition, - type McpServerDefinition, type RuntimeToolDefinition, type SessionToolingSelection, type WorkspaceToolingSettings, } from '@shared/domain/tooling'; -function ThinkingDots() { - return ( -
- - - -
- ); -} - -/* ── Tier badge for model dropdown ─────────────────────────── */ - -function TierBadge({ tier }: { tier: ModelDefinition['tier'] }) { - if (!tier) return null; - const styles = { - premium: 'bg-amber-500/10 text-amber-400', - standard: 'bg-zinc-700/50 text-zinc-500', - fast: 'bg-emerald-500/10 text-emerald-400', - }; - return ( - - {tier} - - ); -} - -/* ── Inline model pill with dropdown ───────────────────────── */ - -function InlineModelPill({ - value, - models, - onChange, - disabled, -}: { - value: string; - models: ReadonlyArray; - onChange: (model: string) => void; - disabled: boolean; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - } - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [open]); - - const selected = findModel(value, models); - const provider = selected?.provider ?? inferProvider(value); - const displayName = selected?.name ?? value ?? 'Model'; - - const groupedModels = providerMeta - .map((pg) => ({ ...pg, models: models.filter((m) => m.provider === pg.id) })) - .filter((pg) => pg.models.length > 0); - const otherModels = models.filter((m) => !m.provider); - - return ( -
- - - {open && !disabled && ( -
- {groupedModels.map((pg) => ( -
-
- - - {pg.label} - -
- {pg.models.map((model) => ( - - ))} -
- ))} - {otherModels.length > 0 && ( -
-
- Other -
- {otherModels.map((model) => ( - - ))} -
- )} -
- )} -
- ); -} - -/* ── Inline thinking effort pill with dropdown ─────────────── */ - -function InlineThinkingPill({ - value, - supportedEfforts, - onChange, - disabled, -}: { - value?: ReasoningEffort; - supportedEfforts?: ReadonlyArray; - onChange: (effort: ReasoningEffort) => void; - disabled: boolean; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - } - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [open]); - - const options = supportedEfforts - ? reasoningEffortOptions.filter((o) => supportedEfforts.includes(o.value)) - : [...reasoningEffortOptions]; - - if (supportedEfforts && supportedEfforts.length === 0) { - return ( - - - N/A - - ); - } - - const currentLabel = options.find((o) => o.value === value)?.label ?? value ?? 'Thinking'; - - return ( -
- - - {open && !disabled && ( -
- {options.map((option) => ( - - ))} -
- )} -
- ); -} - -/* ── Inline tools pill with popover ─────────────────────────── */ - -function InlineToolsPill({ - mcpServers, - lspProfiles, - selection, - disabled, - onToggle, -}: { - mcpServers: ReadonlyArray; - lspProfiles: ReadonlyArray; - selection: SessionToolingSelection; - disabled: boolean; - onToggle: (selection: SessionToolingSelection) => void; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - } - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [open]); - - const enabledCount = selection.enabledMcpServerIds.length + selection.enabledLspProfileIds.length; - const totalCount = mcpServers.length + lspProfiles.length; - - return ( -
- - - {open && !disabled && ( -
- {mcpServers.length > 0 && ( -
-
- MCP Servers -
- {mcpServers.map((server) => ( - - onToggle({ - ...selection, - enabledMcpServerIds: toggleInArray(selection.enabledMcpServerIds, server.id), - }) - } - /> - ))} -
- )} - {lspProfiles.length > 0 && ( -
-
- Language Servers -
- {lspProfiles.map((profile) => ( - - onToggle({ - ...selection, - enabledLspProfileIds: toggleInArray(selection.enabledLspProfileIds, profile.id), - }) - } - /> - ))} -
- )} -
- )} -
- ); -} - -/* ── Inline auto-approval pill with popover ────────────────── */ - -const approvalKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed']; -const approvalKindLabels: Record = { - builtin: 'Built-in', - mcp: 'MCP Servers', - lsp: 'Language Servers', - mixed: 'Other', -}; - -function InlineApprovalPill({ - approvalTools, - effectiveAutoApproved, - isOverridden, - disabled, - onUpdate, -}: { - approvalTools: ApprovalToolDefinition[]; - effectiveAutoApproved: Set; - isOverridden: boolean; - disabled: boolean; - onUpdate: (settings: { autoApprovedToolNames?: string[] }) => void; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - } - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [open]); - - function toggleTool(toolId: string) { - const next = new Set(effectiveAutoApproved); - if (next.has(toolId)) { - next.delete(toolId); - } else { - next.add(toolId); - } - onUpdate({ autoApprovedToolNames: [...next] }); - } - - const groups = approvalKindOrder - .map((kind) => ({ kind, tools: approvalTools.filter((t) => t.kind === kind) })) - .filter((g) => g.tools.length > 0); - const showHeaders = groups.length > 1; - - return ( -
- - - {open && !disabled && ( -
- {/* Override state + reset */} -
- - {isOverridden ? 'Session override' : 'Pattern defaults'} - - {isOverridden && ( - - )} -
- - {/* Tool list grouped by kind */} -
- {groups.map((group, i) => ( -
- {showHeaders && ( -
0 ? 'pt-2' : 'pt-1'} text-[9px] font-semibold uppercase tracking-wider text-zinc-600`}> - {approvalKindLabels[group.kind]} -
- )} - {group.tools.map((tool) => { - const detail = tool.description || (tool.providerNames.length > 0 ? tool.providerNames.join(', ') : undefined); - return ( - toggleTool(tool.id)} - /> - ); - })} -
- ))} -
-
- )} -
- ); -} - -/* ── Shared popover toggle row + helpers ───────────────────── */ - -function PopoverToggleRow({ - label, - detail, - enabled, - onToggle, -}: { - label: string; - detail?: string; - enabled: boolean; - onToggle: () => void; -}) { - return ( - - ); -} - -function toggleInArray(current: string[], id: string): string[] { - return current.includes(id) - ? current.filter((currentId) => currentId !== id) - : [...current, id]; -} - -/* ── Approval banner ────────────────────────────────────────── */ - -function ApprovalBanner({ - approval, - onResolve, - isResolving, - position, - total, -}: { - approval: PendingApprovalRecord; - onResolve: (decision: ApprovalDecision) => void; - isResolving: boolean; - position?: number; - total?: number; -}) { - const kindLabel = approval.kind === 'final-response' ? 'Final response review' : 'Tool call approval'; - const hasMessages = approval.messages && approval.messages.length > 0; - const showPosition = position !== undefined && total !== undefined && total > 1; - - return ( -
- {/* Header */} -
- -
-
- {approval.title} - - {kindLabel} - - {showPosition && ( - - {position} of {total} - - )} -
- - {/* Agent / permission context */} -
- {approval.agentName && Agent: {approval.agentName}} - {approval.toolName && Tool: {approval.toolName}} - {approval.permissionKind && Permission: {approval.permissionKind}} -
- - {approval.detail && ( -

{approval.detail}

- )} -
-
- - {/* Final-response message preview */} - {hasMessages && ( -
-

- Pending messages — not yet published -

- {approval.messages!.map((message) => ( -
-
- - {message.authorName} -
-
- -
-
- ))} -
- )} - - {/* Actions */} -
- - - {showPosition && ( - - Next approval will appear after this one is resolved - - )} -
-
- ); -} - -/* ── Queued approvals preview ──────────────────────────────── */ - -function QueuedApprovalsList({ approvals }: { approvals: PendingApprovalRecord[] }) { - const [expanded, setExpanded] = useState(false); - - return ( -
- - {expanded && ( -
- {approvals.map((approval) => { - const kindLabel = approval.kind === 'final-response' ? 'response' : 'tool'; - return ( -
- - {approval.title} - - {kindLabel} - - {approval.toolName && ( - {approval.toolName} - )} - {approval.agentName && ( - {approval.agentName} - )} -
- ); - })} -
- )} -
- ); -} - /* ── ChatPane ──────────────────────────────────────────────── */ interface ChatPaneProps { diff --git a/src/renderer/components/MarkdownComposer.tsx b/src/renderer/components/MarkdownComposer.tsx index 59b2f20..0e4d04f 100644 --- a/src/renderer/components/MarkdownComposer.tsx +++ b/src/renderer/components/MarkdownComposer.tsx @@ -756,6 +756,7 @@ function ToolbarButton({ }) { return ( -
-

{title}

-

{subtitle}

-
- -
- {onDelete && ( - - )} - -
- - -
-
- {error && ( -
- - {error} -
- )} - {children} -
-
- - ); -} - -function FormField({ - label, - required, - children, -}: { - label: string; - required?: boolean; - children: ReactNode; -}) { - return ( - - ); -} - -function TextInput({ - value, - onChange, - placeholder, - inputMode, -}: { - value: string; - onChange: (value: string) => void; - placeholder?: string; - inputMode?: HTMLAttributes['inputMode']; -}) { - return ( - onChange(event.target.value)} - placeholder={placeholder} - value={value} - /> - ); -} - -function TextareaInput({ - value, - onChange, - placeholder, - rows, -}: { - value: string; - onChange: (value: string) => void; - placeholder?: string; - rows: number; -}) { - return ( -