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 (
+
+ );
+ })}
+
+ );
+}
+
+/* ---------------------------------------------------------------------------
+ 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 (
+
+
+
+ );
+ }
+
+ 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);
+}