refactor: decompose large frontend components into focused modules

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>
This commit is contained in:
David Kaya
2026-03-25 20:12:36 +01:00
co-authored by Copilot
parent 1bd0fcba3e
commit fcfc5c9641
24 changed files with 1209 additions and 1157 deletions
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import type { AppearanceTheme } from '@shared/domain/tooling';
/** Resolves the effective theme and applies it to the document root. */
export function useTheme(themeSetting: AppearanceTheme) {
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]);
}
/** Manages sidecar capabilities loading and refresh. */
export function useSidecarCapabilities(api: {
describeSidecarCapabilities: () => Promise<import('@shared/contracts/sidecar').SidecarCapabilities>;
refreshSidecarCapabilities: () => Promise<import('@shared/contracts/sidecar').SidecarCapabilities>;
}) {
const [capabilities, setCapabilities] = useState<import('@shared/contracts/sidecar').SidecarCapabilities>();
const [isRefreshing, setIsRefreshing] = useState(false);
useEffect(() => {
let disposed = false;
void api
.describeSidecarCapabilities()
.then((c) => !disposed && setCapabilities(c))
.catch((e) => {
if (!disposed) console.warn('Failed to load sidecar capabilities', e);
});
return () => { disposed = true; };
}, [api]);
async function refresh() {
setIsRefreshing(true);
try {
const c = await api.refreshSidecarCapabilities();
setCapabilities(c);
} finally {
setIsRefreshing(false);
}
}
return { capabilities, isRefreshing, refresh };
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useRef, type RefObject } from 'react';
/**
* Calls `onClose` when a click lands outside the referenced element.
* Only attaches the listener while `active` is true.
*/
export function useClickOutside<T extends HTMLElement>(
onClose: () => void,
active: boolean,
): RefObject<T | null> {
const ref = useRef<T | null>(null);
useEffect(() => {
if (!active) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [active, onClose]);
return ref;
}