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
@@ -0,0 +1,92 @@
import { FormField, InfoCallout, TextareaInput, TextInput } from '@renderer/components/ui';
import { joinMultiline, splitMultiline, splitTokens, updateLspProfile } from '@renderer/lib/settingsHelpers';
import { validateLspProfileDefinition, type LspProfileDefinition } from '@shared/domain/tooling';
import { ToolingEditorShell } from './ToolingEditorShell';
export function LspProfileEditor({
profile,
onChange,
onBack,
onSave,
onDelete,
}: {
profile: LspProfileDefinition;
onChange: (profile: LspProfileDefinition) => void;
onBack: () => void;
onSave: () => Promise<void>;
onDelete?: () => Promise<void>;
}) {
const validationError = validateLspProfileDefinition(profile);
return (
<ToolingEditorShell
disableSave={Boolean(validationError)}
error={validationError}
onBack={onBack}
onDelete={onDelete}
onSave={onSave}
subtitle="Machine-wide language server definition"
title={profile.name || 'Untitled LSP Profile'}
>
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
General
</h4>
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Name" required>
<TextInput
onChange={(value) => onChange(updateLspProfile(profile, { name: value }))}
value={profile.name}
/>
</FormField>
<FormField label="Language ID" required>
<TextInput
onChange={(value) => onChange(updateLspProfile(profile, { languageId: value }))}
placeholder="typescript"
value={profile.languageId}
/>
</FormField>
</div>
</section>
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
Server
</h4>
<FormField label="Command" required>
<TextInput
onChange={(value) => onChange(updateLspProfile(profile, { command: value }))}
placeholder="typescript-language-server"
value={profile.command}
/>
</FormField>
<FormField label="Arguments">
<TextareaInput
onChange={(value) => onChange(updateLspProfile(profile, { args: splitMultiline(value) }))}
placeholder="One argument per line"
rows={3}
value={joinMultiline(profile.args)}
/>
</FormField>
</section>
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
File matching
</h4>
<FormField label="File extensions" required>
<TextareaInput
onChange={(value) => onChange(updateLspProfile(profile, { fileExtensions: splitTokens(value) }))}
placeholder={'.ts\n.tsx'}
rows={3}
value={joinMultiline(profile.fileExtensions)}
/>
</FormField>
</section>
<InfoCallout>
Project root resolution comes from the active session's project, not from this definition.
</InfoCallout>
</ToolingEditorShell>
);
}
@@ -0,0 +1,131 @@
import { FormField, InfoCallout, SelectInput, TextareaInput, TextInput } from '@renderer/components/ui';
import { changeMcpTransport, joinMultiline, splitMultiline, splitTokens, updateMcpServer } from '@renderer/lib/settingsHelpers';
import { validateMcpServerDefinition, type McpServerDefinition } from '@shared/domain/tooling';
import { ToolingEditorShell } from './ToolingEditorShell';
export function McpServerEditor({
server,
onChange,
onBack,
onSave,
onDelete,
}: {
server: McpServerDefinition;
onChange: (server: McpServerDefinition) => void;
onBack: () => void;
onSave: () => Promise<void>;
onDelete?: () => Promise<void>;
}) {
const validationError = validateMcpServerDefinition(server);
return (
<ToolingEditorShell
disableSave={Boolean(validationError)}
error={validationError}
onBack={onBack}
onDelete={onDelete}
onSave={onSave}
subtitle="Machine-wide server definition"
title={server.name || 'Untitled MCP Server'}
>
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
General
</h4>
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Name" required>
<TextInput
onChange={(value) => onChange(updateMcpServer(server, { name: value }))}
value={server.name}
/>
</FormField>
<FormField label="Transport" required>
<SelectInput
onChange={(value) => onChange(changeMcpTransport(server, value as McpServerDefinition['transport']))}
options={[
{ value: 'local', label: 'Local process' },
{ value: 'http', label: 'HTTP' },
{ value: 'sse', label: 'SSE' },
]}
value={server.transport}
/>
</FormField>
</div>
</section>
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
{server.transport === 'local' ? 'Process' : 'Endpoint'}
</h4>
{server.transport === 'local' ? (
<>
<FormField label="Command" required>
<TextInput
onChange={(value) => onChange(updateMcpServer(server, { command: value }))}
placeholder="node"
value={server.command}
/>
</FormField>
<FormField label="Arguments">
<TextareaInput
onChange={(value) => onChange(updateMcpServer(server, { args: splitMultiline(value) }))}
placeholder="One argument per line"
rows={3}
value={joinMultiline(server.args)}
/>
</FormField>
<FormField label="Working directory">
<TextInput
onChange={(value) => onChange(updateMcpServer(server, { cwd: value || undefined }))}
placeholder="Optional — defaults to project root"
value={server.cwd ?? ''}
/>
</FormField>
</>
) : (
<FormField label="Server URL" required>
<TextInput
onChange={(value) => onChange(updateMcpServer(server, { url: value }))}
placeholder="https://example.com/mcp"
value={server.url}
/>
</FormField>
)}
</section>
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
Advanced
</h4>
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Allowed tools">
<TextareaInput
onChange={(value) => onChange(updateMcpServer(server, { tools: splitTokens(value) }))}
placeholder="* for all, or one per line"
rows={3}
value={joinMultiline(server.tools)}
/>
</FormField>
<FormField label="Timeout (ms)">
<TextInput
inputMode="numeric"
onChange={(value) =>
onChange(
updateMcpServer(server, {
timeoutMs: value.trim() ? Number(value) : undefined,
}),
)
}
placeholder="Optional"
value={server.timeoutMs?.toString() ?? ''}
/>
</FormField>
</div>
</section>
<InfoCallout>
Keep secrets out of this form. Use commands or endpoints that authenticate through the OS or external tooling.
</InfoCallout>
</ToolingEditorShell>
);
}
@@ -0,0 +1,74 @@
import { AlertCircle, ChevronLeft, Trash } from 'lucide-react';
import type { ReactNode } from 'react';
export function ToolingEditorShell({
title,
subtitle,
error,
disableSave,
onBack,
onSave,
onDelete,
children,
}: {
title: string;
subtitle: string;
error?: string;
disableSave: boolean;
onBack: () => void;
onSave: () => Promise<void>;
onDelete?: () => Promise<void>;
children: ReactNode;
}) {
return (
<div className="flex h-full flex-col">
<div className="drag-region flex items-center justify-between border-b border-[var(--color-border)] px-5 pb-3 pt-3">
<div className="flex items-center gap-3">
<button
className="no-drag flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
onClick={onBack}
type="button"
>
<ChevronLeft className="size-4" />
</button>
<div>
<h2 className="text-[13px] font-semibold text-zinc-100">{title}</h2>
<p className="text-[12px] text-zinc-500">{subtitle}</p>
</div>
</div>
<div className="no-drag flex items-center gap-2">
{onDelete && (
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-red-400 transition hover:bg-red-500/10"
onClick={() => void onDelete()}
type="button"
>
<Trash className="size-3.5" />
Delete
</button>
)}
<button
className="rounded-lg bg-indigo-600 px-4 py-1.5 text-[13px] font-medium text-white transition hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
disabled={disableSave}
onClick={() => void onSave()}
type="button"
>
Save
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
<div className="mx-auto max-w-2xl space-y-8">
{error && (
<div className="flex items-start gap-2 rounded-lg bg-amber-500/10 px-3 py-2 text-[13px] text-amber-300">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
{error}
</div>
)}
{children}
</div>
</div>
</div>
);
}