feat: redesign authenticated UI with home overview and settings hub

- Add HomePage with open sessions grid, projects overview, and session composer
- Add SettingsPage with tabs for General, SSH Keys, Tool Types, Tool Configs
- Update navigation to Home, Projects, Settings
- Redirect legacy routes (/sessions, /ssh-keys, /tool-types, /tool-configs)
- Apply Inter font and warm editorial styling
- Update tests for new dashboard and projects pages

Quality gates: typecheck pass, lint pass, 15/15 tests pass

Refs: openspec/changes/ui-redesign-home-settings
This commit is contained in:
2026-05-22 20:34:39 +02:00
parent 575e5837ea
commit 6f35eb77ae
18 changed files with 3036 additions and 202 deletions
+1 -2
View File
@@ -121,9 +121,8 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
Raises:
RuntimeError: If branch creation fails
"""
if base_branch == "HEAD":
try:
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD")
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
except RuntimeError:
_run_git_command(repo_path, "checkout", "--orphan", name)
return
+1371
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Headquarter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
+796
View File
@@ -0,0 +1,796 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Headquarter - UI Preview</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f4f1ea;
--panel: #fffef9;
--ink: #1d1d1b;
--muted: #5f5b55;
--brand: #275d4b;
--brand-strong: #154236;
--border: #d8d0c5;
--primary: #275d4b;
--primary-fg: #fffef9;
--color-primary: #275d4b;
--success: #2f8f62;
--success-light: rgba(47, 143, 98, 0.14);
--warning: #c08a1e;
--warning-light: rgba(192, 138, 30, 0.14);
--danger: #b94a3c;
--danger-light: rgba(185, 74, 60, 0.14);
--info: #4f7fb8;
--info-light: rgba(79, 127, 184, 0.14);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
--font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem);
--font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
--font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
--font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
--font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
}
/* App Shell */
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.shell-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 88%, transparent);
backdrop-filter: blur(7px);
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
color: var(--ink);
text-decoration: none;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-chip {
border: 1px solid var(--border);
background: var(--panel);
border-radius: 999px;
padding: 0.35rem 0.7rem;
font-size: 0.9rem;
color: var(--ink);
text-decoration: none;
}
.ghost-button {
border: 1px solid var(--border);
background: transparent;
border-radius: 10px;
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
color: var(--muted);
}
.shell-body {
display: grid;
grid-template-columns: 230px 1fr;
min-height: calc(100vh - 57px);
}
.shell-nav {
border-right: 1px solid var(--border);
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
background: color-mix(in srgb, var(--panel) 65%, transparent);
}
.nav-item {
padding: 0.65rem 0.75rem;
border-radius: 10px;
color: var(--muted);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
}
.nav-item:hover {
background: #ece7df;
color: var(--ink);
}
.nav-item-active {
background: var(--brand);
color: #f7fff7;
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--primary);
color: var(--primary-fg);
border-radius: 9px;
font-size: 11px;
font-weight: 600;
margin-left: auto;
}
.nav-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.nav-section-title {
margin-top: 0.5rem;
padding: 0.25rem 0.75rem;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.session-item {
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
}
.session-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
display: inline-block;
margin-right: 0.25rem;
}
.session-status.running {
background: var(--success);
}
.shell-content {
padding: 1.25rem;
overflow-x: hidden;
}
/* Common Components */
.stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
.stack-sm {
gap: 0.5rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem;
}
.muted {
color: var(--muted);
}
.eyebrow {
margin: 0;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.primary-button {
background: var(--brand);
color: white;
border-radius: 10px;
border: 1px solid transparent;
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
}
.primary-button:hover {
background: var(--brand-strong);
}
.secondary-button {
border-color: var(--border);
background: var(--panel);
border-radius: 10px;
border: 1px solid var(--border);
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
}
/* Home Page */
.home-page {
max-width: 1240px;
}
.home-hero {
display: flex;
justify-content: space-between;
gap: var(--space-4);
align-items: flex-start;
}
.home-hero-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.home-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-4);
}
.home-summary-card .card-label {
margin: 0;
color: var(--muted);
font-size: 0.875rem;
}
.home-summary-card .card-value {
margin: 0.45rem 0 0;
font-size: 1.6rem;
font-weight: 700;
}
.home-section h2,
.home-section h3 {
margin: 0;
}
.home-session-grid,
.home-project-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.session-card {
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
}
.session-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-badge.running {
background: var(--success-light);
color: var(--success);
}
.status-badge.building {
background: var(--warning-light);
color: var(--warning);
}
.status-badge.pending {
background: var(--info-light);
color: var(--info);
}
.recent-sessions-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.recent-session-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
}
.recent-session-name {
font-weight: 500;
}
.create-session-form .form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-4);
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-field input,
.form-field select,
.form-field textarea {
padding: 0.55rem 0.7rem;
border: 1px solid var(--border);
border-radius: 10px;
font: inherit;
background: var(--panel);
color: var(--ink);
}
.form-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
/* Settings Page */
.settings-page {
max-width: 1240px;
}
.settings-header {
padding: 1.5rem;
}
.settings-tabs {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.settings-tab {
padding: 0.6rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
background: var(--panel);
text-decoration: none;
cursor: pointer;
}
.settings-tab.active {
background: var(--brand);
color: white;
border-color: transparent;
}
.settings-panel {
padding: 1.5rem;
}
.settings-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.success-text {
color: var(--success);
}
.error-text {
color: var(--danger);
}
/* Preview Switcher */
.preview-switcher {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.preview-switcher button {
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font: inherit;
color: var(--muted);
}
.preview-switcher button.active {
background: var(--brand);
color: white;
}
.page-preview {
display: none;
}
.page-preview.active {
display: block;
}
/* Responsive */
@media (max-width: 767px) {
.shell-body {
grid-template-columns: 1fr;
}
.shell-nav {
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border);
}
.home-hero {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="shell">
<header class="shell-header">
<a href="#" class="brand">Headquarter</a>
<div class="header-actions">
<a href="#" class="user-chip">User</a>
<button class="ghost-button">Logout</button>
</div>
</header>
<div class="shell-body">
<aside class="shell-nav" aria-label="Primary navigation">
<a href="#" class="nav-item nav-item-active">
<span>🏠</span> Home
<span class="nav-badge">3</span>
</a>
<a href="#" class="nav-item">
<span>📁</span> Projects
</a>
<a href="#" class="nav-item">
<span>⚙️</span> Settings
</a>
<div class="nav-divider"></div>
<div class="nav-section-title">Live sessions</div>
<a href="#" class="nav-item session-item">
<span class="session-status running"></span>
<span>Dev Environment</span>
</a>
<a href="#" class="nav-item session-item">
<span class="session-status running"></span>
<span>Jupyter Lab</span>
</a>
<a href="#" class="nav-item session-item">
<span class="session-status"></span>
<span>Code Server</span>
</a>
</aside>
<main class="shell-content">
<!-- HOME PAGE PREVIEW -->
<div id="home-preview" class="page-preview active">
<section class="stack home-page">
<header class="home-hero card">
<div class="stack-sm">
<p class="eyebrow">Workspace overview</p>
<h1>Home</h1>
<p class="muted">Open sessions, available projects, and the fastest path back into work.</p>
</div>
<div class="home-hero-actions">
<button class="primary-button">New Project</button>
<button class="secondary-button">Settings</button>
</div>
</header>
<div class="home-summary-grid">
<article class="card home-summary-card">
<p class="card-label">Open sessions</p>
<p class="card-value">3</p>
</article>
<article class="card home-summary-card">
<p class="card-label">Projects</p>
<p class="card-value">5</p>
</article>
<article class="card home-summary-card">
<p class="card-label">Repositories</p>
<p class="card-value">12</p>
</article>
</div>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Open sessions</p>
<h2>3</h2>
</div>
</div>
<div class="home-session-grid">
<article class="card session-card">
<div class="stack-sm">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<h3>Dev Environment</h3>
<span class="status-badge running">running</span>
</div>
<p class="muted">Acme Corp · main</p>
<p class="muted">VS Code Server</p>
</div>
<div class="session-actions">
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
</div>
</article>
<article class="card session-card">
<div class="stack-sm">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<h3>Jupyter Lab</h3>
<span class="status-badge running">running</span>
</div>
<p class="muted">Data Science · experiments</p>
<p class="muted">Jupyter Notebook</p>
</div>
<div class="session-actions">
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
</div>
</article>
<article class="card session-card">
<div class="stack-sm">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<h3>Database Console</h3>
<span class="status-badge building">building</span>
</div>
<p class="muted">Backend API · staging</p>
<p class="muted">PostgreSQL Client</p>
</div>
<div class="session-actions">
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
</div>
</article>
</div>
</section>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Available projects</p>
<h2>5</h2>
</div>
<button class="secondary-button">View all</button>
</div>
<div class="home-project-grid">
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
<div class="stack-sm">
<h3>Acme Corp</h3>
<p class="muted">Main product development</p>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
</article>
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
<div class="stack-sm">
<h3>Data Science</h3>
<p class="muted">ML experiments and notebooks</p>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
</article>
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
<div class="stack-sm">
<h3>Backend API</h3>
<p class="muted">REST API services</p>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
</article>
</div>
</section>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Quick create</p>
<h2>Start a session</h2>
</div>
</div>
<form class="stack create-session-form">
<div class="form-row">
<label class="form-field">
Project
<select>
<option>Select project...</option>
<option>Acme Corp</option>
<option>Data Science</option>
</select>
</label>
<label class="form-field">
Repository
<select disabled>
<option>Select repository...</option>
</select>
</label>
<label class="form-field">
Tool type
<select>
<option>Select tool...</option>
<option>VS Code Server</option>
<option>Jupyter Lab</option>
</select>
</label>
</div>
<label class="form-field">
Display name
<input type="text" placeholder="My Development Environment">
</label>
<div class="form-actions">
<button class="primary-button" type="submit">Create Session</button>
</div>
</form>
</section>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Recent sessions</p>
<h2>2</h2>
</div>
</div>
<div class="recent-sessions-list">
<article class="recent-session-item">
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span class="recent-session-name">Old Dev Box</span>
<span class="muted">Acme Corp · VS Code Server</span>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
</article>
<article class="recent-session-item">
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span class="recent-session-name">ML Training</span>
<span class="muted">Data Science · Jupyter Lab</span>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
</article>
</div>
</section>
</section>
</div>
<!-- SETTINGS PAGE PREVIEW -->
<div id="settings-preview" class="page-preview">
<section class="stack settings-page">
<header class="settings-header card stack-sm">
<div>
<p class="eyebrow">Configuration</p>
<h1>Settings</h1>
</div>
<p class="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
</header>
<nav class="settings-tabs" aria-label="Settings sections">
<a href="#" class="settings-tab active">General</a>
<a href="#" class="settings-tab">SSH Keys</a>
<a href="#" class="settings-tab">Tool Types</a>
<a href="#" class="settings-tab">Tool Configs</a>
</nav>
<div class="settings-panel card">
<div class="stack">
<h2>General</h2>
<label class="form-field">
Theme
<select>
<option>System</option>
<option>Light</option>
<option>Dark</option>
</select>
</label>
<label class="form-field">
Git user name
<input type="text" placeholder="Your git commit name" value="John Doe">
</label>
<label class="form-field">
Git user email
<input type="email" placeholder="your.email@example.com" value="john@example.com">
</label>
<label class="form-field">
Default editor
<input type="text" placeholder="e.g., vscode, vim, cursor" value="vscode">
</label>
<div class="settings-actions">
<button class="primary-button">Save Settings</button>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</div>
<div class="preview-switcher">
<button class="active" onclick="showPage('home')">Home</button>
<button onclick="showPage('settings')">Settings</button>
</div>
<script>
function showPage(page) {
document.querySelectorAll('.page-preview').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.preview-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(page + '-preview').classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>
+7 -9
View File
@@ -10,10 +10,8 @@ import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/", label: "Dashboard", icon: "dashboard" },
{ to: "/sessions", label: "Sessions", icon: "terminal" },
{ to: "/", label: "Home", icon: "dashboard" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/settings", label: "Settings", icon: "settings" }
];
@@ -23,9 +21,9 @@ const SessionItem = ({ session }: { session: Session }) => {
return (
<a
href={session.url || "#"}
target="_blank"
rel="noopener noreferrer"
href={session.url ?? `/projects/${session.project_id}`}
target={session.url ? "_blank" : undefined}
rel={session.url ? "noopener noreferrer" : undefined}
className="nav-item session-item"
title={`${session.display_name} (${session.status})`}
>
@@ -85,7 +83,7 @@ export const AppShell = () => {
<div className="shell-body">
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const isSessions = item.to === "/sessions";
const isHome = item.to === "/";
const activeCount = sessions.filter((s) => s.status === "running").length;
return (
<NavLink
@@ -96,7 +94,7 @@ export const AppShell = () => {
>
<Icon name={item.icon} size="sm" />
{item.label}
{isSessions && activeCount > 0 && (
{isHome && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
@@ -106,7 +104,7 @@ export const AppShell = () => {
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Sessions</div>
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
+57 -30
View File
@@ -1,54 +1,81 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DashboardPage } from "./dashboard";
import { HomePage } from "./dashboard";
const mockGet = vi.fn();
const mockDashboard = vi.fn();
const mockSessions = vi.fn();
const mockProjects = vi.fn();
const mockRepos = vi.fn();
vi.mock("../api/dashboard", () => ({
getDashboardSummary: (...args: unknown[]) => mockGet(...args)
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
}));
describe("DashboardPage", () => {
vi.mock("../api/sessions", () => ({
getUserSessions: (...args: unknown[]) => mockSessions(...args),
createInstance: vi.fn(),
startInstance: vi.fn(),
stopInstance: vi.fn(),
deleteInstance: vi.fn(),
recreateInstanceTunnel: vi.fn()
}));
vi.mock("../api/projects", () => ({
listProjects: (...args: unknown[]) => mockProjects(...args)
}));
vi.mock("../api/git_repositories", () => ({
listRepositories: (...args: unknown[]) => mockRepos(...args)
}));
vi.mock("../api/tool_types", () => ({
listToolTypes: vi.fn().mockResolvedValue([])
}));
describe("HomePage", () => {
beforeEach(() => {
mockGet.mockReset();
mockDashboard.mockReset();
mockSessions.mockReset();
mockProjects.mockReset();
mockRepos.mockReset();
});
it("shows loading then empty state when summary has no data", async () => {
mockGet.mockResolvedValue({
projects: 0,
repositories: 0,
sshKeys: 0,
recentActivity: []
});
it("shows overview sections", async () => {
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
mockSessions.mockResolvedValue([]);
mockProjects.mockResolvedValue([]);
mockRepos.mockResolvedValue([]);
render(<DashboardPage />);
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("No activity yet")).toBeInTheDocument();
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
expect(screen.getByText("Available projects")).toBeInTheDocument();
});
});
it("shows retry action when summary request fails", async () => {
mockGet.mockRejectedValueOnce(new Error("failed"));
mockGet.mockResolvedValueOnce({
projects: 2,
repositories: 5,
sshKeys: 1,
recentActivity: ["Created repo"]
});
it("shows retry action when home load fails", async () => {
mockDashboard.mockRejectedValueOnce(new Error("failed"));
mockSessions.mockRejectedValueOnce(new Error("failed"));
mockProjects.mockRejectedValueOnce(new Error("failed"));
render(<DashboardPage />);
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText("Dashboard is unavailable")).toBeInTheDocument();
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(screen.getByText("2")).toBeInTheDocument();
});
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
});
});
+293 -38
View File
@@ -1,83 +1,338 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
import { listProjects } from "../api/projects";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings";
import type { Project } from "../types";
import { Icon } from "../components/icon";
const CARDS = [
type HomeStatus = "loading" | "ready" | "error";
const summaryCards = [
{ label: "Open sessions", key: "openSessions" },
{ label: "Projects", key: "projects" },
{ label: "Repositories", key: "repositories" },
{ label: "SSH Keys", key: "sshKeys" }
] as const;
type DashboardStatus = "loading" | "ready" | "error";
type SessionView = SessionApi;
export const DashboardPage = () => {
const [status, setStatus] = useState<DashboardStatus>("loading");
export const HomePage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<HomeStatus>("loading");
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [sessions, setSessions] = useState<SessionView[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState("");
const [selectedRepo, setSelectedRepo] = useState("");
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
const [actionBusy, setActionBusy] = useState<string | null>(null);
const safeSessions = Array.isArray(sessions) ? sessions : [];
const loadSummary = useCallback(async () => {
const loadHome = useCallback(async () => {
setStatus("loading");
try {
const data = await getDashboardSummary();
setSummary(data);
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
getDashboardSummary(),
getUserSessions(),
listProjects(),
listToolTypes(),
]);
setSummary(dashboard);
setSessions(sessionData as SessionView[]);
setProjects(projectData);
setToolTypes(toolTypeData);
setStatus("ready");
} catch {
setSummary(null);
setStatus("error");
}
}, []);
useEffect(() => {
void loadSummary();
}, [loadSummary]);
void loadHome();
}, [loadHome]);
const cards = useMemo(() => CARDS, []);
const isEmpty =
status === "ready" &&
summary !== null &&
summary.projects === 0 &&
summary.repositories === 0 &&
summary.sshKeys === 0 &&
summary.recentActivity.length === 0;
useEffect(() => {
if (!selectedProject) {
setRepositories([]);
return;
}
const loadRepos = async () => {
try {
const data = await listRepositories(selectedProject);
setRepositories(data);
} catch {
setRepositories([]);
}
};
void loadRepos();
}, [selectedProject]);
const activeSessions = useMemo(
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
[safeSessions]
);
const recentSessions = useMemo(
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
[safeSessions]
);
const handleCreate = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedProject || !selectedRepo || !selectedToolType) return;
setSaveState("saving");
try {
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
await startInstance(selectedProject, selectedRepo, instance.id);
await updateUserConfig({ last_session_id: instance.id });
setDisplayName("");
setSelectedProject("");
setSelectedRepo("");
setSelectedToolType("");
setSaveState("idle");
await loadHome();
} catch {
setSaveState("error");
}
};
const handleOpen = (session: SessionView) => {
if (session.url) {
window.open(session.url, "_blank", "noopener,noreferrer");
return;
}
if (session.tool_type_interfaces.includes("terminal")) {
navigate(`/instances/${session.id}/terminal`);
return;
}
navigate(`/projects/${session.project_id}`);
};
const handleStop = async (session: SessionView) => {
setActionBusy(session.id);
try {
await stopInstance(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
const handleDelete = async (session: SessionView) => {
setActionBusy(session.id);
try {
await deleteInstance(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
const handleRecreateTunnel = async (session: SessionView) => {
setActionBusy(session.id);
try {
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
return (
<section className="stack">
<h1>Dashboard</h1>
<p className="muted">Your workspace overview will appear here.</p>
<section className="stack home-page">
<header className="home-hero card">
<div className="stack-sm">
<p className="eyebrow">Workspace overview</p>
<h1>Home</h1>
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
</div>
<div className="home-hero-actions">
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
</div>
</header>
{status === "loading" && <p className="muted">Loading dashboard...</p>}
{status === "loading" && <p className="muted">Loading overview...</p>}
{status === "error" && (
<div className="card stack">
<p>Dashboard is unavailable</p>
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
<p>Unable to load your workspace overview.</p>
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
<div className="card-grid">
{cards.map((card) => (
<article className="card" key={card.label}>
{status === "ready" && summary && (
<>
<div className="home-summary-grid">
{summaryCards.map((card) => (
<article className="card home-summary-card" key={card.label}>
<p className="card-label">{card.label}</p>
<p className="card-value">{summary ? String(summary[card.key]) : "-"}</p>
<p className="card-value">
{card.key === "openSessions"
? activeSessions.length
: card.key === "projects"
? summary.projects
: summary.repositories}
</p>
</article>
))}
</div>
{isEmpty && <p className="muted">No activity yet</p>}
<div className="quick-actions">
<button className="primary-button" type="button">
<Icon name="add" size="sm" />
New Project
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Open sessions</p>
<h2>{activeSessions.length}</h2>
</div>
</div>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions right now.</p>
) : (
<div className="home-session-grid">
{activeSessions.map((session) => (
<article className="card session-card" key={session.id}>
<div className="stack-sm">
<div className="row row-tight">
<h3>{session.display_name}</h3>
<span className={`status-badge ${session.status}`}>{session.status}</span>
</div>
<p className="muted">{session.project_name} · {session.repository_name}</p>
<p className="muted">{session.tool_type_name}</p>
</div>
<div className="session-actions">
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
<Icon name="external" size="sm" />
Open
</button>
<button className="secondary-button" type="button">
<Icon name="add" size="sm" />
Add Repository
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
<Icon name="refresh" size="sm" />
Tunnel
</button>
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
<Icon name="stop" size="sm" />
Stop
</button>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
<Icon name="delete" size="sm" />
Delete
</button>
</div>
</article>
))}
</div>
)}
</section>
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Available projects</p>
<h2>{projects.length}</h2>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
</div>
{projects.length === 0 ? (
<p className="muted">No projects yet.</p>
) : (
<div className="home-project-grid">
{projects.map((project) => (
<article className="card project-card home-project-card" key={project.id}>
<div className="stack-sm">
<h3>{project.name}</h3>
{project.description && <p className="muted">{project.description}</p>}
</div>
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
Open Workspace
</button>
</article>
))}
</div>
)}
</section>
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Quick create</p>
<h2>Start a session</h2>
</div>
</div>
<form className="stack create-session-form" onSubmit={handleCreate}>
<div className="form-row">
<label className="form-field">
Project
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
<option value="">Select project...</option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</label>
<label className="form-field">
Repository
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
<option value="">Select repository...</option>
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
</select>
</label>
<label className="form-field">
Tool type
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
<option value="">Select tool...</option>
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
</select>
</label>
</div>
<label className="form-field">
Display name
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
</label>
<div className="form-actions">
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
</button>
{saveState === "error" && <span className="error-text">Failed to create session</span>}
</div>
</form>
</section>
{recentSessions.length > 0 && (
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Recent sessions</p>
<h2>{recentSessions.length}</h2>
</div>
</div>
<div className="recent-sessions-list">
{recentSessions.map((session) => (
<article className="recent-session-item" key={session.id}>
<div className="recent-session-info">
<span className="recent-session-name">{session.display_name}</span>
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
</div>
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
</article>
))}
</div>
</section>
)}
</>
)}
</section>
);
};
export { HomePage as DashboardPage };
+41 -8
View File
@@ -1,4 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectsPage } from "./projects";
@@ -29,13 +30,21 @@ afterEach(() => {
describe("ProjectsPage", () => {
it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
});
it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
@@ -46,7 +55,11 @@ describe("ProjectsPage", () => {
it("renders empty state when no projects", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -55,7 +68,11 @@ describe("ProjectsPage", () => {
it("renders error state with retry button", async () => {
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
@@ -67,7 +84,11 @@ describe("ProjectsPage", () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -96,7 +117,11 @@ describe("ProjectsPage", () => {
it("shows validation error when name is empty", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -112,7 +137,11 @@ describe("ProjectsPage", () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
@@ -141,7 +170,11 @@ describe("ProjectsPage", () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
+76 -76
View File
@@ -1,17 +1,33 @@
import { useCallback, useEffect, useState } from "react";
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
import { Icon } from "../components/icon";
type SettingsStatus = "loading" | "ready" | "error";
const TABS = [
{ label: "General", path: "general" },
{ label: "SSH Keys", path: "ssh-keys" },
{ label: "Tool Types", path: "tool-types" },
{ label: "Tool Configs", path: "tool-configs" },
] as const;
const THEME_OPTIONS = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
];
type SettingsOutletContext = {
config: UserConfig;
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
handleSave: () => Promise<void>;
saveStatus: "idle" | "saving" | "saved" | "error";
};
export const SettingsPage = () => {
const location = useLocation();
const [status, setStatus] = useState<SettingsStatus>("loading");
const [config, setConfig] = useState<UserConfig>({
theme: "system",
@@ -50,21 +66,15 @@ export const SettingsPage = () => {
git_user_name: config.git_user_name,
git_user_email: config.git_user_email,
};
console.log("Sending update:", update);
const updated = await updateUserConfig(update);
console.log("Received response:", updated);
setConfig(updated);
setSaveStatus("saved");
// Apply theme immediately
const theme = updated.theme ?? "system";
if (theme === "system") {
if (updated.theme === "system") {
document.documentElement.removeAttribute("data-theme");
} else {
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.setAttribute("data-theme", updated.theme);
}
setTimeout(() => setSaveStatus("idle"), 2000);
window.setTimeout(() => setSaveStatus("idle"), 2000);
} catch {
setSaveStatus("error");
}
@@ -86,81 +96,71 @@ export const SettingsPage = () => {
);
}
const parts = location.pathname.split("/").filter(Boolean);
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
return (
<section className="stack">
<div className="page-header">
<section className="stack settings-page">
<header className="settings-header card stack-sm">
<div>
<p className="eyebrow">Configuration</p>
<h1>Settings</h1>
</div>
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
</header>
<div className="card stack">
<h2>Appearance</h2>
<label className="form-field">
Theme
<select
value={config.theme}
onChange={(e) => handleChange("theme", e.target.value)}
<nav className="settings-tabs" aria-label="Settings sections">
{TABS.map((tab) => (
<Link
key={tab.path}
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
{tab.label}
</Link>
))}
</select>
</label>
</div>
</nav>
<div className="card stack">
<h2>Git Identity</h2>
<label className="form-field">
User Name
<input
type="text"
value={config.git_user_name ?? ""}
onChange={(e) => handleChange("git_user_name", e.target.value || null)}
placeholder="Your git commit name"
/>
</label>
<label className="form-field">
User Email
<input
type="email"
value={config.git_user_email ?? ""}
onChange={(e) => handleChange("git_user_email", e.target.value || null)}
placeholder="your.email@example.com"
/>
</label>
</div>
<div className="card stack">
<h2>Editor</h2>
<label className="form-field">
Default Editor
<input
type="text"
value={config.default_editor ?? ""}
onChange={(e) => handleChange("default_editor", e.target.value || null)}
placeholder="e.g., vscode, vim, cursor"
/>
</label>
</div>
<div className="settings-actions">
<button className="primary-button" onClick={() => void handleSave()} type="button">
{saveStatus === "saving" ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save Settings
</>
)}
</button>
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
<div className="settings-panel card">
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
</div>
</section>
);
};
export const GeneralSettingsTab = () => {
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
return (
<div className="stack">
<h2>General</h2>
<label className="form-field">
Theme
<select value={config.theme} onChange={(e) => handleChange("theme", e.target.value)}>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</label>
<label className="form-field">
Git user name
<input type="text" value={config.git_user_name ?? ""} onChange={(e) => handleChange("git_user_name", e.target.value || null)} placeholder="Your git commit name" />
</label>
<label className="form-field">
Git user email
<input type="email" value={config.git_user_email ?? ""} onChange={(e) => handleChange("git_user_email", e.target.value || null)} placeholder="your.email@example.com" />
</label>
<label className="form-field">
Default editor
<input type="text" value={config.default_editor ?? ""} onChange={(e) => handleChange("default_editor", e.target.value || null)} placeholder="e.g., vscode, vim, cursor" />
</label>
<div className="settings-actions">
<button className="primary-button" onClick={() => void handleSave()} type="button">
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
</button>
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
</div>
</div>
);
};
+10
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { Icon } from "../components/icon";
export const SSHKeysPage = () => {
const navigate = useNavigate();
const [keys, setKeys] = useState<SSHKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -61,7 +63,15 @@ export const SSHKeysPage = () => {
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Settings</p>
<h1>SSH Keys</h1>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
Back to settings
</button>
</div>
{error && <div className="error">{error}</div>}
+8
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "../components/icon";
import { listToolTypes, type ToolType } from "../api/tool_types";
@@ -13,6 +14,7 @@ import {
type ConfigStatus = "loading" | "ready" | "error";
export const ToolConfigsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ConfigStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [configs, setConfigs] = useState<ToolConfig[]>([]);
@@ -135,7 +137,13 @@ export const ToolConfigsPage = () => {
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Configurations</h1>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
Back to settings
</button>
<p className="muted">
Manage environment variables and configuration files for your tools
</p>
+9 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
createToolType,
@@ -15,6 +16,7 @@ type ToolTypesStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ToolTypesPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ToolTypesStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
@@ -165,13 +167,19 @@ export const ToolTypesPage = () => {
return (
<div className="container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<div className="page-header" style={{ marginBottom: "1rem" }}>
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Types</h1>
</div>
<div className="row">
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
<button onClick={openCreate}>
<Icon name="add" size="sm" />
Create Tool Type
</button>
</div>
</div>
{toolTypes.length === 0 ? (
<p>No tool types found.</p>
+18 -10
View File
@@ -2,24 +2,29 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { AppShell } from "./components/app-shell";
import { ProtectedRoute } from "./components/protected-route";
import { DashboardPage } from "./pages/dashboard";
import { HomePage } from "./pages/dashboard";
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
import { SessionsPage } from "./pages/sessions";
import { ProfilePage } from "./pages/profile";
import { ProjectsPage } from "./pages/projects";
import { GitRepositoriesPage } from "./pages/git-repositories";
import { GitHistoryPage } from "./pages/git-history";
import { ProjectSettingsPage } from "./pages/project-settings";
import { RepoWorkspace } from "./pages/repo-workspace";
import { SSHKeysPage } from "./pages/ssh-keys";
import { SettingsPage } from "./pages/settings";
import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
import { TerminalPage } from "./pages/terminal";
import { ToolWorkshopPage } from "./pages/tool-workshop";
import { SSHKeysPage } from "./pages/ssh-keys";
import { ToolConfigsPage } from "./pages/tool-configs";
import { ToolTypesPage } from "./pages/tool-types";
export const AppRouter = () => {
return (
<Routes>
<Route path="/login" element={<LoginRedirectPage />} />
<Route path="/sessions" element={<Navigate to="/" replace />} />
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
<Route path="/tool-types" element={<Navigate to="/settings/tool-types" replace />} />
<Route path="/tool-configs" element={<Navigate to="/settings/tool-configs" replace />} />
<Route
path="/"
element={
@@ -28,19 +33,22 @@ export const AppRouter = () => {
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="sessions" element={<SessionsPage />} />
<Route index element={<HomePage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId" element={<RepoWorkspace />} />
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="settings" element={<SettingsPage />}>
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<GeneralSettingsTab />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="tool-types" element={<ToolTypesPage />} />
<Route path="tool-configs" element={<ToolConfigsPage />} />
<Route path="*" element={<Navigate to="general" replace />} />
</Route>
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
<Route path="tool-types" element={<Navigate to="/tool-workshop" replace />} />
<Route path="tool-configs" element={<Navigate to="/tool-workshop" replace />} />
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
</Route>
<Route path="/404" element={<NotFoundPage />} />
+148 -10
View File
@@ -1,6 +1,6 @@
:root {
color-scheme: light;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
--bg: #f4f1ea;
--panel: #fffef9;
--ink: #1d1d1b;
@@ -8,6 +8,17 @@
--brand: #275d4b;
--brand-strong: #154236;
--border: #d8d0c5;
--primary: #275d4b;
--primary-fg: #fffef9;
--color-primary: #275d4b;
--success: #2f8f62;
--success-light: rgba(47, 143, 98, 0.14);
--warning: #c08a1e;
--warning-light: rgba(192, 138, 30, 0.14);
--danger: #b94a3c;
--danger-light: rgba(185, 74, 60, 0.14);
--info: #4f7fb8;
--info-light: rgba(79, 127, 184, 0.14);
/* Spacing Scale (4px base) */
--space-1: 0.25rem;
@@ -36,13 +47,16 @@
[data-theme="dark"] {
color-scheme: dark;
--bg: #1a1a18;
--panel: #252522;
--ink: #e8e6e1;
--muted: #a39e96;
--brand: #4a9e7f;
--brand-strong: #3d8a6e;
--border: #3d3d38;
--bg: #171613;
--panel: #22201d;
--ink: #ece7df;
--muted: #a59d92;
--brand: #5fa889;
--brand-strong: #4d9175;
--border: #39342d;
--primary: #5fa889;
--primary-fg: #171613;
--color-primary: #5fa889;
--success: #22c55e;
--success-light: rgba(34, 197, 94, 0.15);
--warning: #f59e0b;
@@ -84,12 +98,12 @@ a {
align-items: center;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.85);
background: color-mix(in srgb, var(--panel) 88%, transparent);
backdrop-filter: blur(7px);
}
[data-theme="dark"] .shell-header {
background: rgba(37, 37, 34, 0.85);
background: color-mix(in srgb, var(--panel) 88%, transparent);
}
.brand {
@@ -115,6 +129,7 @@ a {
display: flex;
flex-direction: column;
gap: 0.4rem;
background: color-mix(in srgb, var(--panel) 65%, transparent);
}
.nav-item {
@@ -133,11 +148,134 @@ a {
color: #f7fff7;
}
.nav-section-title {
margin-top: 0.5rem;
padding: 0.25rem 0.75rem;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.nav-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.shell-content {
padding: 1.25rem;
overflow-x: hidden;
}
.eyebrow {
margin: 0;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.home-page,
.settings-page {
max-width: 1240px;
}
.home-hero {
display: flex;
justify-content: space-between;
gap: var(--space-4);
align-items: flex-start;
}
.home-hero-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.home-summary-grid,
.home-project-grid,
.home-session-grid {
display: grid;
gap: var(--space-4);
}
.home-summary-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.home-project-grid,
.home-session-grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.home-section h2,
.settings-header h1,
.settings-panel h2 {
margin: 0;
}
.home-section h3,
.home-section p {
margin: 0;
}
.row-tight {
gap: var(--space-2);
}
.settings-tabs {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.settings-tab {
padding: 0.6rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
background: var(--panel);
}
.settings-tab.active {
background: var(--brand);
color: white;
border-color: transparent;
}
.settings-panel {
padding: 1.5rem;
}
.settings-actions,
.form-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.error-text {
color: var(--danger);
}
.success-text {
color: var(--success);
}
.small {
padding: 0.42rem 0.7rem;
min-height: 38px;
}
.session-card,
.project-card,
.recent-session-item {
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
}
/* Responsive Shell */
@media (max-width: 767px) {
.shell-body {
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-22
@@ -0,0 +1,106 @@
# UI Redesign - Design
## Information Architecture
```
App
├── Home
│ ├── Hero / status
│ ├── Open sessions
│ ├── Available projects
│ └── Session creation
├── Projects
├── Settings
│ ├── General
│ ├── SSH Keys
│ ├── Tool Types
│ └── Tool Configs
└── Legacy routes
└── Redirect to new locations
```
## Home Page
### Purpose
Provide a fast, glanceable overview of the user's active work.
### Sections
1. **Hero**
- Greeting
- Short status line
- Primary actions: New Project, Open Session, Settings
2. **Summary strip**
- Small count cards for sessions, projects, and tooling state
3. **Open Sessions**
- Primary section
- Session cards with project, repository, tool type, status, and actions
4. **Available Projects**
- Secondary section
- Project cards with quick entry into the project workspace
5. **Session composer**
- Optional compact create flow if it fits the page cleanly
## Settings Page
### Layout
Tabbed shell with one content area and four tabs:
- General
- SSH Keys
- Tool Types
- Tool Configs
### Tab Responsibilities
**General**
- Theme
- Git identity
- Default editor
**SSH Keys**
- List keys
- Create key
- Copy public key
- Delete key
**Tool Types**
- Browse tool catalog
- Edit custom tool types
- Delete custom tool types
**Tool Configs**
- Browse per-tool configurations
- Add/edit/delete configs
- Keep the existing config model and API behavior
## Visual Direction
- Font: Inter for UI text
- Code font: monospace only for technical fields
- Palette: warm light surfaces, forest green primary, muted utility accents
- Dark mode: charcoal surfaces with softened accents
- Styling: editorial, structured, high-contrast hierarchy, minimal chrome
## Routing
- `/` -> Home
- `/sessions` -> redirect to `/`
- `/settings` -> General tab
- `/settings/ssh-keys` -> SSH Keys tab
- `/settings/tool-types` -> Tool Types tab
- `/settings/tool-configs` -> Tool Configs tab
- legacy `/ssh-keys`, `/tool-types`, `/tool-configs` -> redirect to settings tabs
## Component Strategy
- Reuse shell and existing APIs
- Replace the dashboard page with the new home overview
- Convert the settings layout into a shared tab shell
- Keep changes focused to the frontend layer
@@ -0,0 +1,35 @@
# UI Redesign: Home + Settings
## Problem
The current authenticated UI is functional but fragmented. Sessions, tool setup, and settings are spread across top-level pages, and the home screen does not yet provide a strong overview of open sessions and available projects.
## Solution
Redesign the authenticated frontend around two primary surfaces:
1. **Home**: an overview of open sessions and available projects
2. **Settings**: a tabbed settings hub with General, SSH Keys, Tool Types, and Tool Configs
Keep existing functionality and the Project -> Repository -> Session hierarchy intact. Reuse the current APIs and workflows.
## Scope
- Redesign the main landing page into an operational overview
- Fold the Sessions page into the home experience
- Convert SSH Keys, Tool Types, and Tool Configs into settings tabs
- Update navigation and routes to match the new IA
- Refresh visual design, typography, and spacing
## Non-Goals
- No backend behavior changes
- No new session or project APIs
- No changes to the project/repository/session data model
## Success Criteria
- Home shows open sessions and available projects clearly
- Settings contains tabs for General, SSH Keys, Tool Types, Tool Configs
- Old top-level settings-related routes redirect to the new structure
- Visual system uses Inter and a refined warm palette
@@ -0,0 +1,34 @@
# UI Redesign - Tasks
## 1. Visual System
- [ ] Update global typography to Inter
- [ ] Refine color tokens for the new warm editorial palette
- [ ] Add styling for new home sections and settings tabs
## 2. Navigation and Routing
- [ ] Remove Sessions from top-level navigation
- [ ] Keep SSH Keys, Tool Types, and Tool Configs accessible from Settings tabs
- [ ] Add redirects for legacy top-level config routes
- [ ] Redirect `/sessions` to `/`
## 3. Home Page
- [ ] Redesign the home page as an overview of open sessions and projects
- [ ] Add summary cards and hero actions
- [ ] Reuse existing session and project data
- [ ] Keep create/open session actions available
## 4. Settings Hub
- [ ] Turn Settings into a tabbed hub
- [ ] Build General, SSH Keys, Tool Types, and Tool Configs tabs
- [ ] Reuse existing APIs and forms
- [ ] Keep the Project settings page separate
## 5. Cleanup and Verification
- [ ] Remove obsolete top-level pages from navigation flow
- [ ] Update tests for the new landing page and redirects
- [ ] Run typecheck, lint, and build