From 7ace32539eec2333c7045120143bbda8dc693065 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 22 Feb 2026 22:28:03 -0600 Subject: [PATCH] feat(web): add project workspace page with tasks and agent sessions Add the workspace page at /workspace with two modes: - Project selector (no ?project param): grid of project cards to choose from - Workspace view (?project=): project detail with tabbed sections for Tasks, Agent Sessions, and Project Settings Also creates the runner-jobs API client for fetching workspace-level runner job data. Refs #468 Co-Authored-By: Claude Opus 4.6 --- .../app/(authenticated)/workspace/page.tsx | 1085 +++++++++++++++++ apps/web/src/lib/api/runner-jobs.ts | 100 ++ 2 files changed, 1185 insertions(+) create mode 100644 apps/web/src/app/(authenticated)/workspace/page.tsx create mode 100644 apps/web/src/lib/api/runner-jobs.ts diff --git a/apps/web/src/app/(authenticated)/workspace/page.tsx b/apps/web/src/app/(authenticated)/workspace/page.tsx new file mode 100644 index 0000000..cbef787 --- /dev/null +++ b/apps/web/src/app/(authenticated)/workspace/page.tsx @@ -0,0 +1,1085 @@ +"use client"; + +import { Suspense, useState, useEffect, useCallback } from "react"; +import type { ReactElement } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { ArrowLeft, Clock, CheckCircle2, XCircle, Loader2, Pause } from "lucide-react"; + +import { MosaicSpinner } from "@/components/ui/MosaicSpinner"; +import { fetchProjects, fetchProject, ProjectStatus } from "@/lib/api/projects"; +import type { Project } from "@/lib/api/projects"; +import { fetchTasks } from "@/lib/api/tasks"; +import { fetchRunnerJobs, RunnerJobStatus } from "@/lib/api/runner-jobs"; +import type { RunnerJob } from "@/lib/api/runner-jobs"; +import { useWorkspaceId } from "@/lib/hooks"; +import { TaskStatus, TaskPriority } from "@mosaic/shared"; +import type { Task } from "@mosaic/shared"; + +/* --------------------------------------------------------------------------- + Tab types + --------------------------------------------------------------------------- */ + +type WorkspaceTab = "tasks" | "sessions" | "settings"; + +/* --------------------------------------------------------------------------- + Status badge helpers + --------------------------------------------------------------------------- */ + +interface StatusStyle { + label: string; + bg: string; + color: string; +} + +function getProjectStatusStyle(status: ProjectStatus): StatusStyle { + switch (status) { + case ProjectStatus.PLANNING: + return { label: "Planning", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" }; + case ProjectStatus.ACTIVE: + return { label: "Active", bg: "rgba(20,184,166,0.15)", color: "var(--success)" }; + case ProjectStatus.PAUSED: + return { label: "Paused", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" }; + case ProjectStatus.COMPLETED: + return { label: "Completed", bg: "rgba(139,92,246,0.15)", color: "var(--purple)" }; + case ProjectStatus.ARCHIVED: + return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + default: + return { label: String(status), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + } +} + +function getTaskStatusStyle(status: TaskStatus): StatusStyle { + switch (status) { + case TaskStatus.NOT_STARTED: + return { label: "Not Started", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" }; + case TaskStatus.IN_PROGRESS: + return { label: "In Progress", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" }; + case TaskStatus.PAUSED: + return { label: "Paused", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + case TaskStatus.COMPLETED: + return { label: "Completed", bg: "rgba(20,184,166,0.15)", color: "var(--success)" }; + case TaskStatus.ARCHIVED: + return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + default: + return { label: status as string, bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + } +} + +function getTaskPriorityStyle(priority: TaskPriority): StatusStyle { + switch (priority) { + case TaskPriority.HIGH: + return { label: "High", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" }; + case TaskPriority.MEDIUM: + return { label: "Medium", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" }; + case TaskPriority.LOW: + return { label: "Low", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + default: + return { label: priority as string, bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + } +} + +function getJobStatusStyle(status: RunnerJobStatus): StatusStyle { + switch (status) { + case RunnerJobStatus.PENDING: + return { label: "Pending", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + case RunnerJobStatus.QUEUED: + return { label: "Queued", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" }; + case RunnerJobStatus.RUNNING: + return { label: "Running", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" }; + case RunnerJobStatus.COMPLETED: + return { label: "Completed", bg: "rgba(20,184,166,0.15)", color: "var(--success)" }; + case RunnerJobStatus.FAILED: + return { label: "Failed", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" }; + case RunnerJobStatus.CANCELLED: + return { label: "Cancelled", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + default: + return { label: String(status), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + } +} + +function getJobStatusIcon(status: RunnerJobStatus): ReactElement { + const size = 14; + switch (status) { + case RunnerJobStatus.RUNNING: + return ; + case RunnerJobStatus.COMPLETED: + return ; + case RunnerJobStatus.FAILED: + return ; + case RunnerJobStatus.CANCELLED: + return ; + default: + return ; + } +} + +function formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch { + return iso; + } +} + +function formatDatetime(iso: string): string { + try { + return new Date(iso).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +/* --------------------------------------------------------------------------- + StatusBadge + --------------------------------------------------------------------------- */ + +interface StatusBadgeProps { + style: StatusStyle; + icon?: ReactElement; +} + +function StatusBadge({ style: statusStyle, icon }: StatusBadgeProps): ReactElement { + return ( + + {icon} + {statusStyle.label} + + ); +} + +/* --------------------------------------------------------------------------- + Project Selector Card + --------------------------------------------------------------------------- */ + +interface SelectorCardProps { + project: Project; +} + +function ProjectSelectorCard({ project }: SelectorCardProps): ReactElement { + const [hovered, setHovered] = useState(false); + const status = getProjectStatusStyle(project.status); + + return ( +
{ + setHovered(true); + }} + onMouseLeave={() => { + setHovered(false); + }} + style={{ + background: "var(--surface)", + border: `1px solid ${hovered ? "var(--primary)" : "var(--border)"}`, + borderRadius: "var(--r-lg)", + padding: 20, + cursor: "pointer", + transition: "border-color 0.2s var(--ease)", + display: "flex", + flexDirection: "column", + gap: 12, + }} + > +
+

+ {project.name} +

+
+ + {project.description ? ( +

+ {project.description} +

+ ) : ( +

+ No description +

+ )} + +
+ + + {formatTimestamp(project.createdAt)} + +
+
+ ); +} + +/* --------------------------------------------------------------------------- + Project Selector View + --------------------------------------------------------------------------- */ + +interface ProjectSelectorProps { + workspaceId: string | null; +} + +function ProjectSelector({ workspaceId }: ProjectSelectorProps): ReactElement { + const router = useRouter(); + const [projects, setProjects] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const loadProjects = useCallback(async (wsId: string | null): Promise => { + try { + setIsLoading(true); + setError(null); + const data = await fetchProjects(wsId ?? undefined); + setProjects(data); + } catch (err: unknown) { + console.error("[Workspace] Failed to fetch projects:", err); + setError(err instanceof Error ? err.message : "Something went wrong loading projects."); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (!workspaceId) { + setIsLoading(false); + return; + } + + let cancelled = false; + + async function load(): Promise { + try { + setIsLoading(true); + setError(null); + const data = await fetchProjects(workspaceId ?? undefined); + if (!cancelled) { + setProjects(data); + } + } catch (err: unknown) { + if (!cancelled) { + console.error("[Workspace] Failed to fetch projects:", err); + setError(err instanceof Error ? err.message : "Something went wrong loading projects."); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + void load(); + + return (): void => { + cancelled = true; + }; + }, [workspaceId]); + + function handleRetry(): void { + void loadProjects(workspaceId); + } + + function handleProjectClick(projectId: string): void { + router.push(`/workspace?project=${projectId}`); + } + + return ( +
+
+

+ Select a Project +

+

+ Choose a project to open its workspace +

+
+ + {isLoading ? ( +
+ +
+ ) : error !== null ? ( +
+

{error}

+ +
+ ) : projects.length === 0 ? ( +
+

+ No projects found. Create a project first from the Projects page. +

+
+ ) : ( +
+ {projects.map((project) => ( +
{ + handleProjectClick(project.id); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleProjectClick(project.id); + } + }} + > + +
+ ))} +
+ )} +
+ ); +} + +/* --------------------------------------------------------------------------- + Tasks Section + --------------------------------------------------------------------------- */ + +interface TasksSectionProps { + tasks: Task[]; + isLoading: boolean; + error: string | null; + onRetry: () => void; +} + +function TasksSection({ tasks, isLoading, error, onRetry }: TasksSectionProps): ReactElement { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error !== null) { + return ( +
+

{error}

+ +
+ ); + } + + if (tasks.length === 0) { + return ( +
+

+ No tasks for this project +

+
+ ); + } + + return ( +
+ {tasks.map((task) => { + const statusStyle = getTaskStatusStyle(task.status); + const priorityStyle = getTaskPriorityStyle(task.priority); + + return ( +
+
+

+ {task.title} +

+
+ + +
+ ); + })} +
+ ); +} + +/* --------------------------------------------------------------------------- + Agent Sessions Section + --------------------------------------------------------------------------- */ + +interface SessionsSectionProps { + jobs: RunnerJob[]; + isLoading: boolean; + error: string | null; + onRetry: () => void; +} + +function SessionsSection({ jobs, isLoading, error, onRetry }: SessionsSectionProps): ReactElement { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error !== null) { + return ( +
+

{error}

+ +
+ ); + } + + if (jobs.length === 0) { + return ( +
+

+ No agent sessions found +

+
+ ); + } + + return ( +
+ {jobs.map((job) => { + const statusStyle = getJobStatusStyle(job.status); + + return ( +
+
+

+ {job.type} +

+

+ {formatDatetime(job.createdAt)} + {job.completedAt ? ` \u2014 ${formatDatetime(job.completedAt)}` : ""} +

+
+ +
+ ); + })} +
+ ); +} + +/* --------------------------------------------------------------------------- + Project Settings Section + --------------------------------------------------------------------------- */ + +interface SettingsSectionProps { + project: Project; +} + +function SettingsSection({ project }: SettingsSectionProps): ReactElement { + const rows: { label: string; value: string | ReactElement }[] = [ + { label: "Project ID", value: project.id }, + { label: "Created", value: formatTimestamp(project.createdAt) }, + { label: "Updated", value: formatTimestamp(project.updatedAt) }, + { + label: "Status", + value: , + }, + { label: "Domain ID", value: project.domainId ?? "None" }, + { label: "Color", value: project.color ?? "Default" }, + ]; + + return ( +
+ {rows.map((row, idx) => ( +
+ + {row.label} + + + {row.value} + +
+ ))} + + +
+ ); +} + +/* --------------------------------------------------------------------------- + Workspace View (project selected) + --------------------------------------------------------------------------- */ + +interface WorkspaceViewProps { + projectId: string; + workspaceId: string | null; +} + +function WorkspaceView({ projectId, workspaceId }: WorkspaceViewProps): ReactElement { + const router = useRouter(); + const [activeTab, setActiveTab] = useState("tasks"); + + // Project state + const [project, setProject] = useState(null); + const [projectLoading, setProjectLoading] = useState(true); + const [projectError, setProjectError] = useState(null); + + // Tasks state + const [tasks, setTasks] = useState([]); + const [tasksLoading, setTasksLoading] = useState(true); + const [tasksError, setTasksError] = useState(null); + + // Runner jobs state + const [jobs, setJobs] = useState([]); + const [jobsLoading, setJobsLoading] = useState(true); + const [jobsError, setJobsError] = useState(null); + + // Load project + const loadProject = useCallback(async (): Promise => { + try { + setProjectLoading(true); + setProjectError(null); + const data = await fetchProject(projectId, workspaceId ?? undefined); + setProject(data); + } catch (err: unknown) { + console.error("[Workspace] Failed to fetch project:", err); + setProjectError(err instanceof Error ? err.message : "Failed to load project."); + } finally { + setProjectLoading(false); + } + }, [projectId, workspaceId]); + + // Load tasks + const loadTasks = useCallback(async (): Promise => { + if (!workspaceId) return; + try { + setTasksLoading(true); + setTasksError(null); + const allTasks = await fetchTasks({ workspaceId }); + // Filter client-side by projectId + const projectTasks = allTasks.filter((t) => t.projectId === projectId); + setTasks(projectTasks); + } catch (err: unknown) { + console.error("[Workspace] Failed to fetch tasks:", err); + setTasksError(err instanceof Error ? err.message : "Failed to load tasks."); + } finally { + setTasksLoading(false); + } + }, [projectId, workspaceId]); + + // Load runner jobs + const loadJobs = useCallback(async (): Promise => { + if (!workspaceId) return; + try { + setJobsLoading(true); + setJobsError(null); + const data = await fetchRunnerJobs({ workspaceId }); + setJobs(data); + } catch (err: unknown) { + console.error("[Workspace] Failed to fetch runner jobs:", err); + setJobsError(err instanceof Error ? err.message : "Failed to load agent sessions."); + } finally { + setJobsLoading(false); + } + }, [workspaceId]); + + // Initial loads + useEffect(() => { + void loadProject(); + }, [loadProject]); + + useEffect(() => { + if (workspaceId) { + void loadTasks(); + void loadJobs(); + } else { + setTasksLoading(false); + setJobsLoading(false); + } + }, [workspaceId, loadTasks, loadJobs]); + + // Loading state for the whole project + if (projectLoading) { + return ( +
+
+ +
+
+ ); + } + + // Project error state + if (projectError !== null) { + return ( +
+
+

{projectError}

+ +
+
+ ); + } + + if (!project) { + return ( +
+
+

Project not found

+
+
+ ); + } + + const projectStatus = getProjectStatusStyle(project.status); + const tabs: { key: WorkspaceTab; label: string }[] = [ + { key: "tasks", label: "Tasks" }, + { key: "sessions", label: "Agent Sessions" }, + { key: "settings", label: "Settings" }, + ]; + + return ( +
+ {/* Back link */} + + + {/* Header */} +
+
+

+ {project.name} +

+ +
+ {project.description && ( +

+ {project.description} +

+ )} +
+ + {/* Tabs */} +
+ {tabs.map((tab) => { + const isActive = activeTab === tab.key; + + return ( + + ); + })} +
+ + {/* Tab content */} + {activeTab === "tasks" && ( + { + void loadTasks(); + }} + /> + )} + {activeTab === "sessions" && ( + { + void loadJobs(); + }} + /> + )} + {activeTab === "settings" && } +
+ ); +} + +/* --------------------------------------------------------------------------- + Workspace Page (entry point) + --------------------------------------------------------------------------- */ + +export default function WorkspacePage(): ReactElement { + return ( + +
+ +
+ + } + > + +
+ ); +} + +function WorkspacePageContent(): ReactElement { + const searchParams = useSearchParams(); + const workspaceId = useWorkspaceId(); + const projectId = searchParams.get("project"); + + if (projectId) { + return ; + } + + return ; +} diff --git a/apps/web/src/lib/api/runner-jobs.ts b/apps/web/src/lib/api/runner-jobs.ts new file mode 100644 index 0000000..7415bc5 --- /dev/null +++ b/apps/web/src/lib/api/runner-jobs.ts @@ -0,0 +1,100 @@ +/** + * Runner Jobs API Client + * Handles runner-job-related API requests + */ + +import { apiGet, type ApiResponse } from "./client"; + +/** + * Runner job status enum (matches backend RunnerJobStatus) + */ +export enum RunnerJobStatus { + PENDING = "PENDING", + QUEUED = "QUEUED", + RUNNING = "RUNNING", + COMPLETED = "COMPLETED", + FAILED = "FAILED", + CANCELLED = "CANCELLED", +} + +/** + * Runner job response interface (matches Prisma RunnerJob model) + */ +export interface RunnerJob { + id: string; + workspaceId: string; + agentTaskId: string | null; + type: string; + status: RunnerJobStatus; + priority: number; + progressPercent: number; + version: number; + result: Record | null; + error: string | null; + createdAt: string; + startedAt: string | null; + completedAt: string | null; +} + +/** + * Filters for querying runner jobs + */ +export interface RunnerJobFilters { + workspaceId?: string; + status?: RunnerJobStatus | RunnerJobStatus[]; + type?: string; + agentTaskId?: string; + page?: number; + limit?: number; +} + +/** + * Paginated runner jobs response + */ +export interface PaginatedRunnerJobs { + data: RunnerJob[]; + meta?: { + total?: number; + page?: number; + limit?: number; + }; +} + +/** + * Fetch runner jobs with optional filters + */ +export async function fetchRunnerJobs(filters?: RunnerJobFilters): Promise { + const params = new URLSearchParams(); + + if (filters?.status) { + const statuses = Array.isArray(filters.status) ? filters.status : [filters.status]; + for (const s of statuses) { + params.append("status", s); + } + } + if (filters?.type) { + params.append("type", filters.type); + } + if (filters?.agentTaskId) { + params.append("agentTaskId", filters.agentTaskId); + } + if (filters?.page !== undefined) { + params.append("page", String(filters.page)); + } + if (filters?.limit !== undefined) { + params.append("limit", String(filters.limit)); + } + + const queryString = params.toString(); + const endpoint = queryString ? `/api/runner-jobs?${queryString}` : "/api/runner-jobs"; + + const response = await apiGet>(endpoint, filters?.workspaceId); + return response.data; +} + +/** + * Fetch a single runner job by ID + */ +export async function fetchRunnerJob(id: string, workspaceId?: string): Promise { + return apiGet(`/api/runner-jobs/${id}`, workspaceId); +}