refactor: organize frontend components into features/ directories

Moved 43 component files into 9 feature domains:
- features/git/ — commit-dialog, commit-panel, file-editor, git-mount-editor,
  git-toolbar, merge-dialog
- features/project/ — repositories-settings-tab, repository-create-dialog
- features/terminal/ — special-keys-panel, special-keys-strip,
  terminal-session-tabs, terminal
- features/workspace/ — workspace-card, workspace-create-form,
  workspace-header, workspace-instance-chips
- features/session/ — create-session-form, session-card, session-list
- features/tool/ — instance-list, manifest-editor, start-tool-fab,
  start-tool-modal, tool-starter, tools-bottom-sheet
- features/notification/ — event-toast-bridge, notification-center,
  notification-item
- features/settings/ — settings-tab-layout
- features/mobile/ — mobile-action-sheet, mobile-detail-view, mobile-edit-view,
  mobile-fab, mobile-list-view, mobile-nav, mobile-page-header,
  mobile-terminal-header, mobile-terminal-wrapper

Updated all imports across pages and components.
Root components/ now only contains generic UI pieces:
app-shell, code-editor, data-states, icon, protected-route, syntax-highlighter.

Quality gates: verified no remaining old imports.
This commit is contained in:
2026-06-04 12:37:24 +02:00
parent 7224afafd1
commit 1021d61be3
54 changed files with 31 additions and 31 deletions
@@ -0,0 +1,88 @@
import { useEffect, useRef } from "react";
import { Icon } from "./icon";
import type { IconName } from "./icon";
export interface MobileActionSheetItem {
id: string;
label: string;
icon?: IconName;
variant?: "default" | "danger";
onClick: () => void;
}
interface MobileActionSheetProps {
isOpen: boolean;
onClose: () => void;
title: string;
actions: MobileActionSheetItem[];
}
export function MobileActionSheet({
isOpen,
onClose,
title,
actions,
}: MobileActionSheetProps) {
const sheetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="mobile-action-sheet-overlay" onClick={onClose}>
<div
ref={sheetRef}
className="mobile-action-sheet"
onClick={(e) => e.stopPropagation()}
>
<div className="mobile-action-sheet-header">
<div className="mobile-action-sheet-handle" />
<h3>{title}</h3>
</div>
<div className="mobile-action-sheet-actions">
{actions.map((action) => (
<button
key={action.id}
className={`mobile-action-sheet-button ${action.variant || "default"}`}
onClick={() => {
action.onClick();
onClose();
}}
type="button"
>
{action.icon && <Icon name={action.icon} size="md" />}
<span>{action.label}</span>
</button>
))}
</div>
<button
className="mobile-action-sheet-cancel"
onClick={onClose}
type="button"
>
Cancel
</button>
</div>
</div>
);
}
@@ -0,0 +1,95 @@
import { Icon } from "./icon";
interface Field {
label: string;
value: string | number | boolean | null;
type?: "text" | "code" | "json" | "boolean";
}
interface MobileDetailViewProps {
title: string;
subtitle?: string;
fields: Field[];
onEdit: () => void;
onDelete: () => void;
onBack: () => void;
}
export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
title,
subtitle,
fields,
onEdit,
onDelete,
onBack,
}) => {
const renderValue = (field: Field) => {
if (field.value === null || field.value === undefined) {
return <span className="text-muted">Not set</span>;
}
if (field.type === "boolean") {
return field.value ? (
<span className="badge badge-success">Yes</span>
) : (
<span className="badge badge-secondary">No</span>
);
}
if (field.type === "code" || field.type === "json") {
return (
<pre className="mobile-detail-code">
{typeof field.value === "string" ? field.value : JSON.stringify(field.value, null, 2)}
</pre>
);
}
return <span>{String(field.value)}</span>;
};
return (
<div className="mobile-detail-view">
<header className="mobile-detail-header">
<button
className="mobile-detail-back"
onClick={onBack}
type="button"
aria-label="Go back"
>
<Icon name="arrow-left" size="md" />
</button>
<div className="mobile-detail-header-content">
<h1 className="mobile-detail-title">{title}</h1>
{subtitle && <p className="mobile-detail-subtitle">{subtitle}</p>}
</div>
<div className="mobile-detail-actions">
<button
className="mobile-detail-action"
onClick={onEdit}
type="button"
aria-label="Edit"
>
<Icon name="edit" size="sm" />
</button>
<button
className="mobile-detail-action mobile-detail-action-danger"
onClick={onDelete}
type="button"
aria-label="Delete"
>
<Icon name="delete" size="sm" />
</button>
</div>
</header>
<div className="mobile-detail-fields">
{fields.map((field, index) => (
<div key={index} className="mobile-detail-field">
<label className="mobile-detail-field-label">{field.label}</label>
<div className="mobile-detail-field-value">{renderValue(field)}</div>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,161 @@
import { useState } from "react";
interface FormField {
name: string;
label: string;
type: "text" | "textarea" | "number" | "select" | "checkbox" | "code";
value: string | number | boolean;
options?: { value: string; label: string }[];
placeholder?: string;
required?: boolean;
rows?: number;
}
interface MobileEditViewProps {
title: string;
fields?: FormField[];
onSave: (data: Record<string, string | number | boolean>) => void;
onCancel: () => void;
isSaving?: boolean;
children?: React.ReactNode;
}
export const MobileEditView: React.FC<MobileEditViewProps> = ({
title,
fields,
onSave,
onCancel,
isSaving = false,
children,
}) => {
const [formData, setFormData] = useState<Record<string, string | number | boolean>>(
() => {
const initial: Record<string, string | number | boolean> = {};
fields?.forEach((field) => {
initial[field.name] = field.value;
});
return initial;
}
);
const handleChange = (name: string, value: string | number | boolean) => {
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
return (
<div className="mobile-edit-view">
<header className="mobile-edit-header">
<button
className="mobile-edit-cancel"
onClick={onCancel}
type="button"
disabled={isSaving}
>
Cancel
</button>
<h1 className="mobile-edit-title">{title}</h1>
<button
className="mobile-edit-save"
onClick={() => onSave(formData)}
type="button"
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save"}
</button>
</header>
<form className="mobile-edit-form" onSubmit={handleSubmit}>
{children || fields?.map((field) => (
<div key={field.name} className="mobile-edit-field">
<label className="mobile-edit-field-label" htmlFor={field.name}>
{field.label}
{field.required && <span className="required">*</span>}
</label>
{field.type === "textarea" && (
<textarea
id={field.name}
name={field.name}
value={String(formData[field.name] ?? "")}
onChange={(e) => handleChange(field.name, e.target.value)}
placeholder={field.placeholder}
required={field.required}
rows={field.rows || 4}
className="mobile-edit-input mobile-edit-textarea"
/>
)}
{field.type === "select" && (
<select
id={field.name}
name={field.name}
value={String(formData[field.name] ?? "")}
onChange={(e) => handleChange(field.name, e.target.value)}
required={field.required}
className="mobile-edit-input"
>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
{field.type === "checkbox" && (
<label className="mobile-edit-checkbox">
<input
type="checkbox"
id={field.name}
name={field.name}
checked={Boolean(formData[field.name])}
onChange={(e) => handleChange(field.name, e.target.checked)}
/>
<span>{field.label}</span>
</label>
)}
{field.type === "code" && (
<textarea
id={field.name}
name={field.name}
value={String(formData[field.name] ?? "")}
onChange={(e) => handleChange(field.name, e.target.value)}
placeholder={field.placeholder}
required={field.required}
rows={field.rows || 8}
className="mobile-edit-input mobile-edit-code"
style={{ fontFamily: "monospace" }}
/>
)}
{(field.type === "text" || field.type === "number") && (
<input
type={field.type === "number" ? "number" : "text"}
id={field.name}
name={field.name}
value={String(formData[field.name] ?? "")}
onChange={(e) =>
handleChange(
field.name,
field.type === "number"
? Number(e.target.value)
: e.target.value
)
}
placeholder={field.placeholder}
required={field.required}
className="mobile-edit-input"
/>
)}
</div>
))}
</form>
</div>
);
};
@@ -0,0 +1,22 @@
import { Icon } from "./icon";
interface MobileFABProps {
onClick: () => void;
label?: string;
}
export const MobileFAB: React.FC<MobileFABProps> = ({
onClick,
label = "Create new",
}) => {
return (
<button
className="mobile-fab"
onClick={onClick}
type="button"
aria-label={label}
>
<Icon name="add" size="md" />
</button>
);
};
@@ -0,0 +1,76 @@
import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
interface MobileListItem {
id: string;
title: string;
subtitle?: string;
icon?: string;
status?: string;
}
interface MobileListViewProps {
items: MobileListItem[];
onItemClick: (id: string) => void;
onItemDelete?: (id: string) => void;
onItemDuplicate?: (id: string) => void;
emptyMessage?: string;
searchPlaceholder?: string;
onSearch?: (query: string) => void;
}
export const MobileListView: React.FC<MobileListViewProps> = ({
items,
onItemClick,
emptyMessage = "No items found",
searchPlaceholder = "Search...",
onSearch,
}) => {
return (
<div className="mobile-list-view">
{onSearch && (
<div className="mobile-list-search">
<input
type="search"
placeholder={searchPlaceholder}
onChange={(e) => onSearch(e.target.value)}
className="mobile-list-search-input"
/>
</div>
)}
{items.length === 0 ? (
<div className="mobile-list-empty">
<Icon name="folder" size="lg" />
<p>{emptyMessage}</p>
</div>
) : (
<div className="mobile-list-items">
{items.map((item) => (
<button
key={item.id}
className="mobile-list-item"
onClick={() => onItemClick(item.id)}
type="button"
>
{item.icon && (
<div className="mobile-list-item-icon">
<Icon name={item.icon as IconName} size="md" />
</div>
)}
<div className="mobile-list-item-content">
<div className="mobile-list-item-title">{item.title}</div>
{item.subtitle && (
<div className="mobile-list-item-subtitle">{item.subtitle}</div>
)}
</div>
<div className="mobile-list-item-actions" style={{ transform: "rotate(180deg)" }}>
<Icon name="arrow-left" size="sm" />
</div>
</button>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,87 @@
import { useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { Icon } from "./icon";
import { ToolsBottomSheet } from "./tools-bottom-sheet";
import type { IconName } from "../utils/icons";
interface MobileNavProps {
sessionCount?: number;
}
interface NavItem {
to: string;
label: string;
icon: IconName;
isGroup?: boolean;
}
const MOBILE_NAV_ITEMS: NavItem[] = [
{ to: "/", label: "Home", icon: "dashboard" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/sessions", label: "Sessions", icon: "terminal" },
{ to: "/tools", label: "Tools", icon: "settings", isGroup: true },
{ to: "/settings", label: "Settings", icon: "settings" },
];
export const MobileNav: React.FC<MobileNavProps> = ({ sessionCount }) => {
const location = useLocation();
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const isToolsActive =
location.pathname === "/tool-workshop" ||
location.pathname === "/config-profiles";
const handleNavClick = (item: NavItem) => {
if (item.isGroup) {
setToolsSheetOpen(true);
}
};
return (
<>
<nav className="mobile-nav" role="navigation" aria-label="Mobile navigation">
{MOBILE_NAV_ITEMS.map((item) => {
if (item.isGroup) {
return (
<button
key={item.to}
className={`mobile-nav-item ${isToolsActive ? "active" : ""}`}
onClick={() => handleNavClick(item)}
type="button"
>
<div className="mobile-nav-icon-wrapper">
<Icon name={item.icon} size="md" />
</div>
<span className="mobile-nav-label">{item.label}</span>
</button>
);
}
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`mobile-nav-item ${isActive ? "active" : ""}`
}
end={item.to === "/"}
>
<div className="mobile-nav-icon-wrapper">
<Icon name={item.icon} size="md" />
{item.to === "/sessions" && sessionCount ? (
<span className="mobile-nav-badge">{sessionCount}</span>
) : null}
</div>
<span className="mobile-nav-label">{item.label}</span>
</NavLink>
);
})}
</nav>
<ToolsBottomSheet
isOpen={toolsSheetOpen}
onClose={() => setToolsSheetOpen(false)}
/>
</>
);
};
@@ -0,0 +1,33 @@
import { useNavigate } from "react-router-dom";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { Icon } from "./icon";
interface MobilePageHeaderProps {
title: string;
showBack?: boolean;
actions?: React.ReactNode;
}
export function MobilePageHeader({ title, showBack = true, actions }: MobilePageHeaderProps) {
const navigate = useNavigate();
const isMobile = useMobileViewport();
if (!isMobile) return null;
return (
<div className="mobile-page-header">
{showBack && (
<button
className="mobile-page-header-back"
onClick={() => navigate(-1)}
type="button"
aria-label="Go back"
>
<Icon name="arrow-left" size="md" />
</button>
)}
<h1>{title}</h1>
{actions && <div className="mobile-page-header-actions">{actions}</div>}
</div>
);
}
@@ -0,0 +1,94 @@
import React from "react";
import { Icon } from "./icon";
interface MobileTerminalHeaderProps {
instanceName?: string;
onBack?: () => void;
onMenuToggle?: () => void;
onClose?: () => void;
onFontSizeChange?: (delta: number) => void;
isVisible: boolean;
connectionStatus?: "connecting" | "connected" | "disconnected" | "error" | "resetting";
}
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
instanceName,
onBack,
onMenuToggle,
onClose,
onFontSizeChange,
isVisible,
connectionStatus = "connecting",
}) => {
return (
<div
className={`mobile-terminal-header ${isVisible ? "visible" : "hidden"}`}
>
<div className="mobile-terminal-header-left">
{onBack && (
<button
className="mobile-terminal-header-button"
onClick={onBack}
type="button"
aria-label="Go back"
>
<Icon name="arrow-left" size="sm" />
</button>
)}
{onMenuToggle && (
<button
className="mobile-terminal-header-button"
onClick={onMenuToggle}
type="button"
aria-label="Toggle menu"
>
<Icon name="menu" size="sm" />
</button>
)}
</div>
<div className="mobile-terminal-header-center">
<span className="mobile-terminal-header-title">
{instanceName || "Terminal"}
</span>
<span
className={`mobile-terminal-header-status ${connectionStatus}`}
aria-label={`Connection status: ${connectionStatus}`}
/>
</div>
<div className="mobile-terminal-header-right">
{onFontSizeChange && (
<>
<button
className="mobile-terminal-header-button"
onClick={() => onFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
<span style={{ fontSize: "0.75rem" }}>A-</span>
</button>
<button
className="mobile-terminal-header-button"
onClick={() => onFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
<span style={{ fontSize: "1rem" }}>A+</span>
</button>
</>
)}
{onClose && (
<button
className="mobile-terminal-header-button"
onClick={onClose}
type="button"
aria-label="Close terminal"
>
<Icon name="close" size="sm" />
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,116 @@
import React, { useState, useCallback } from "react";
import { TerminalComponent } from "./terminal";
import { MobileTerminalHeader } from "./mobile-terminal-header";
import { SpecialKeysStrip } from "./special-keys-strip";
import { SpecialKeysPanel } from "./special-keys-panel";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
import { useAutoHide } from "../hooks/use-auto-hide";
import type { ModifierKey } from "../hooks/use-special-keys";
interface MobileTerminalWrapperProps {
instanceId: string;
instanceName?: string;
onClose?: () => void;
onBack?: () => void;
onMenuToggle?: () => void;
}
export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
instanceId,
instanceName,
onClose,
onBack,
onMenuToggle,
}) => {
const isMobile = useMobileViewport();
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const [showPanel, setShowPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
const [terminalRef, setTerminalRef] = useState<{
sendData: (data: string) => void;
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting";
focusInput: () => void;
changeFontSize: (delta: number) => void;
} | null>(null);
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const handleTerminalTap = useCallback(() => {
headerAutoHide.toggle();
}, [headerAutoHide]);
const handleTerminalReady = useCallback(
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void) => {
setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
},
[]
);
const handleSendKey = useCallback(
(data: string) => {
terminalRef?.sendData(data);
},
[terminalRef]
);
if (!isMobile) {
return (
<TerminalComponent
instanceId={instanceId}
onClose={onClose}
isMobile={false}
/>
);
}
return (
<div className="mobile-terminal-wrapper">
<MobileTerminalHeader
instanceName={instanceName}
onBack={onBack}
onMenuToggle={onMenuToggle}
onClose={onClose}
onFontSizeChange={(delta) => terminalRef?.changeFontSize(delta)}
isVisible={headerAutoHide.isVisible}
connectionStatus={terminalRef?.connectionStatus}
/>
<div
className="mobile-terminal-content"
style={{
paddingBottom: isKeyboardOpen ? keyboardHeight : 0,
}}
onClick={handleTerminalTap}
>
<TerminalComponent
instanceId={instanceId}
onClose={onClose}
isMobile={true}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
onTerminalReady={handleTerminalReady}
/>
</div>
<SpecialKeysStrip
onSend={handleSendKey}
isVisible={!showPanel}
onMoreClick={() => setShowPanel(true)}
onKeepFocus={() => terminalRef?.focusInput()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
<SpecialKeysPanel
onSend={handleSendKey}
isOpen={showPanel}
onClose={() => setShowPanel(false)}
onKeepFocus={() => terminalRef?.focusInput()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
</div>
);
};