Compare commits

...

7 Commits

Author SHA1 Message Date
Developer 7070867393 docs: update reorganize-long-files spec to include CSS reorganization
Add Phase 2 (CSS Reorganization) covering:
- Restore styles/ directory (tokens, global, utilities, syntax-highlight)
- Restore styles/pages/*.css for page-specific styles
- Restore 11 component CSS modules from monolithic styles.css
- Delete styles.css after extraction

Shift frontend page extraction phases to 3-7.
Add visual regression checks to integration phase.

Quality gates unchanged: tsc, build, py_compile, file size limits
2026-06-05 10:33:10 +00:00
Developer d472c41092 docs: add OpenSpec spec for reorganize-long-files
Create SDD proposal, spec, and tasks for splitting monolithic pages
and routers into focused components and services.

Targets:
- Frontend pages: 50-150 lines max (from 300-1600)
- Backend routers: 300-400 lines max (from 800-2900)
- Follow main branch pattern: thin pages + extracted components

Quality gates: tsc, build, py_compile, file size limits
2026-06-05 10:19:27 +00:00
Developer a9e2dd3552 fix: complete refactoring integration — rename remaining snake_case API files and fix test imports
During refactoring verification found several remaining inconsistencies:

API files (kebab-case naming):
- Rename config_profiles.ts → config-profiles.ts
- Rename tool_definitions.ts → tool-definitions.ts
- Update all imports across 8 files

Missing Python __init__.py (backend package structure):
- Add utils/__init__.py
- Add services/__init__.py
- Add schemas/__init__.py
- Add services/build/__init__.py

Test file import fixes (component reorganization fallout):
- DashboardPage.test.tsx: import from ./dashboard → ./DashboardPage
- ProjectsPage.test.tsx: import from ./projects → ./ProjectsPage
- event-toast-bridge.test.tsx: fix relative paths for moved components
  (../state/events → ../../../state/events, ./toast-rules → ../../toast-rules)
- notification-center.test.tsx: fix relative path
  (../state/notifications → ../../../state/notifications)

Quality gates: tsc --noEmit (pass), build (pass), py_compile (pass)
Tests: 9/12 test files pass (3 pre-existing UI test failures unrelated to refactoring)
2026-06-05 09:30:44 +00:00
Developer 6553a8845b fix: disable WebGL renderer to fix black-on-black text in tmux
The xterm.js WebGL addon has known rendering bugs with reverse-video
(inverse color) ANSI sequences — exactly what tmux uses for its status
bar, pane borders, and selected text. On desktop the WebGL addon loaded
successfully, causing characters to render as black-on-black and appear
to 'disappear'. On mobile WebGL typically fails to initialize, so the
terminal silently fell back to the DOM renderer which handles these
color attributes correctly.

- Remove WebGL addon loading and its cleanup logic
- Remove unused xterm-addon-webgl import and dependency
- DOM renderer is the default and correctly handles all ANSI color
  attributes including reverse video

Quality gates: tsc --noEmit (pass), build (pass), bundle -100KB
Refs: xterm.js WebGL reverse-video / minimumContrastRatio issues
2026-06-05 09:04:20 +00:00
Developer 994b1cf3b7 feat: make notification center mobile friendly
- Use useMobileViewport to detect mobile and position dropdown
  centered with left/right margins instead of right-aligned, which
  caused overflow on small screens.
- Add a semi-transparent backdrop overlay on mobile so tapping
  outside the dropdown naturally closes it.
- Update mobile CSS: notification-dropdown fills screen width
  with 0.75rem margins, max-height capped at 70vh for reachability.
- Remove the 360px max-width cap on mobile so the dropdown uses
  available screen space properly.

Quality gates: tsc --noEmit (pass), build (pass)
2026-06-05 08:57:00 +00:00
Developer 6aea83bf17 fix: render notification dropdown via portal for true always-on-top
The notification dropdown was trapped inside .shell-header's stacking
context (created by backdrop-filter). Even with z-index: 9999, it
remained below any element with a higher root-level z-index such as
modal overlays (1000), dialog overlays (1000), and fullscreen
terminals (1000).

- Render the dropdown via ReactDOM.createPortal into document.body
  so it escapes all parent stacking contexts.
- Dynamically measure the bell button's bounding rect to position
  the dropdown correctly with position: fixed.
- Update click-outside handler to also ignore clicks on the bell
  button itself.
- Add window resize listener to keep dropdown aligned.
- Change .notification-dropdown from position: absolute to fixed.

Quality gates: tsc --noEmit (pass), build (pass)
2026-06-05 08:45:36 +00:00
Developer 8d51877afa Merge branch 'fix/notification-center-zindex' into dev 2026-06-04 19:15:23 +00:00
23 changed files with 479 additions and 101 deletions
View File
View File
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Icon } from "../../icon"; import { Icon } from "../../icon";
import { validateGitUrl } from "../../../api/config_profiles"; import { validateGitUrl } from "../../../api/config-profiles";
import type { GitMount, GitMountMapping } from "../../../api/config_profiles"; import type { GitMount, GitMountMapping } from "../../../api/config-profiles";
interface GitMountEditorProps { interface GitMountEditorProps {
mounts: GitMount[]; mounts: GitMount[];
@@ -1,12 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act } from "@testing-library/react"; import { render, act } from "@testing-library/react";
import { EventToastBridge } from "./event-toast-bridge"; import { EventToastBridge } from "./event-toast-bridge";
import { useEventContext } from "../state/events"; import { useEventContext } from "../../../state/events";
import { getUserConfig } from "../../../api/settings"; import { getUserConfig } from "../../../api/settings";
import { handleEventToast } from "./toast-rules"; import { handleEventToast } from "../../toast-rules";
import type { InstanceEventPayload } from "../../../types/events"; import type { InstanceEventPayload } from "../../../types/events";
vi.mock("../state/events", () => ({ vi.mock("../../../state/events", () => ({
useEventContext: vi.fn(), useEventContext: vi.fn(),
})); }));
@@ -14,8 +14,8 @@ vi.mock("../../../api/settings", () => ({
getUserConfig: vi.fn(), getUserConfig: vi.fn(),
})); }));
vi.mock("./toast-rules", async (importOriginal) => { vi.mock("../../toast-rules", async (importOriginal) => {
const actual = await importOriginal<typeof import("./toast-rules")>(); const actual = await importOriginal<typeof import("../../toast-rules")>();
return { return {
...actual, ...actual,
handleEventToast: vi.fn(), handleEventToast: vi.fn(),
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { NotificationCenter } from "./notification-center"; import { NotificationCenter } from "./notification-center";
import { NotificationProvider } from "../state/notifications"; import { NotificationProvider } from "../../../state/notifications";
vi.mock("../../../api/notifications", () => ({ vi.mock("../../../api/notifications", () => ({
getNotifications: vi.fn(), getNotifications: vi.fn(),
@@ -1,5 +1,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { useNotifications } from "../../../hooks/use-notifications"; import { useNotifications } from "../../../hooks/use-notifications";
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
import { NotificationItem } from "./notification-item"; import { NotificationItem } from "./notification-item";
import { Icon } from "../../icon"; import { Icon } from "../../icon";
@@ -22,15 +24,39 @@ export function NotificationCenter({
setIsDropdownOpen, setIsDropdownOpen,
} = useNotifications(); } = useNotifications();
const isMobile = useMobileViewport();
const bellRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const updatePosition = useCallback(() => {
if (!bellRef.current) return;
const rect = bellRef.current.getBoundingClientRect();
if (isMobile) {
setDropdownStyle({
top: rect.bottom + 6,
left: "1rem",
right: "1rem",
});
} else {
setDropdownStyle({
top: rect.bottom + 6,
right: window.innerWidth - rect.right,
});
}
}, [isMobile]);
useEffect(() => { useEffect(() => {
if (!isDropdownOpen) return; if (!isDropdownOpen) return;
updatePosition();
const handleMouseDown = (e: MouseEvent) => { const handleMouseDown = (e: MouseEvent) => {
const target = e.target as Node;
if ( if (
dropdownRef.current && dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node) !dropdownRef.current.contains(target) &&
!bellRef.current?.contains(target)
) { ) {
setIsDropdownOpen(false); setIsDropdownOpen(false);
} }
@@ -42,14 +68,20 @@ export function NotificationCenter({
} }
}; };
const handleResize = () => {
updatePosition();
};
document.addEventListener("mousedown", handleMouseDown); document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", handleResize);
return () => { return () => {
document.removeEventListener("mousedown", handleMouseDown); document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", handleResize);
}; };
}, [isDropdownOpen, setIsDropdownOpen]); }, [isDropdownOpen, setIsDropdownOpen, updatePosition]);
useEffect(() => { useEffect(() => {
if (isDropdownOpen) { if (isDropdownOpen) {
@@ -66,6 +98,7 @@ export function NotificationCenter({
return ( return (
<div className="notification-center"> <div className="notification-center">
<button <button
ref={bellRef}
type="button" type="button"
className="notification-bell" className="notification-bell"
onClick={() => setIsDropdownOpen(!isDropdownOpen)} onClick={() => setIsDropdownOpen(!isDropdownOpen)}
@@ -79,56 +112,68 @@ export function NotificationCenter({
)} )}
</button> </button>
{isDropdownOpen && ( {isDropdownOpen &&
<div createPortal(
ref={dropdownRef} <>
role="dialog" {isMobile && (
aria-label="Notifications" <div
className="notification-dropdown" className="notification-dropdown-backdrop"
> onClick={() => setIsDropdownOpen(false)}
<div className="notification-dropdown-header"> aria-hidden="true"
<span>Notifications</span> />
</div>
<ul className="notification-list">
{notifications.length === 0 ? (
<li className="notification-empty">No notifications</li>
) : (
notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onMarkRead={markRead}
onDismiss={dismiss}
/>
))
)} )}
</ul> <div
ref={dropdownRef}
role="dialog"
aria-label="Notifications"
className="notification-dropdown"
style={dropdownStyle}
>
<div className="notification-dropdown-header">
<span>Notifications</span>
</div>
{notifications.length > 0 && ( <ul className="notification-list">
<div className="notification-dropdown-footer"> {notifications.length === 0 ? (
<button <li className="notification-empty">No notifications</li>
type="button" ) : (
className="notification-mark-all" notifications.map((n) => (
onClick={() => { <NotificationItem
void markAllRead(); key={n.id}
}} notification={n}
> onMarkRead={markRead}
Mark all as read onDismiss={dismiss}
</button> />
<button ))
type="button" )}
className="notification-clear-all" </ul>
onClick={() => {
void clearAll(); {notifications.length > 0 && (
}} <div className="notification-dropdown-footer">
> <button
Clear all type="button"
</button> className="notification-mark-all"
onClick={() => {
void markAllRead();
}}
>
Mark all as read
</button>
<button
type="button"
className="notification-clear-all"
onClick={() => {
void clearAll();
}}
>
Clear all
</button>
</div>
)}
</div> </div>
)} </>,
</div> document.body,
)} )}
</div> </div>
); );
} }
@@ -5,7 +5,7 @@ import type { Project } from "../../../types";
import { listRepositoryBranches, type GitRepository, type Branch } from "../../../api/git-repositories"; import { listRepositoryBranches, type GitRepository, type Branch } from "../../../api/git-repositories";
import type { ToolType } from "../../../api/tool-types"; import type { ToolType } from "../../../api/tool-types";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys"; import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
import { listConfigProfiles, type ConfigProfile } from "../../../api/config_profiles"; import { listConfigProfiles, type ConfigProfile } from "../../../api/config-profiles";
interface CreateSessionFormProps { interface CreateSessionFormProps {
projects: Project[]; projects: Project[];
@@ -8,7 +8,6 @@ import React, {
import { Terminal } from "xterm"; import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit"; import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links"; import { WebLinksAddon } from "xterm-addon-web-links";
import { WebglAddon } from "xterm-addon-webgl";
import "xterm/css/xterm.css"; import "xterm/css/xterm.css";
import { import {
@@ -310,25 +309,13 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon()); term.loadAddon(new WebLinksAddon());
// Load WebGL renderer for GPU acceleration, fall back to DOM // NOTE: WebGL renderer disabled.
let webglAddon: WebglAddon | null = null; // The WebGL addon causes black-on-black rendering artifacts with
try { // tmux/vim reverse-video (inverse color) sequences on desktop.
webglAddon = new WebglAddon(); // Mobile already uses the DOM renderer (WebGL fails there), which
term.loadAddon(webglAddon); // handles these color attributes correctly. The DOM renderer is
webglAddon.onContextLoss(() => { // fast enough for typical terminal workloads.
console.warn("WebGL context lost, falling back to DOM renderer"); // See: xterm.js WebGL known issues with reverse video / minimumContrastRatio
try {
webglAddon?.dispose();
} catch {
// ignore
}
webglAddon = null;
// Trigger a refit since cell dimensions may differ
requestAnimationFrame(() => fitTerminal());
});
} catch (e) {
console.warn("WebGL renderer failed to load, using DOM renderer", e);
}
const container = terminalRef.current; const container = terminalRef.current;
@@ -585,16 +572,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
window.clearInterval(heartbeatCheckRef.current); window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null; heartbeatCheckRef.current = null;
} }
// Dispose WebGL addon BEFORE the terminal to avoid race with
// RenderService.setRenderer accessing a disposed renderer
if (webglAddon) {
try {
webglAddon.dispose();
} catch {
// Ignore disposal errors from partially torn-down terminal
}
webglAddon = null;
}
try { try {
term.dispose(); term.dispose();
} catch { } catch {
@@ -11,7 +11,7 @@ import {
} from "../../../api/sessions"; } from "../../../api/sessions";
import type { ToolType } from "../../../api/tool-types"; import type { ToolType } from "../../../api/tool-types";
import { CreateSessionForm } from "../session/create-session-form"; import { CreateSessionForm } from "../session/create-session-form";
import { listConfigProfiles, type ConfigProfile } from "../../../api/config_profiles"; import { listConfigProfiles, type ConfigProfile } from "../../../api/config-profiles";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys"; import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
import { useEventContext } from "../../../state/events"; import { useEventContext } from "../../../state/events";
@@ -4,7 +4,7 @@ import { extractErrorMessage } from "../../../utils/errors";
import { import {
compileToolDefinition, compileToolDefinition,
type ToolDefinitionManifest, type ToolDefinitionManifest,
} from "../../../api/tool_definitions"; } from "../../../api/tool-definitions";
interface PackageEntry { interface PackageEntry {
name: string; name: string;
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { Icon } from "../../icon"; import { Icon } from "../../icon";
import { listToolTypes, type ToolType } from "../../../api/tool-types"; import { listToolTypes, type ToolType } from "../../../api/tool-types";
import { listConfigProfiles, type ConfigProfile } from "../../../api/config_profiles"; import { listConfigProfiles, type ConfigProfile } from "../../../api/config-profiles";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys"; import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
import type { Workspace } from "../../../types/workspace"; import type { Workspace } from "../../../types/workspace";
import type { ToolInstance } from "../../../api/sessions"; import type { ToolInstance } from "../../../api/sessions";
+1 -1
View File
@@ -17,7 +17,7 @@ import {
type ConfigProfile, type ConfigProfile,
type CreateConfigProfileRequest, type CreateConfigProfileRequest,
type ResolvedProfile, type ResolvedProfile,
} from "../api/config_profiles"; } from "../api/config-profiles";
import { listProjects } from "../api/projects"; import { listProjects } from "../api/projects";
import type { ProjectWithRepos } from "../types"; import type { ProjectWithRepos } from "../types";
import { listToolTypes, type ToolType } from "../api/tool-types"; import { listToolTypes, type ToolType } from "../api/tool-types";
+1 -1
View File
@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { HomePage } from "./dashboard"; import { HomePage } from "./DashboardPage";
const mockDashboard = vi.fn(); const mockDashboard = vi.fn();
const mockSessions = vi.fn(); const mockSessions = vi.fn();
+1 -1
View File
@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectsPage } from "./projects"; import { ProjectsPage } from "./ProjectsPage";
import * as projectsApi from "../api/projects"; import * as projectsApi from "../api/projects";
const mockProjects = [ const mockProjects = [
+1 -1
View File
@@ -23,7 +23,7 @@ import {
listToolDefinitions, listToolDefinitions,
updateToolDefinition, updateToolDefinition,
type ToolDefinitionManifest, type ToolDefinitionManifest,
} from "../api/tool_definitions"; } from "../api/tool-definitions";
import { ManifestEditor } from "../components/features/tool/manifest-editor"; import { ManifestEditor } from "../components/features/tool/manifest-editor";
type Status = "loading" | "ready" | "error"; type Status = "loading" | "ready" | "error";
+14 -5
View File
@@ -4670,9 +4670,7 @@ a:active,
} }
.notification-dropdown { .notification-dropdown {
position: absolute; position: fixed;
top: calc(100% + 6px);
right: 0;
width: 360px; width: 360px;
max-width: calc(100vw - 2rem); max-width: calc(100vw - 2rem);
max-height: 480px; max-height: 480px;
@@ -4840,9 +4838,20 @@ a:active,
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.notification-dropdown-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 9998;
}
.notification-dropdown { .notification-dropdown {
width: calc(100vw - 2rem); width: auto;
max-width: 360px; left: 0.75rem;
right: 0.75rem;
max-width: none;
border-radius: 12px;
max-height: 70vh;
} }
} }
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-04
@@ -0,0 +1,73 @@
## Why
After the backend-frontend refactoring (commits `0591b00` through `8c7affc`), the codebase gained proper directory structure but several files grew into monoliths. The `main` branch (pre-refactor baseline at `5ed5e1c`) kept pages thin by delegating to extracted components. On `dev`, new features were added inline, causing pages and routers to absorb responsibilities that belong in components or services.
### Problem Files (Frontend)
| File | Lines | Problem |
|------|-------|---------|
| `pages/ToolWorkshopPage.tsx` | **1,269** | Merged 3 tab components inline (ToolTypes, ToolConfigs, ConfigFolders) |
| `pages/ConfigProfilesPage.tsx` | **1,611** | List, detail, edit, create, and mobile views all in one file |
| `pages/TerminalPage.tsx` | **571** | Session tabs, keyboard shortcuts, fullscreen, mobile overlay, special keys all inline |
| `pages/RepoWorkspacePage.tsx` | **505** | File editor, git toolbar, workspace header, sidebar logic inline |
| `pages/SettingsPage.tsx` | **284** | Settings nav + multiple setting sections inline |
| `pages/SshKeysPage.tsx` | **277** | List and create inline |
### Problem Files (Backend)
| File | Lines | Problem |
|------|-------|---------|
| `api/tool/tool_instances.py` | **2,900** | CRUD, Docker lifecycle, WebSocket proxy, terminal sessions, instance proxy all in one router |
| `api/project/git_repositories.py` | **1,588** | HTTP endpoints mixed with git command orchestration |
| `api/config/config_profiles.py` | **842** | CRUD + validation + resolver + mount/include management |
### What `main` Did Differently
`main` at `5ed5e1c`:
- `ToolWorkshopPage.tsx` = **77 lines** (just a tab switcher, tabs imported from `features/tool-workshop/`)
- `TerminalPage.tsx` = **38 lines** (just a wrapper around `TerminalComponent`)
- `api/tool_instances.py` = **284 lines** (HTTP endpoints only)
- `api/terminal.py` = **158 lines** (separate WebSocket router)
## What Changes
Restore the **thin-page / thin-router / fat-component** pattern from `main`, adapted to current `dev` features:
1. **Frontend page extraction** — Split monolithic pages into:
- Page shell (orchestrator, 50-150 lines)
- Tab components (for tabbed pages)
- List / Detail / Edit / Create components (for CRUD pages)
- Mobile-specific views (extracted, not inline)
2. **Backend router slimming** — Split `tool_instances.py` into:
- `tool_instances.py` — CRUD endpoints only
- `tool_lifecycle.py` — Start/stop/restart/delete logic
- Move terminal WebSocket back to dedicated `terminal.py`
3. **Git repository router** — Extract git command orchestration into `services/git/`
## Capabilities
### New Capabilities
- None (pure structural refactor)
### Modified Capabilities
- `frontend-structure`: Pages become orchestrators; components carry the UI logic
- `backend-structure`: Routers become HTTP-only; services carry business logic
## Impact
- **Frontend**: New `features/tool-workshop/` tab components, new `features/config-profiles/` components, `features/terminal/` session manager, etc.
- **Backend**: New `api/tool/tool_lifecycle.py`, `api/tool/terminal.py`, slimmer `api/tool/tool_instances.py`
- **Tests**: Test files may need import path updates (component moved → test follows)
## Exclusions (Already Done / Out of Scope)
- Directory structure already exists (`features/`, `services/`, etc.)
- Schema extraction already done (`schemas/` subpackages)
- Model subpackages already done (`models/` subpackages)
- API router subpackages already done (`api/tool/`, `api/project/`, etc.)
- File naming already done (kebab-case APIs, PascalCase pages)
- No behavioral changes to any endpoint or UI flow
- No database schema changes
- No new features
@@ -0,0 +1,128 @@
## Scope
This change is a **pure structural refactoring** to split monolithic pages and routers into focused components and services. No API contracts, database schemas, or user-facing behaviors change.
### In Scope
#### 1. Frontend Page Extraction
Split the following pages into a thin page shell + extracted components:
**`pages/ToolWorkshopPage.tsx` (1,269 → ~80 lines)**
- Extract `ToolTypesTab``components/features/tool-workshop/ToolTypesTab.tsx`
- Extract `ToolConfigsTab``components/features/tool-workshop/ToolConfigsTab.tsx`
- Extract `ConfigFoldersTab``components/features/tool-workshop/ConfigFoldersTab.tsx`
- Page becomes: tab switcher only, imports the 3 tabs
**`pages/ConfigProfilesPage.tsx` (1,611 → ~80 lines)**
- Extract `ConfigProfileListView` → list view + mobile list view
- Extract `ConfigProfileDetailView` → detail view with edit toggle
- Extract `ConfigProfileEditForm` → edit/create form
- Extract `ConfigProfileMobileView` → mobile view state machine wrapper
- Page becomes: router between list/detail/edit views
**`pages/TerminalPage.tsx` (571 → ~80 lines)**
- Extract `TerminalSessionManager` → session tabs + auto-create logic
- Extract `TerminalKeyboardShortcuts` → shortcut handler hook (already exists, just use it)
- Extract `MobileTerminalOverlay` → mobile overlay toolbar + tabs
- Page becomes: choose between desktop (`TerminalComponent` + `TerminalSessionTabs`) and mobile (`MobileTerminalOverlay` + `TerminalComponent`) wrappers
**`pages/SettingsPage.tsx` (284 → ~80 lines)**
- Extract `SettingsNavigation` → settings nav sidebar
- Extract `GeneralSettingsTab`, `SSHKeysTab` (already separate pages, but move sections into components if inline)
- Page becomes: nav + `<Outlet>` for nested routes
**`pages/SshKeysPage.tsx` (277 → ~80 lines)**
- Extract `SSHKeyList` → list with actions
- Extract `SSHKeyCreateForm` → create form
- Page becomes: layout wrapper + conditionally render list or form
**`pages/RepoWorkspacePage.tsx` (505 → ~150 lines)**
- Extract `WorkspaceLayout` → sidebar + main content layout
- Page becomes: data loader + layout wrapper
**`pages/ProjectsPage.tsx` (433 → ~100 lines)**
- Extract `ProjectList` → list with cards
- Extract `ProjectCreateDialog` → create form in dialog
- Extract `ProjectEditDialog` → edit form in dialog
- Page becomes: data loader + layout + dialog state manager
#### 2. CSS Reorganization
**`styles.css` (5,683 lines → deleted)**
- Restore `styles/` directory with extracted files:
- `styles/tokens.css` — CSS custom properties (colors, spacing, typography)
- `styles/global.css` — global reset, body, shell layout
- `styles/utilities.css` — utility classes (.stack, .card, .muted, etc.)
- `styles/syntax-highlight.css` — code highlighting
- Restore `styles/pages/*.css` — page-specific styles:
- `styles/pages/dashboard.css`
- `styles/pages/projects.css`
- `styles/pages/sessions.css`
- `styles/pages/settings.css`
- `styles/pages/ssh-keys.css`
- `styles/pages/git-history.css`
- `styles/pages/repo-workspace.css`
- Restore component CSS modules:
- `components/features/terminal/TerminalComponent.module.css`
- `components/features/git/GitToolbar.module.css`
- `components/features/git/CommitDialog.module.css`
- `components/features/git/MergeDialog.module.css`
- `components/features/git/FileEditor.module.css`
- `components/features/git/FileBrowser.module.css`
- `components/features/git/FileViewer.module.css`
- `components/features/git/CommitPanel.module.css`
- `components/features/session/InstanceList.module.css`
- `components/features/settings/SettingsTabLayout.module.css`
- `components/layout/AppShell.module.css`
- Update all component imports to use `import styles from './ComponentName.module.css'`
- Update `main.tsx` to import `styles/tokens.css`, `styles/global.css`, `styles/utilities.css`, `styles/syntax-highlight.css`
- Update each page to import its `styles/pages/*.css`
- Delete monolithic `styles.css`
#### 3. Backend Router Slimming
**`api/tool/tool_instances.py` (2,900 → ~300 lines)**
- Extract terminal WebSocket handlers → `api/tool/terminal.py` (~400 lines)
- Extract instance lifecycle (create/start/stop/delete/restart) → `api/tool/tool_lifecycle.py` (~600 lines)
- Keep in `tool_instances.py`: CRUD endpoints (GET list, GET detail, POST, PATCH, DELETE) + instance proxy endpoint
**`api/project/git_repositories.py` (1,588 → ~300 lines)**
- Extract git command orchestration into `services/git/operations.py`
- Router keeps: auth, parameter validation, response building, error handling
- Service functions: `clone_repo`, `fetch_repo`, `pull_repo`, `push_repo`, `merge_repo`, etc.
**`api/config/config_profiles.py` (842 → ~200 lines)**
- Extract resolver orchestration into `services/config/resolver_service.py`
- Extract CRUD helpers into `services/config/crud_service.py`
- Router keeps: endpoint definitions, auth, input validation
### Out of Scope
- Any new features or behavioral changes
- Database schema changes (no migrations)
- API contract changes (same endpoints, same request/response shapes)
- Frontend UI behavior changes (same components, same interactions)
- Moving existing `features/` components (already organized)
- Renaming files (naming already done)
- Changing any CSS rules (only moving them)
## Acceptance Criteria
1. All pages ≤ 150 lines (except `RepoWorkspacePage` which may stay at ~150)
2. All API routers ≤ 400 lines
3. No monolithic `styles.css` — all CSS in `styles/` directory or `.module.css` files
4. All existing tests pass without modification (behavior unchanged)
5. All existing API endpoints return identical responses
6. Frontend `npm run typecheck` passes
7. Frontend `npm run build` passes
8. Backend `py_compile` passes on all files
9. No import errors in browser console
10. File count increases (more files, smaller files)
## Preconditions
- `dev` branch is stable (all fixes from this session are committed)
- Backend compiles (`py_compile` pass)
- Frontend typechecks and builds (`tsc`, `vite build` pass)
- Current tests pass (or known failures are documented)
@@ -0,0 +1,144 @@
## Phase 0: Preparation
- [ ] 0.1 Verify `dev` builds cleanly (backend `py_compile`, frontend `tsc` + `vite build`)
- [ ] 0.2 Document current file sizes for before/after comparison
- [ ] 0.3 Create component directory stubs if missing:
- `apps/web/src/components/features/tool-workshop/`
- `apps/web/src/components/features/config-profiles/`
- `apps/web/src/components/features/terminal/`
- `apps/web/src/components/features/settings/`
- `apps/web/src/components/features/ssh-keys/`
- `apps/api/src/services/git/operations.py` (extract from router)
- `apps/api/src/services/config/crud_service.py`
- `apps/api/src/services/config/resolver_service.py`
## Phase 1: Backend — Router Slimming
### 1.1 Terminal WebSocket Extraction
- [ ] 1.1.1 Create `api/tool/terminal.py` from terminal WebSocket handlers in `api/tool/tool_instances.py`
- [ ] 1.1.2 Move `_handle_terminal_websocket`, `_get_user_from_websocket`, `SessionRef` class
- [ ] 1.1.3 Update `main.py` to include `terminal_router` from `api.tool.terminal`
- [ ] 1.1.4 Remove terminal routes from `api/tool/tool_instances.py`
- [ ] 1.1.5 Verify `py_compile` passes
### 1.2 Instance Lifecycle Extraction
- [ ] 1.2.1 Create `api/tool/tool_lifecycle.py` for start/stop/restart/delete endpoints
- [ ] 1.2.2 Extract lifecycle endpoints from `api/tool/tool_instances.py`
- [ ] 1.2.3 Update `main.py` to include lifecycle router
- [ ] 1.2.4 Verify `py_compile` passes
### 1.3 Git Repository Router
- [ ] 1.3.1 Create `services/git/operations.py` for git command orchestration
- [ ] 1.3.2 Extract `clone_repo`, `fetch_repo`, `pull_repo`, `push_repo`, `merge_repo`, `commit_repo` helpers
- [ ] 1.3.3 Update `api/project/git_repositories.py` to call service functions
- [ ] 1.3.4 Verify `py_compile` passes
### 1.4 Config Profile Router
- [ ] 1.4.1 Extract CRUD helpers into `services/config/crud_service.py`
- [ ] 1.4.2 Extract resolver helpers into `services/config/resolver_service.py`
- [ ] 1.4.3 Update `api/config/config_profiles.py` to call services
- [ ] 1.4.4 Verify `py_compile` passes
## Phase 2: CSS Reorganization
### 2.1 Restore `styles/` Directory Structure
- [ ] 2.1.1 Create `styles/` directory
- [ ] 2.1.2 Extract `styles/tokens.css` from `styles.css` — CSS custom properties
- [ ] 2.1.3 Extract `styles/global.css` from `styles.css` — global reset, body, shell layout
- [ ] 2.1.4 Extract `styles/utilities.css` from `styles.css` — utility classes (.stack, .card, .muted, .dialog, etc.)
- [ ] 2.1.5 Extract `styles/syntax-highlight.css` from `styles.css` — code highlighting
- [ ] 2.1.6 Update `main.tsx` to import: `styles/tokens.css`, `styles/global.css`, `styles/utilities.css`, `styles/syntax-highlight.css`
- [ ] 2.1.7 Verify build passes
### 2.2 Restore Page-Specific CSS
- [ ] 2.2.1 Extract `styles/pages/dashboard.css` from `styles.css`
- [ ] 2.2.2 Extract `styles/pages/projects.css` from `styles.css`
- [ ] 2.2.3 Extract `styles/pages/sessions.css` from `styles.css`
- [ ] 2.2.4 Extract `styles/pages/settings.css` from `styles.css`
- [ ] 2.2.5 Extract `styles/pages/ssh-keys.css` from `styles.css`
- [ ] 2.2.6 Extract `styles/pages/git-history.css` from `styles.css`
- [ ] 2.2.7 Extract `styles/pages/repo-workspace.css` from `styles.css`
- [ ] 2.2.8 Update each page to import its page CSS
- [ ] 2.2.9 Verify build passes
### 2.3 Restore Component CSS Modules
- [ ] 2.3.1 Create `components/features/terminal/TerminalComponent.module.css` from terminal styles in `styles.css`
- [ ] 2.3.2 Create `components/features/git/GitToolbar.module.css` from git toolbar styles in `styles.css`
- [ ] 2.3.3 Create `components/features/git/CommitDialog.module.css` from commit dialog styles in `styles.css`
- [ ] 2.3.4 Create `components/features/git/MergeDialog.module.css` from merge dialog styles in `styles.css`
- [ ] 2.3.5 Create `components/features/git/FileEditor.module.css` from file editor styles in `styles.css`
- [ ] 2.3.6 Create `components/features/git/FileBrowser.module.css` from file browser styles in `styles.css`
- [ ] 2.3.7 Create `components/features/git/FileViewer.module.css` from file viewer styles in `styles.css`
- [ ] 2.3.8 Create `components/features/git/CommitPanel.module.css` from commit panel styles in `styles.css`
- [ ] 2.3.9 Create `components/features/session/InstanceList.module.css` from instance list styles in `styles.css`
- [ ] 2.3.10 Create `components/features/settings/SettingsTabLayout.module.css` from settings tab layout styles in `styles.css`
- [ ] 2.3.11 Create `components/layout/AppShell.module.css` from shell styles in `styles.css`
- [ ] 2.3.12 Update each component to use `import styles from './ComponentName.module.css'`
- [ ] 2.3.13 Remove extracted styles from `styles.css`
- [ ] 2.3.14 Verify build passes
### 2.4 Verify and Delete Monolith
- [ ] 2.4.1 Confirm `styles.css` is empty (or only has truly unclassifiable styles)
- [ ] 2.4.2 Delete `styles.css`
- [ ] 2.4.3 Verify build passes
- [ ] 2.4.4 Verify no visual regressions
## Phase 3: Frontend — Tool Workshop Page
- [ ] 3.1 Extract `ToolTypesTab` from `pages/ToolWorkshopPage.tsx` into `components/features/tool-workshop/ToolTypesTab.tsx`
- [ ] 3.2 Extract `ToolConfigsTab` into `components/features/tool-workshop/ToolConfigsTab.tsx`
- [ ] 3.3 Extract `ConfigFoldersTab` into `components/features/tool-workshop/ConfigFoldersTab.tsx`
- [ ] 3.4 Slim `pages/ToolWorkshopPage.tsx` to ~80 lines (tab switcher only)
- [ ] 3.5 Update imports in all consumers
- [ ] 3.6 Verify `tsc --noEmit` and `npm run build` pass
## Phase 4: Frontend — Config Profiles Page
- [ ] 4.1 Extract `ConfigProfileListView` into `components/features/config-profiles/ConfigProfileListView.tsx`
- [ ] 4.2 Extract `ConfigProfileDetailView` into `components/features/config-profiles/ConfigProfileDetailView.tsx`
- [ ] 4.3 Extract `ConfigProfileEditForm` into `components/features/config-profiles/ConfigProfileEditForm.tsx`
- [ ] 4.4 Extract `ConfigProfileMobileView` into `components/features/config-profiles/ConfigProfileMobileView.tsx`
- [ ] 4.5 Slim `pages/ConfigProfilesPage.tsx` to ~80 lines
- [ ] 4.6 Update imports
- [ ] 4.7 Verify `tsc --noEmit` and `npm run build` pass
## Phase 5: Frontend — Terminal Page
- [ ] 5.1 Extract `TerminalSessionManager` (tabs + auto-create) into `components/features/terminal/TerminalSessionManager.tsx`
- [ ] 5.2 Extract `MobileTerminalOverlay` into `components/features/terminal/MobileTerminalOverlay.tsx`
- [ ] 5.3 Extract fullscreen keyboard shortcut handler into `hooks/use-terminal-shortcuts.ts`
- [ ] 5.4 Slim `pages/TerminalPage.tsx` to ~80 lines
- [ ] 5.5 Update imports
- [ ] 5.6 Verify `tsc --noEmit` and `npm run build` pass
## Phase 6: Frontend — Settings & SSH Keys Pages
- [ ] 6.1 Extract `SettingsNavigation` into `components/features/settings/SettingsNavigation.tsx`
- [ ] 6.2 Slim `pages/SettingsPage.tsx` to ~80 lines
- [ ] 6.3 Extract `SSHKeyList` into `components/features/ssh-keys/SSHKeyList.tsx`
- [ ] 6.4 Extract `SSHKeyCreateForm` into `components/features/ssh-keys/SSHKeyCreateForm.tsx`
- [ ] 6.5 Slim `pages/SshKeysPage.tsx` to ~80 lines
- [ ] 6.6 Verify `tsc --noEmit` and `npm run build` pass
## Phase 7: Frontend — Projects & Repo Workspace Pages
- [ ] 7.1 Extract `ProjectList` into `components/features/project/ProjectList.tsx`
- [ ] 7.2 Extract `ProjectCreateDialog` into `components/features/project/ProjectCreateDialog.tsx`
- [ ] 7.3 Extract `ProjectEditDialog` into `components/features/project/ProjectEditDialog.tsx`
- [ ] 7.4 Slim `pages/ProjectsPage.tsx` to ~100 lines
- [ ] 7.5 Extract `WorkspaceLayout` into `components/features/workspace/WorkspaceLayout.tsx`
- [ ] 7.6 Slim `pages/RepoWorkspacePage.tsx` to ~150 lines
- [ ] 7.7 Verify `tsc --noEmit` and `npm run build` pass
## Phase 8: Integration and Verification
- [ ] 8.1 Run backend `py_compile` on all files
- [ ] 8.2 Run frontend `npm run typecheck`
- [ ] 8.3 Run frontend `npm run build`
- [ ] 8.4 Run frontend tests: `npm test`
- [ ] 8.5 Verify file size targets met (pages ≤ 150, routers ≤ 400, no `styles.css` monolith)
- [ ] 8.6 Verify no 404s or import errors in browser console
- [ ] 8.7 Manual smoke test: create project, start terminal, open config profiles
- [ ] 8.8 Verify visual regression: colors, spacing, typography unchanged
- [ ] 8.9 Verify mobile terminal styles intact
- [ ] 8.10 Verify notification dropdown styles intact