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"
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
last_session_id: str | None = None
|
||||
|
||||
|
||||
class UserConfigUpdate(BaseModel):
|
||||
@@ -54,6 +55,7 @@ class UserConfigUpdate(BaseModel):
|
||||
theme: str | None = None
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
last_session_id: str | None = None
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface UserConfig {
|
||||
theme: string;
|
||||
git_user_name: string | null;
|
||||
git_user_email: string | null;
|
||||
last_session_id: string | null;
|
||||
}
|
||||
|
||||
export interface UserConfigUpdate {
|
||||
@@ -12,6 +13,7 @@ export interface UserConfigUpdate {
|
||||
theme?: string;
|
||||
git_user_name?: string;
|
||||
git_user_email?: string;
|
||||
last_session_id?: string;
|
||||
}
|
||||
|
||||
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 }[] = [
|
||||
{ to: "/", label: "Dashboard", icon: "dashboard" },
|
||||
{ to: "/sessions", label: "Sessions", icon: "terminal" },
|
||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
|
||||
{ to: "/tool-types", label: "Tool Types", icon: "code" },
|
||||
@@ -83,17 +84,24 @@ export const AppShell = () => {
|
||||
|
||||
<div className="shell-body">
|
||||
<aside className="shell-nav" aria-label="Primary navigation">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isSessions = item.to === "/sessions";
|
||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
{isSessions && activeCount > 0 && (
|
||||
<span className="nav-badge">{activeCount}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{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,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
last_session_id: null,
|
||||
});
|
||||
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 { DashboardPage } 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";
|
||||
@@ -28,6 +29,7 @@ export const AppRouter = () => {
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="sessions" element={<SessionsPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
||||
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
||||
|
||||
@@ -2456,3 +2456,176 @@ a.nav-item,
|
||||
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