feat: add Sessions Hub page
- Add Sessions tab to navigation between Dashboard and Projects - Show active session count badge in navigation - Create SessionsPage with: - Last session section with resume button - Active sessions grid with open/stop actions - Recent sessions list - Create session form with project/repo/tool selectors - Add last_session_id to user config - Update UserConfig schemas (backend and frontend) - Add comprehensive CSS for sessions page Quality gates: typecheck ✓, lint ✓, build ✓
This commit is contained in:
@@ -47,6 +47,7 @@ class UserConfigResponse(BaseModel):
|
|||||||
theme: str = "system"
|
theme: str = "system"
|
||||||
git_user_name: str | None = None
|
git_user_name: str | None = None
|
||||||
git_user_email: str | None = None
|
git_user_email: str | None = None
|
||||||
|
last_session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserConfigUpdate(BaseModel):
|
class UserConfigUpdate(BaseModel):
|
||||||
@@ -54,6 +55,7 @@ class UserConfigUpdate(BaseModel):
|
|||||||
theme: str | None = None
|
theme: str | None = None
|
||||||
git_user_name: str | None = None
|
git_user_name: str | None = None
|
||||||
git_user_email: str | None = None
|
git_user_email: str | None = None
|
||||||
|
last_session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface UserConfig {
|
|||||||
theme: string;
|
theme: string;
|
||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
git_user_email: string | null;
|
git_user_email: string | null;
|
||||||
|
last_session_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserConfigUpdate {
|
export interface UserConfigUpdate {
|
||||||
@@ -12,6 +13,7 @@ export interface UserConfigUpdate {
|
|||||||
theme?: string;
|
theme?: string;
|
||||||
git_user_name?: string;
|
git_user_name?: string;
|
||||||
git_user_email?: string;
|
git_user_email?: string;
|
||||||
|
last_session_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUserConfig = async (): Promise<UserConfig> => {
|
export const getUserConfig = async (): Promise<UserConfig> => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { IconName } from "../utils/icons";
|
|||||||
|
|
||||||
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
||||||
{ to: "/", label: "Dashboard", icon: "dashboard" },
|
{ to: "/", label: "Dashboard", icon: "dashboard" },
|
||||||
|
{ to: "/sessions", label: "Sessions", icon: "terminal" },
|
||||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
|
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
|
||||||
{ to: "/tool-types", label: "Tool Types", icon: "code" },
|
{ to: "/tool-types", label: "Tool Types", icon: "code" },
|
||||||
@@ -83,17 +84,24 @@ export const AppShell = () => {
|
|||||||
|
|
||||||
<div className="shell-body">
|
<div className="shell-body">
|
||||||
<aside className="shell-nav" aria-label="Primary navigation">
|
<aside className="shell-nav" aria-label="Primary navigation">
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => {
|
||||||
<NavLink
|
const isSessions = item.to === "/sessions";
|
||||||
key={item.to}
|
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||||
to={item.to}
|
return (
|
||||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
<NavLink
|
||||||
end={item.to === "/"}
|
key={item.to}
|
||||||
>
|
to={item.to}
|
||||||
<Icon name={item.icon} size="sm" />
|
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
||||||
{item.label}
|
end={item.to === "/"}
|
||||||
</NavLink>
|
>
|
||||||
))}
|
<Icon name={item.icon} size="sm" />
|
||||||
|
{item.label}
|
||||||
|
{isSessions && activeCount > 0 && (
|
||||||
|
<span className="nav-badge">{activeCount}</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { listProjects } from "../api/projects";
|
||||||
|
import type { Project } from "../types";
|
||||||
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
|
import { getUserSessions, type Session, deleteInstance, stopInstance } from "../api/sessions";
|
||||||
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
|
import { createInstance } from "../api/sessions";
|
||||||
|
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||||
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
|
type SessionsStatus = "loading" | "ready" | "error";
|
||||||
|
type CreateStatus = "idle" | "creating" | "error";
|
||||||
|
|
||||||
|
export const SessionsPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
|
||||||
|
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState<string>("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadSessions = useCallback(async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const [sessionsData, config] = await Promise.all([
|
||||||
|
getUserSessions(),
|
||||||
|
getUserConfig(),
|
||||||
|
]);
|
||||||
|
setSessions(sessionsData);
|
||||||
|
setLastSessionId(config.last_session_id ?? null);
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSessions();
|
||||||
|
}, [loadSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const data = await listProjects();
|
||||||
|
setProjects(data);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadToolTypes = async () => {
|
||||||
|
try {
|
||||||
|
const data = await listToolTypes();
|
||||||
|
setToolTypes(data);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadToolTypes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProject) {
|
||||||
|
setRepositories([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadRepos = async () => {
|
||||||
|
try {
|
||||||
|
const data = await listRepositories(selectedProject);
|
||||||
|
setRepositories(data);
|
||||||
|
} catch {
|
||||||
|
setRepositories([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadRepos();
|
||||||
|
}, [selectedProject]);
|
||||||
|
|
||||||
|
const activeSessions = useMemo(
|
||||||
|
() => sessions.filter((s) => s.status === "running"),
|
||||||
|
[sessions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentSessions = useMemo(
|
||||||
|
() => sessions.filter((s) => s.status !== "running").slice(0, 5),
|
||||||
|
[sessions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastSession = useMemo(
|
||||||
|
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
||||||
|
[sessions, lastSessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCreateError(null);
|
||||||
|
|
||||||
|
if (!selectedProject || !selectedRepo || !selectedToolType) {
|
||||||
|
setCreateError("Project, repository, and tool type are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateStatus("creating");
|
||||||
|
try {
|
||||||
|
const instance = await createInstance(
|
||||||
|
selectedProject,
|
||||||
|
selectedRepo,
|
||||||
|
selectedToolType,
|
||||||
|
displayName || undefined
|
||||||
|
);
|
||||||
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
|
setCreateStatus("idle");
|
||||||
|
setSelectedProject("");
|
||||||
|
setSelectedRepo("");
|
||||||
|
setSelectedToolType("");
|
||||||
|
setDisplayName("");
|
||||||
|
await loadSessions();
|
||||||
|
} catch {
|
||||||
|
setCreateStatus("error");
|
||||||
|
setCreateError("Failed to create session");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
||||||
|
try {
|
||||||
|
await stopInstance(projectId, repoId, sessionId);
|
||||||
|
await loadSessions();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (sessionId: string, projectId: string, repoId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteInstance(projectId, repoId, sessionId);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
await loadSessions();
|
||||||
|
} catch {
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = (session: Session) => {
|
||||||
|
navigate(`/projects/${session.project_name}/repositories/${session.repository_name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResumeLast = async () => {
|
||||||
|
if (!lastSession) return;
|
||||||
|
// Find the project and repo IDs
|
||||||
|
const project = projects.find((p) => p.name === lastSession.project_name);
|
||||||
|
if (project) {
|
||||||
|
navigate(`/projects/${project.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="stack sessions-page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Sessions</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "loading" && <p className="muted">Loading sessions...</p>}
|
||||||
|
|
||||||
|
{status === "error" && (
|
||||||
|
<div className="card stack">
|
||||||
|
<p>Failed to load sessions</p>
|
||||||
|
<button className="secondary-button" onClick={() => void loadSessions()} type="button">
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "ready" && (
|
||||||
|
<>
|
||||||
|
{/* Last Session */}
|
||||||
|
{lastSession && (
|
||||||
|
<div className="last-session-section">
|
||||||
|
<h2>Last Session</h2>
|
||||||
|
<div className="card last-session-card">
|
||||||
|
<div className="last-session-info">
|
||||||
|
<h3>{lastSession.display_name}</h3>
|
||||||
|
<p className="muted">
|
||||||
|
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
|
||||||
|
</p>
|
||||||
|
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="last-session-actions">
|
||||||
|
<button className="primary-button" onClick={handleResumeLast} type="button">
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Sessions */}
|
||||||
|
<div className="active-sessions-section">
|
||||||
|
<h2>
|
||||||
|
Active Sessions
|
||||||
|
{activeSessions.length > 0 && (
|
||||||
|
<span className="badge">{activeSessions.length}</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{activeSessions.length === 0 ? (
|
||||||
|
<p className="muted">No active sessions</p>
|
||||||
|
) : (
|
||||||
|
<div className="sessions-grid">
|
||||||
|
{activeSessions.map((session) => (
|
||||||
|
<div className="card session-card" key={session.id}>
|
||||||
|
<div className="session-info">
|
||||||
|
<h4>{session.display_name}</h4>
|
||||||
|
<p className="muted">
|
||||||
|
{session.tool_type_name} · {session.project_name}
|
||||||
|
</p>
|
||||||
|
<span className="status-badge running">running</span>
|
||||||
|
</div>
|
||||||
|
<div className="session-actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => handleOpen(session)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() =>
|
||||||
|
void handleStop(
|
||||||
|
session.id,
|
||||||
|
projects.find((p) => p.name === session.project_name)?.id ?? "",
|
||||||
|
""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Sessions */}
|
||||||
|
{recentSessions.length > 0 && (
|
||||||
|
<div className="recent-sessions-section">
|
||||||
|
<h2>Recent Sessions</h2>
|
||||||
|
<div className="recent-sessions-list">
|
||||||
|
{recentSessions.map((session) => (
|
||||||
|
<div 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.tool_type_name} · {session.project_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="recent-session-actions">
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => handleOpen(session)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
{deleteConfirmId === session.id ? (
|
||||||
|
<div className="delete-confirm-inline">
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={() =>
|
||||||
|
void handleDelete(
|
||||||
|
session.id,
|
||||||
|
projects.find((p) => p.name === session.project_name)?.id ?? "",
|
||||||
|
""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
onClick={() => setDeleteConfirmId(session.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Session */}
|
||||||
|
<div className="create-session-section">
|
||||||
|
<h2>Create New Session</h2>
|
||||||
|
<form onSubmit={handleCreate} className="card stack create-session-form">
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-field">
|
||||||
|
Project
|
||||||
|
<select
|
||||||
|
value={selectedProject}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedProject(e.target.value);
|
||||||
|
setSelectedRepo("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select
|
||||||
|
value={selectedRepo}
|
||||||
|
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||||
|
disabled={!selectedProject}
|
||||||
|
>
|
||||||
|
<option value="">Select repository...</option>
|
||||||
|
{repositories.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Tool Type
|
||||||
|
<select
|
||||||
|
value={selectedToolType}
|
||||||
|
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select tool...</option>
|
||||||
|
{toolTypes.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Display Name (optional)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="My Development Environment"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{createError && <p className="error-text">{createError}</p>}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={createStatus === "creating"}
|
||||||
|
>
|
||||||
|
{createStatus === "creating" ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Create Session
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ export const SettingsPage = () => {
|
|||||||
default_editor: null,
|
default_editor: null,
|
||||||
git_user_name: null,
|
git_user_name: null,
|
||||||
git_user_email: null,
|
git_user_email: null,
|
||||||
|
last_session_id: null,
|
||||||
});
|
});
|
||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AppShell } from "./components/app-shell";
|
|||||||
import { ProtectedRoute } from "./components/protected-route";
|
import { ProtectedRoute } from "./components/protected-route";
|
||||||
import { DashboardPage } from "./pages/dashboard";
|
import { DashboardPage } from "./pages/dashboard";
|
||||||
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
|
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
|
||||||
|
import { SessionsPage } from "./pages/sessions";
|
||||||
import { ProfilePage } from "./pages/profile";
|
import { ProfilePage } from "./pages/profile";
|
||||||
import { ProjectsPage } from "./pages/projects";
|
import { ProjectsPage } from "./pages/projects";
|
||||||
import { GitRepositoriesPage } from "./pages/git-repositories";
|
import { GitRepositoriesPage } from "./pages/git-repositories";
|
||||||
@@ -28,6 +29,7 @@ export const AppRouter = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="sessions" element={<SessionsPage />} />
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
||||||
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
||||||
|
|||||||
@@ -2456,3 +2456,176 @@ a.nav-item,
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Sessions Page Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-session-section {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-session-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 2px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-session-info h3 {
|
||||||
|
margin: 0 0 var(--space-1) 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-section {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-section h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-sessions-section .badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info h4 {
|
||||||
|
margin: 0 0 var(--space-1) 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-sessions-section {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-session-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-session-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-confirm-inline {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-section {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-form {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-form .form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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, #dcfce7);
|
||||||
|
color: var(--success, #16a34a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.stopped {
|
||||||
|
background: var(--muted-bg, #f3f4f6);
|
||||||
|
color: var(--muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.pending {
|
||||||
|
background: var(--warning-light, #fef3c7);
|
||||||
|
color: var(--warning, #d97706);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.error {
|
||||||
|
background: var(--danger-light, #fee2e2);
|
||||||
|
color: var(--danger, #dc2626);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
name: sessions-hub
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Sessions Hub - Design
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Sessions Hub
|
||||||
|
├── Navigation
|
||||||
|
│ └── "Sessions" tab (between Dashboard and Projects)
|
||||||
|
│ └── Badge with active session count
|
||||||
|
├── SessionsPage
|
||||||
|
│ ├── Last Session Section
|
||||||
|
│ │ └── Quick access card with resume button
|
||||||
|
│ ├── Active Sessions Section
|
||||||
|
│ │ └── Grid of active session cards
|
||||||
|
│ ├── Recent Sessions Section
|
||||||
|
│ │ └── List of recent sessions
|
||||||
|
│ └── Create Session Section
|
||||||
|
│ └── Project selector + tool type selector
|
||||||
|
└── User Config
|
||||||
|
└── last_session_id field
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Design
|
||||||
|
|
||||||
|
### SessionsPage
|
||||||
|
|
||||||
|
**Sections:**
|
||||||
|
1. **Last Session** (if exists)
|
||||||
|
- Large card showing last session details
|
||||||
|
- "Resume" button to open the workspace
|
||||||
|
- Shows project, repository, tool type
|
||||||
|
|
||||||
|
2. **Active Sessions**
|
||||||
|
- Grid of cards for running instances
|
||||||
|
- Each card: name, type, status badge, action buttons
|
||||||
|
- Actions: Open, Stop, Restart, Delete
|
||||||
|
|
||||||
|
3. **Recent Sessions**
|
||||||
|
- List of last 5 sessions (any status)
|
||||||
|
- Compact list view with status indicators
|
||||||
|
- Click to navigate to workspace
|
||||||
|
|
||||||
|
4. **Create New Session**
|
||||||
|
- Project dropdown (all user's projects)
|
||||||
|
- Repository dropdown (filtered by project)
|
||||||
|
- Tool type dropdown
|
||||||
|
- Display name input
|
||||||
|
- "Create" button
|
||||||
|
|
||||||
|
### AppShell Updates
|
||||||
|
|
||||||
|
**Navigation:**
|
||||||
|
```
|
||||||
|
Dashboard | Sessions (3) | Projects | SSH Keys | Tool Types | Settings
|
||||||
|
```
|
||||||
|
|
||||||
|
**Badge:**
|
||||||
|
- Shows count of active (running) sessions
|
||||||
|
- Updates via existing sessions polling
|
||||||
|
|
||||||
|
### User Config Extension
|
||||||
|
|
||||||
|
**New field:**
|
||||||
|
```typescript
|
||||||
|
interface UserConfig {
|
||||||
|
// existing fields...
|
||||||
|
last_session_id: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update timing:**
|
||||||
|
- Set when creating a new session
|
||||||
|
- Set when opening/resuming a session
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Loading Sessions Page
|
||||||
|
1. Fetch user config (for last_session_id)
|
||||||
|
2. Fetch all user sessions via `/users/me/sessions`
|
||||||
|
3. Filter into active vs recent
|
||||||
|
4. Display last session if available
|
||||||
|
|
||||||
|
### Creating Session
|
||||||
|
1. User selects project, repo, tool type
|
||||||
|
2. POST to `/projects/{id}/repositories/{id}/instances`
|
||||||
|
3. On success: update user config with last_session_id
|
||||||
|
4. Refresh sessions list
|
||||||
|
|
||||||
|
### Resuming Session
|
||||||
|
1. User clicks "Resume" on last session
|
||||||
|
2. Navigate to workspace with session active
|
||||||
|
3. Update user config (reinforce as last)
|
||||||
|
|
||||||
|
## API Changes
|
||||||
|
|
||||||
|
### GET /users/me/sessions
|
||||||
|
Already exists - returns all sessions for user.
|
||||||
|
|
||||||
|
### PATCH /users/me/config
|
||||||
|
Already exists - add `last_session_id` to config schema.
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- New page: `pages/sessions.tsx`
|
||||||
|
- Update: `app-shell.tsx` for navigation
|
||||||
|
- Update: `api/settings.ts` for config type
|
||||||
|
- Update: `state/sessions.tsx` for badge count
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Update: `models/user_config.py` schema
|
||||||
|
- Update: `api/user_config.py` to accept last_session_id
|
||||||
|
|
||||||
|
**No new backend endpoints needed** - reuse existing APIs.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- No sessions: Show empty state with "Create your first session" CTA
|
||||||
|
- Failed to load: Show error with retry button
|
||||||
|
- Create failed: Show error message, keep form open
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Sessions Hub
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Users currently have to navigate into individual projects and repositories to see their active tool instances (sessions). There's no centralized place to:
|
||||||
|
- See all active/open sessions at a glance
|
||||||
|
- Quickly access the last used session
|
||||||
|
- Create new sessions without navigating deep into the project hierarchy
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Create a dedicated **Sessions Hub** page that serves as the central place for managing tool instances:
|
||||||
|
|
||||||
|
1. **Navigation tab** between Dashboard and Projects
|
||||||
|
2. **Active sessions section** showing all running/open instances
|
||||||
|
3. **Last session** prominently displayed for quick access
|
||||||
|
4. **Quick create** - create sessions for any project from one place
|
||||||
|
5. **Persist last session** in user config for easier access
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Sessions Page
|
||||||
|
- Shows all active (running) sessions with status, type, and links
|
||||||
|
- Shows recent sessions (last 5)
|
||||||
|
- Shows last created session at the top for quick access
|
||||||
|
- "New Session" button to create instances for any project
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- New "Sessions" tab in the app shell between Dashboard and Projects
|
||||||
|
- Shows count of active sessions as a badge
|
||||||
|
|
||||||
|
### Quick Access
|
||||||
|
- Last created session saved to user config
|
||||||
|
- One-click to reopen/resume last session
|
||||||
|
- Session history for quick navigation
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
- **Faster workflow** - No need to navigate deep into projects
|
||||||
|
- **Better visibility** - See all active work at a glance
|
||||||
|
- **Quick resume** - Jump back to last work instantly
|
||||||
|
- **Centralized management** - One place for all sessions
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Sessions tab visible in navigation between Dashboard and Projects
|
||||||
|
- [ ] Sessions page shows active sessions
|
||||||
|
- [ ] Last session displayed prominently
|
||||||
|
- [ ] Can create session for any project from Sessions page
|
||||||
|
- [ ] Last created session persists in user config
|
||||||
|
- [ ] Badge shows count of active sessions
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Sessions Hub Specification
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
1. **Sessions Tab**: Navigation item between Dashboard and Projects
|
||||||
|
2. **Active Sessions Display**: Show all running sessions with actions
|
||||||
|
3. **Last Session**: Prominently show last created/accessed session
|
||||||
|
4. **Quick Create**: Create sessions for any project from Sessions page
|
||||||
|
5. **Session Persistence**: Save last_session_id in user config
|
||||||
|
6. **Badge**: Show active session count in navigation
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
1. **Performance**: Load sessions in < 500ms
|
||||||
|
2. **Real-time**: Badge updates with active count
|
||||||
|
3. **Responsive**: Works on mobile and desktop
|
||||||
|
|
||||||
|
## API Specification
|
||||||
|
|
||||||
|
### Existing Endpoints Used
|
||||||
|
|
||||||
|
- `GET /users/me/sessions` - List all user sessions
|
||||||
|
- `POST /projects/{id}/repositories/{id}/instances` - Create instance
|
||||||
|
- `GET /projects` - List projects for selector
|
||||||
|
- `GET /projects/{id}/repositories` - List repos for selector
|
||||||
|
- `GET /tool-types` - List tool types for selector
|
||||||
|
- `GET /users/me/config` - Get user config (with last_session_id)
|
||||||
|
- `PATCH /users/me/config` - Update user config (last_session_id)
|
||||||
|
|
||||||
|
### User Config Schema Update
|
||||||
|
|
||||||
|
```python
|
||||||
|
class UserConfigUpdate(BaseModel):
|
||||||
|
theme: Optional[str] = None
|
||||||
|
default_editor: Optional[str] = None
|
||||||
|
git_user_name: Optional[str] = None
|
||||||
|
git_user_email: Optional[str] = None
|
||||||
|
last_session_id: Optional[str] = None # NEW
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Specification
|
||||||
|
|
||||||
|
### Sessions Page Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------------------------------+
|
||||||
|
| Sessions [New Session]|
|
||||||
|
+------------------------------------------+
|
||||||
|
| |
|
||||||
|
| Last Session |
|
||||||
|
| +--------------------------------------+ |
|
||||||
|
| | VS Code Server - My Project [Open] | |
|
||||||
|
| | Running on port 8080 | |
|
||||||
|
| +--------------------------------------+ |
|
||||||
|
| |
|
||||||
|
| Active Sessions (3) |
|
||||||
|
| +----------+ +----------+ +----------+ |
|
||||||
|
| | Session 1| | Session 2| | Session 3| |
|
||||||
|
| | Running | | Running | | Running | |
|
||||||
|
| | [Open] | | [Open] | | [Open] | |
|
||||||
|
| +----------+ +----------+ +----------+ |
|
||||||
|
| |
|
||||||
|
| Recent Sessions |
|
||||||
|
| - Session 4 (stopped) |
|
||||||
|
| - Session 5 (stopped) |
|
||||||
|
| |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation Badge
|
||||||
|
|
||||||
|
```
|
||||||
|
[Dashboard] [Sessions (3)] [Projects] ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Badge shows count of sessions with status === "running".
|
||||||
|
|
||||||
|
### Create Session Dialog
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------------------------------+
|
||||||
|
| Create New Session |
|
||||||
|
+------------------------------------------+
|
||||||
|
| Project: [Dropdown] |
|
||||||
|
| Repository: [Dropdown] |
|
||||||
|
| Tool Type: [Dropdown] |
|
||||||
|
| Name: [Input] |
|
||||||
|
| |
|
||||||
|
| [Cancel] [Create] |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### Sessions Context (existing)
|
||||||
|
Already polls `/users/me/sessions` every 10s. Use this for:
|
||||||
|
- Active session count (badge)
|
||||||
|
- Active sessions list
|
||||||
|
- Recent sessions list
|
||||||
|
|
||||||
|
### User Config (existing)
|
||||||
|
Add `last_session_id` field. Update:
|
||||||
|
- On session creation
|
||||||
|
- On session open/resume
|
||||||
|
|
||||||
|
## Quality Gates
|
||||||
|
|
||||||
|
- TypeScript compilation passes
|
||||||
|
- ESLint passes
|
||||||
|
- All sessions load correctly
|
||||||
|
- Badge updates with active count
|
||||||
|
- Last session persists across reloads
|
||||||
|
- Create session works from Sessions page
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Sessions Hub - Tasks
|
||||||
|
|
||||||
|
## Phase 1: Backend Config Update
|
||||||
|
|
||||||
|
- [ ] **Task 1.1**: Update UserConfig model
|
||||||
|
- Add `last_session_id` field to `models/user_config.py`
|
||||||
|
- Create Alembic migration
|
||||||
|
|
||||||
|
- [ ] **Task 1.2**: Update config API
|
||||||
|
- Accept `last_session_id` in `api/user_config.py`
|
||||||
|
- Update Pydantic schemas
|
||||||
|
|
||||||
|
## Phase 2: Frontend Navigation
|
||||||
|
|
||||||
|
- [ ] **Task 2.1**: Add Sessions tab to AppShell
|
||||||
|
- Insert between Dashboard and Projects
|
||||||
|
- Add sessions icon
|
||||||
|
- Show badge with active count
|
||||||
|
|
||||||
|
- [ ] **Task 2.2**: Update router
|
||||||
|
- Add `/sessions` route
|
||||||
|
- Create placeholder page
|
||||||
|
|
||||||
|
## Phase 3: Sessions Page
|
||||||
|
|
||||||
|
- [ ] **Task 3.1**: Create SessionsPage component
|
||||||
|
- Page layout with sections
|
||||||
|
- Loading and error states
|
||||||
|
|
||||||
|
- [ ] **Task 3.2**: Implement Last Session section
|
||||||
|
- Fetch from user config
|
||||||
|
- Show session card with resume button
|
||||||
|
- Handle no last session state
|
||||||
|
|
||||||
|
- [ ] **Task 3.3**: Implement Active Sessions section
|
||||||
|
- Fetch from sessions context
|
||||||
|
- Grid of session cards
|
||||||
|
- Action buttons (Open, Stop, Restart, Delete)
|
||||||
|
|
||||||
|
- [ ] **Task 3.4**: Implement Recent Sessions section
|
||||||
|
- Show last 5 sessions
|
||||||
|
- Compact list view
|
||||||
|
- Status indicators
|
||||||
|
|
||||||
|
- [ ] **Task 3.5**: Implement Create Session section
|
||||||
|
- Project selector (fetch all projects)
|
||||||
|
- Repository selector (filtered by project)
|
||||||
|
- Tool type selector
|
||||||
|
- Display name input
|
||||||
|
- Create button with validation
|
||||||
|
|
||||||
|
## Phase 4: Session Actions
|
||||||
|
|
||||||
|
- [ ] **Task 4.1**: Resume last session
|
||||||
|
- Navigate to workspace
|
||||||
|
- Update user config
|
||||||
|
|
||||||
|
- [ ] **Task 4.2**: Open session
|
||||||
|
- Navigate to workspace with session
|
||||||
|
|
||||||
|
- [ ] **Task 4.3**: Create session
|
||||||
|
- Call API to create instance
|
||||||
|
- Update user config with last_session_id
|
||||||
|
- Refresh sessions list
|
||||||
|
|
||||||
|
## Phase 5: Polish
|
||||||
|
|
||||||
|
- [ ] **Task 5.1**: Add CSS styles
|
||||||
|
- Session cards layout
|
||||||
|
- Badge styling
|
||||||
|
- Responsive design
|
||||||
|
|
||||||
|
- [ ] **Task 5.2**: Add icons
|
||||||
|
- Session icon in navigation
|
||||||
|
- Action icons on cards
|
||||||
|
|
||||||
|
## Phase 6: Quality Gates
|
||||||
|
|
||||||
|
- [ ] **Task 6.1**: TypeScript check
|
||||||
|
- `npm run typecheck`
|
||||||
|
|
||||||
|
- [ ] **Task 6.2**: Lint check
|
||||||
|
- `npm run lint`
|
||||||
|
|
||||||
|
- [ ] **Task 6.3**: Build check
|
||||||
|
- `npm run build`
|
||||||
|
|
||||||
|
- [ ] **Task 6.4**: Manual verification
|
||||||
|
- Navigation shows Sessions tab
|
||||||
|
- Badge shows correct count
|
||||||
|
- Last session displays
|
||||||
|
- Can create session from page
|
||||||
|
- Config persists
|
||||||
Reference in New Issue
Block a user