diff --git a/packages/webview-shared/createWebviewConfig.ts b/packages/webview-shared/createWebviewConfig.ts
index f6d8728e28..7a47393c61 100644
--- a/packages/webview-shared/createWebviewConfig.ts
+++ b/packages/webview-shared/createWebviewConfig.ts
@@ -45,6 +45,11 @@ export function createWebviewConfig(
resolve: {
alias: {
"@repo/webview-shared": resolve(dirname, "../webview-shared/src"),
+ // @repo/ui ships TypeScript source and its package-internal
+ // subpath imports; bundling it needs the same direct resolution
+ "@repo/ui": resolve(dirname, "../ui/src"),
+ "#cx": resolve(dirname, "../ui/src/cx.ts"),
+ "#codicons": resolve(dirname, "../ui/src/codicons.ts"),
},
},
});
diff --git a/packages/workspaces/package.json b/packages/workspaces/package.json
index d7a3b34591..f64d783f00 100644
--- a/packages/workspaces/package.json
+++ b/packages/workspaces/package.json
@@ -11,6 +11,7 @@
},
"dependencies": {
"@repo/shared": "workspace:*",
+ "@repo/ui": "workspace:*",
"@repo/webview-shared": "workspace:*",
"@tanstack/react-query": "catalog:",
"@vscode-elements/react-elements": "catalog:",
diff --git a/packages/workspaces/src/App.tsx b/packages/workspaces/src/App.tsx
index abed211177..b8cdff9266 100644
--- a/packages/workspaces/src/App.tsx
+++ b/packages/workspaces/src/App.tsx
@@ -1,3 +1,7 @@
+import { MOCK_WORKSPACES } from "./mockData";
+import { WorkspacesPanel } from "./WorkspacesPanel";
+
export default function App() {
- return
TODO
;
+ // Prototype: mock data only; IPC arrives with the real provider wiring.
+ return ;
}
diff --git a/packages/workspaces/src/WorkspaceFilterSelect.tsx b/packages/workspaces/src/WorkspaceFilterSelect.tsx
new file mode 100644
index 0000000000..37c871786b
--- /dev/null
+++ b/packages/workspaces/src/WorkspaceFilterSelect.tsx
@@ -0,0 +1,60 @@
+import {
+ Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+ Icon,
+} from "@repo/ui";
+
+/** Which workspace set the panel lists. */
+export type WorkspaceFilter = "mine" | "all" | "shared";
+
+const FILTER_LABELS: Record = {
+ mine: "Mine",
+ all: "All",
+ shared: "Shared",
+};
+
+export interface WorkspaceFilterSelectProps {
+ /** "Shared" is hidden unless the signed-in user is an owner. */
+ isOwner?: boolean;
+ value: WorkspaceFilter;
+ onChange: (filter: WorkspaceFilter) => void;
+}
+
+export function WorkspaceFilterSelect({
+ isOwner = false,
+ value,
+ onChange,
+}: WorkspaceFilterSelectProps): React.JSX.Element {
+ return (
+
+
+
+
+
+ onChange(filter as WorkspaceFilter)}
+ >
+
+ {FILTER_LABELS.mine}
+
+
+ {FILTER_LABELS.all}
+
+ {isOwner ? (
+
+ {FILTER_LABELS.shared}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/packages/workspaces/src/WorkspacesPanel.css b/packages/workspaces/src/WorkspacesPanel.css
new file mode 100644
index 0000000000..2516ae1487
--- /dev/null
+++ b/packages/workspaces/src/WorkspacesPanel.css
@@ -0,0 +1,50 @@
+.workspaces-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+.workspaces-panel__toolbar {
+ display: flex;
+ gap: var(--ui-spacing-40);
+ align-items: center;
+ padding: var(--ui-spacing-40);
+}
+
+.workspaces-panel__toolbar .ui-search-input {
+ flex: 1;
+ min-width: 0;
+}
+
+.workspaces-panel__filter {
+ gap: var(--ui-spacing-40);
+ flex: none;
+}
+
+.workspaces-panel__tree {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+}
+
+.workspaces-panel__row-label {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--ui-spacing-60);
+ min-width: 0;
+}
+
+.workspaces-panel__name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.workspaces-panel__owner {
+ color: var(--ui-description-foreground);
+ flex: none;
+}
+
+.workspaces-panel__highlight {
+ color: var(--vscode-list-highlightForeground, var(--ui-link-foreground));
+}
diff --git a/packages/workspaces/src/WorkspacesPanel.tsx b/packages/workspaces/src/WorkspacesPanel.tsx
new file mode 100644
index 0000000000..80aece3a97
--- /dev/null
+++ b/packages/workspaces/src/WorkspacesPanel.tsx
@@ -0,0 +1,146 @@
+import {
+ EmptyState,
+ ErrorState,
+ LoadingState,
+ SearchInput,
+ Tree,
+ type TreeNode,
+} from "@repo/ui";
+import { useMemo, useState } from "react";
+
+import { workspaceNode } from "./rows";
+import {
+ WorkspaceFilterSelect,
+ type WorkspaceFilter,
+} from "./WorkspaceFilterSelect";
+import "./WorkspacesPanel.css";
+
+import type { MockWorkspaceEntry } from "./mockData";
+
+export interface WorkspacesPanelProps {
+ readonly workspaces: readonly MockWorkspaceEntry[];
+ /** Gates the "Shared" filter option, like `coder.isOwner`. */
+ readonly isOwner?: boolean;
+ readonly state?: "ready" | "loading" | "error";
+ readonly onRetry?: () => void;
+}
+
+function filterEntries(
+ entries: readonly MockWorkspaceEntry[],
+ filter: WorkspaceFilter,
+ query: string,
+): readonly MockWorkspaceEntry[] {
+ const lowered = query.trim().toLocaleLowerCase();
+ return entries.filter((entry) => {
+ if (filter === "mine" && entry.owner !== "me") return false;
+ if (filter === "shared" && !entry.shared) return false;
+ if (lowered === "") return true;
+ const haystack = [
+ entry.workspace.name,
+ entry.workspace.owner_name,
+ entry.workspace.template_display_name,
+ ...entry.agents.map((agent) => agent.name),
+ ]
+ .join(" ")
+ .toLocaleLowerCase();
+ return haystack.includes(lowered);
+ });
+}
+
+/** Expands workspaces and agents, leaving leaf sections collapsed. */
+function initialExpandedIds(nodes: readonly TreeNode[]): readonly string[] {
+ const ids: string[] = [];
+ const visit = (node: TreeNode, depth: number): void => {
+ if (node.children && depth < 2) {
+ ids.push(node.id);
+ }
+ node.children?.forEach((child) => visit(child, depth + 1));
+ };
+ nodes.forEach((node) => visit(node, 0));
+ return ids;
+}
+
+/** Prototype panel: toolbar, filtered tree, and the loading/error/empty states. */
+export function WorkspacesPanel({
+ workspaces,
+ isOwner = false,
+ state = "ready",
+ onRetry,
+}: WorkspacesPanelProps): React.JSX.Element {
+ const [filter, setFilter] = useState("mine");
+ const [query, setQuery] = useState("");
+ const [selectedItemId, setSelectedItemId] = useState();
+
+ const nodes = useMemo(
+ () =>
+ filterEntries(workspaces, filter, query).map((entry) =>
+ workspaceNode(entry, filter !== "mine", query),
+ ),
+ [workspaces, filter, query],
+ );
+ const [expandedIds, setExpandedIds] = useState(() =>
+ initialExpandedIds(nodes),
+ );
+
+ let body: React.JSX.Element;
+ if (state === "loading") {
+ body = ;
+ } else if (state === "error") {
+ body = (
+
+ );
+ } else if (workspaces.length === 0) {
+ body = (
+
+ );
+ } else if (nodes.length === 0) {
+ body = (
+
+ );
+ } else {
+ body = (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/workspaces/src/index.css b/packages/workspaces/src/index.css
index 8f414f586f..19de1895bb 100644
--- a/packages/workspaces/src/index.css
+++ b/packages/workspaces/src/index.css
@@ -1 +1,14 @@
-/* TODO */
+/* UI library theme tokens and codicon font */
+@import "@repo/ui/codicon.css";
+@import "@repo/ui/tokens.css";
+
+body {
+ margin: 0;
+ padding: 0;
+ font-family: var(--ui-font-family);
+ font-size: var(--ui-font-size);
+ font-weight: var(--ui-font-weight-regular);
+ color: var(--ui-foreground);
+ background: var(--ui-background);
+ overflow: hidden;
+}
diff --git a/packages/workspaces/src/index.tsx b/packages/workspaces/src/index.tsx
index e6bb115928..176fe4bb83 100644
--- a/packages/workspaces/src/index.tsx
+++ b/packages/workspaces/src/index.tsx
@@ -1,3 +1,4 @@
+import { TooltipProvider } from "@repo/ui";
import { ErrorBoundary } from "@repo/webview-shared/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
@@ -19,7 +20,9 @@ createRoot(root).render(
-
+
+
+
,
diff --git a/packages/workspaces/src/mockData.ts b/packages/workspaces/src/mockData.ts
new file mode 100644
index 0000000000..bb4acb8cdd
--- /dev/null
+++ b/packages/workspaces/src/mockData.ts
@@ -0,0 +1,278 @@
+import type {
+ Workspace,
+ WorkspaceAgent,
+ WorkspaceAgentMetadata,
+ WorkspaceApp,
+ WorkspaceAppStatus,
+ WorkspaceBuild,
+} from "coder/site/src/api/typesGenerated";
+
+/** A workspace with its agents and per-agent metadata for the prototype. */
+export interface MockWorkspaceEntry {
+ readonly workspace: Workspace;
+ readonly agents: readonly WorkspaceAgent[];
+ readonly metadata: ReadonlyMap;
+ readonly owner: "me" | "other";
+ readonly shared: boolean;
+}
+
+/* Local fixtures instead of @repo/mocks: the mocks package is restricted to
+ tests and stories, and the panel only needs a few fixed objects. */
+
+const mockBuild = (
+ overrides: Partial = {},
+): WorkspaceBuild => ({
+ id: "build-1",
+ created_at: "2026-08-01T00:00:00Z",
+ updated_at: "2026-08-01T00:00:00Z",
+ workspace_id: "workspace-1",
+ workspace_name: "dev",
+ workspace_owner_id: "owner-1",
+ workspace_owner_name: "testuser",
+ template_version_id: "version-1",
+ template_version_name: "v1",
+ build_number: 1,
+ transition: "start",
+ initiator_id: "owner-1",
+ initiator_name: "testuser",
+ job: {
+ id: "job-1",
+ created_at: "2026-08-01T00:00:00Z",
+ status: "succeeded",
+ file_id: "file-1",
+ tags: {},
+ queue_position: 0,
+ queue_size: 0,
+ organization_id: "org-1",
+ initiator_id: "owner-1",
+ input: {},
+ type: "workspace_build",
+ metadata: {
+ template_version_name: "v1",
+ template_id: "template-1",
+ template_name: "devcontainer",
+ template_display_name: "Dev Container",
+ template_icon: "/icon.svg",
+ },
+ logs_overflowed: false,
+ },
+ reason: "initiator",
+ resources: [],
+ status: "running",
+ daily_cost: 0,
+ template_version_preset_id: null,
+ ...overrides,
+});
+
+const mockWorkspace = (overrides: Partial = {}): Workspace => ({
+ id: "workspace-1",
+ created_at: "2026-08-01T00:00:00Z",
+ updated_at: "2026-08-01T00:00:00Z",
+ owner_id: "owner-1",
+ owner_name: "testuser",
+ owner_avatar_url: "",
+ organization_id: "org-1",
+ organization_name: "test-org",
+ template_id: "template-1",
+ template_name: "devcontainer",
+ template_display_name: "Dev Container",
+ template_icon: "/icon.svg",
+ template_allow_user_cancel_workspace_jobs: true,
+ template_active_version_id: "version-1",
+ template_require_active_version: false,
+ template_use_classic_parameter_flow: false,
+ latest_build: mockBuild(),
+ latest_app_status: null,
+ outdated: false,
+ name: "dev",
+ last_used_at: "2026-08-13T00:00:00Z",
+ deleting_at: null,
+ dormant_at: null,
+ health: { healthy: true, failing_agents: [] },
+ automatic_updates: "never",
+ allow_renames: false,
+ favorite: false,
+ next_start_at: null,
+ is_prebuild: false,
+ ...overrides,
+});
+
+const mockAgent = (
+ overrides: Partial = {},
+): WorkspaceAgent => ({
+ id: "agent-1",
+ parent_id: null,
+ created_at: "2026-08-01T00:00:00Z",
+ updated_at: "2026-08-01T00:00:00Z",
+ status: "connected",
+ lifecycle_state: "ready",
+ name: "main",
+ resource_id: "resource-1",
+ architecture: "amd64",
+ environment_variables: {},
+ operating_system: "linux",
+ logs_length: 0,
+ logs_overflowed: false,
+ version: "2.25.0",
+ api_version: "1.0",
+ apps: [],
+ connection_timeout_seconds: 120,
+ troubleshooting_url: "",
+ subsystems: [],
+ health: { healthy: true },
+ display_apps: [],
+ log_sources: [],
+ scripts: [],
+ startup_script_behavior: "non-blocking",
+ ...overrides,
+});
+
+const mockApp = (overrides: Partial = {}): WorkspaceApp => ({
+ id: "app-1",
+ external: false,
+ slug: "app-1",
+ subdomain: false,
+ sharing_level: "owner",
+ health: "healthy",
+ hidden: false,
+ open_in: "tab",
+ statuses: [],
+ ...overrides,
+});
+
+const mockStatus = (
+ overrides: Partial = {},
+): WorkspaceAppStatus => ({
+ id: "status-1",
+ created_at: "2026-08-13T10:00:00Z",
+ workspace_id: "workspace-1",
+ agent_id: "agent-1",
+ app_id: "app-1",
+ state: "idle",
+ message: "Idle",
+ uri: "",
+ icon: "",
+ needs_user_attention: false,
+ ...overrides,
+});
+
+const mockMetadata = (
+ key: string,
+ displayName: string,
+ value: string,
+ collectedAt: string,
+): WorkspaceAgentMetadata => ({
+ description: {
+ display_name: displayName,
+ key,
+ script: `echo ${value}`,
+ interval: 10,
+ timeout: 1,
+ },
+ result: { collected_at: collectedAt, age: 12, value, error: "" },
+});
+
+/**
+ * Mock deployment data: two own workspaces (one running with app statuses and
+ * metadata, one stopped), one shared running workspace, and one workspace from
+ * another owner.
+ */
+export const MOCK_WORKSPACES: readonly MockWorkspaceEntry[] = [
+ {
+ workspace: mockWorkspace({ id: "workspace-dev", name: "dev" }),
+ agents: [
+ mockAgent({
+ id: "agent-dev",
+ apps: [
+ mockApp({
+ id: "vscode",
+ slug: "vscode",
+ display_name: "VS Code Desktop",
+ }),
+ mockApp({
+ id: "ci",
+ slug: "ci",
+ display_name: "CI Watcher",
+ statuses: [
+ mockStatus({
+ id: "status-ci-running",
+ app_id: "ci",
+ state: "working",
+ message: "Building packages/ui",
+ }),
+ mockStatus({
+ id: "status-ci-failed",
+ app_id: "ci",
+ state: "failure",
+ message: "Type check failed in treePolicy.ts",
+ needs_user_attention: true,
+ }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ metadata: new Map([
+ [
+ "agent-dev",
+ [
+ mockMetadata("cpu", "CPU Usage", "23%", "2026-08-13T13:58:00Z"),
+ mockMetadata(
+ "branch",
+ "Git Branch",
+ "feat/ui-tree-suite",
+ "2026-08-13T13:55:00Z",
+ ),
+ ],
+ ],
+ ]),
+ owner: "me",
+ shared: false,
+ },
+ {
+ workspace: mockWorkspace({
+ id: "workspace-staging",
+ name: "staging",
+ template_name: "kubernetes",
+ template_display_name: "Kubernetes",
+ latest_build: mockBuild({ status: "stopped" }),
+ }),
+ agents: [
+ mockAgent({
+ id: "agent-staging",
+ status: "disconnected",
+ lifecycle_state: "off",
+ }),
+ ],
+ metadata: new Map(),
+ owner: "me",
+ shared: false,
+ },
+ {
+ workspace: mockWorkspace({
+ id: "workspace-shared-review",
+ name: "code-review",
+ owner_id: "owner-2",
+ owner_name: "priya",
+ shared_with: [],
+ }),
+ agents: [mockAgent({ id: "agent-review", name: "review" })],
+ metadata: new Map(),
+ owner: "other",
+ shared: true,
+ },
+ {
+ workspace: mockWorkspace({
+ id: "workspace-ci-pool",
+ name: "ci-pool",
+ owner_id: "owner-3",
+ owner_name: "marcus",
+ template_name: "ci",
+ template_display_name: "CI Runner",
+ }),
+ agents: [mockAgent({ id: "agent-ci", name: "runner" })],
+ metadata: new Map(),
+ owner: "other",
+ shared: false,
+ },
+];
diff --git a/packages/workspaces/src/rows.tsx b/packages/workspaces/src/rows.tsx
new file mode 100644
index 0000000000..54b99b6f55
--- /dev/null
+++ b/packages/workspaces/src/rows.tsx
@@ -0,0 +1,227 @@
+import {
+ IconButton,
+ StatusPill,
+ Tooltip,
+ type TreeNode,
+ type StatusPillTone,
+ type CodiconName,
+} from "@repo/ui";
+import { formatDistanceToNow } from "date-fns";
+
+import type {
+ Workspace,
+ WorkspaceAgent,
+ WorkspaceAgentMetadata,
+ WorkspaceAppStatus,
+ WorkspaceStatus,
+} from "coder/site/src/api/typesGenerated";
+
+import type { MockWorkspaceEntry } from "./mockData";
+
+const WORKSPACE_STATUS_PILLS: Record<
+ WorkspaceStatus,
+ { icon: CodiconName; tone: StatusPillTone }
+> = {
+ running: { icon: "play", tone: "success" },
+ starting: { icon: "loading", tone: "info" },
+ stopped: { icon: "pass", tone: "neutral" },
+ failed: { icon: "error", tone: "danger" },
+ pending: { icon: "history", tone: "info" },
+ canceling: { icon: "loading", tone: "warning" },
+ canceled: { icon: "debug-stop", tone: "neutral" },
+ deleting: { icon: "loading", tone: "warning" },
+ deleted: { icon: "archive", tone: "neutral" },
+ stopping: { icon: "loading", tone: "warning" },
+};
+
+const AGENT_STATUS_PILLS: Record<
+ WorkspaceAgent["status"],
+ { icon: CodiconName; tone: StatusPillTone }
+> = {
+ connected: { icon: "pass", tone: "success" },
+ connecting: { icon: "loading", tone: "info" },
+ disconnected: { icon: "alert", tone: "warning" },
+ timeout: { icon: "alert", tone: "danger" },
+};
+
+const APP_STATUS_PILLS: Record<
+ WorkspaceAppStatus["state"],
+ { icon: CodiconName; tone: StatusPillTone }
+> = {
+ complete: { icon: "pass", tone: "success" },
+ failure: { icon: "error", tone: "danger" },
+ idle: { icon: "circle-filled", tone: "neutral" },
+ working: { icon: "loading", tone: "info" },
+};
+
+function statusPill(
+ pill: { icon: CodiconName; tone: StatusPillTone },
+ label: string,
+): React.JSX.Element {
+ return (
+
+ {label}
+
+ );
+}
+
+/** Marks the first case-insensitive match of the search query, like the native views. */
+function highlight(text: string, query: string): React.ReactNode {
+ const lowered = query.trim().toLocaleLowerCase();
+ if (lowered === "") return text;
+ const index = text.toLocaleLowerCase().indexOf(lowered);
+ if (index === -1) return text;
+ return (
+ <>
+ {text.slice(0, index)}
+
+ {text.slice(index, index + lowered.length)}
+
+ {text.slice(index + lowered.length)}
+ >
+ );
+}
+
+/** The workspace branch row: name, owner, status pill, and hover actions. */
+export function workspaceNode(
+ entry: MockWorkspaceEntry,
+ showOwner: boolean,
+ query: string,
+): TreeNode {
+ const { workspace } = entry;
+ const status = workspace.latest_build.status;
+ const textValue = showOwner
+ ? `${workspace.name} (${workspace.owner_name})`
+ : workspace.name;
+ return {
+ id: workspace.id,
+ label: (
+
+
+ {highlight(workspace.name, query)}
+
+ {showOwner ? (
+
+ {highlight(workspace.owner_name, query)}
+
+ ) : null}
+ {statusPill(WORKSPACE_STATUS_PILLS[status], status)}
+
+ ),
+ textValue,
+ icon: "window",
+ action: (
+ <>
+
+
+
+ >
+ ),
+ children: entry.agents.map((agent) => agentNode(entry, agent, query)),
+ };
+}
+
+/** The agent row: name, connection pill, hover actions, and inline sections. */
+export function agentNode(
+ entry: MockWorkspaceEntry,
+ agent: WorkspaceAgent,
+ query: string,
+): TreeNode {
+ const running = entry.workspace.latest_build.status === "running";
+ const pill = running
+ ? statusPill(AGENT_STATUS_PILLS[agent.status], agent.status)
+ : statusPill({ icon: "pass", tone: "neutral" }, "offline");
+ const sections = [
+ appStatusSection(entry.workspace, agent),
+ metadataSection(agent.id, entry.metadata.get(agent.id)),
+ ].filter((section): section is TreeNode => section !== undefined);
+ return {
+ id: agent.id,
+ label: (
+
+
+ {highlight(agent.name, query)}
+
+ {pill}
+
+ ),
+ textValue: agent.name,
+ icon: "server",
+ action: (
+ <>
+
+
+ >
+ ),
+ children: sections.length > 0 ? sections : undefined,
+ };
+}
+
+/** App statuses inline under their agent; nothing when no app reports any. */
+export function appStatusSection(
+ workspace: Workspace,
+ agent: WorkspaceAgent,
+): TreeNode | undefined {
+ const statuses = agent.apps.flatMap((app) =>
+ app.statuses.map((status) => ({ app, status })),
+ );
+ if (statuses.length === 0) return undefined;
+ return {
+ id: `${agent.id}/app-statuses`,
+ label: "App Statuses",
+ children: statuses.map(({ app, status }) => ({
+ id: status.id,
+ label: (
+
+ {statusPill(APP_STATUS_PILLS[status.state], status.state)}
+
+ {app.display_name ?? app.slug}
+
+ {status.message}
+
+ ),
+ textValue: `${app.display_name ?? app.slug}: ${status.message}`,
+ })),
+ };
+}
+
+/** Agent metadata inline under their agent; values carry a collected-at tooltip. */
+export function metadataSection(
+ agentId: string,
+ metadata: readonly WorkspaceAgentMetadata[] | undefined,
+): TreeNode | undefined {
+ if (!metadata || metadata.length === 0) return undefined;
+ return {
+ id: `${agentId}/metadata`,
+ label: "Agent Metadata",
+ children: metadata.map((entry) => ({
+ id: `${agentId}/metadata/${entry.description.key}`,
+ label: (
+
+
+ {entry.description.display_name}
+
+
+ Collected{" "}
+ {formatDistanceToNow(new Date(entry.result.collected_at), {
+ addSuffix: true,
+ })}
+
+ }
+ >
+ {entry.result.value}
+
+
+ ),
+ textValue: `${entry.description.display_name}: ${entry.result.value}`,
+ })),
+ };
+}
diff --git a/packages/workspaces/tsconfig.json b/packages/workspaces/tsconfig.json
index 27059a9803..2cc370cb52 100644
--- a/packages/workspaces/tsconfig.json
+++ b/packages/workspaces/tsconfig.json
@@ -1,8 +1,10 @@
{
"extends": "../tsconfig.packages.json",
"compilerOptions": {
+ "resolveJsonModule": true,
"paths": {
"@repo/shared": ["../shared/src"],
+ "@repo/ui": ["../ui/src"],
"@repo/webview-shared": ["../webview-shared/src"]
}
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e619154c86..72236490fd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -522,6 +522,9 @@ importers:
'@repo/shared':
specifier: workspace:*
version: link:../shared
+ '@repo/ui':
+ specifier: workspace:*
+ version: link:../ui
'@repo/webview-shared':
specifier: workspace:*
version: link:../webview-shared
diff --git a/src/extension.ts b/src/extension.ts
index 67dd15672c..d5746dedf6 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -275,7 +275,7 @@ async function doActivate(
const workspacesPanelEnabled = vscode.workspace
.getConfiguration("coder")
- .get("experimental.workspacesPanel", false);
+ .get("experimental.workspacesPanel", true);
contextManager.set("coder.workspacesPanelEnabled", workspacesPanelEnabled);
diff --git a/test/tsconfig.json b/test/tsconfig.json
index 864101a0b6..534437419d 100644
--- a/test/tsconfig.json
+++ b/test/tsconfig.json
@@ -15,6 +15,7 @@
"@repo/tasks/*": ["../packages/tasks/src/*"],
"@repo/ui": ["../packages/ui/src/index.ts"],
"@repo/ui/*": ["../packages/ui/src/*"],
+ "@repo/workspaces/*": ["../packages/workspaces/src/*"],
"@repo/netcheck/*": ["../packages/netcheck/src/*"],
"@repo/speedtest/*": ["../packages/speedtest/src/*"]
}
diff --git a/test/webview/workspaces/WorkspacesPanel.test.tsx b/test/webview/workspaces/WorkspacesPanel.test.tsx
new file mode 100644
index 0000000000..09019912df
--- /dev/null
+++ b/test/webview/workspaces/WorkspacesPanel.test.tsx
@@ -0,0 +1,107 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+
+import { TooltipProvider } from "@repo/ui";
+import {
+ MOCK_WORKSPACES,
+ type MockWorkspaceEntry,
+} from "@repo/workspaces/mockData";
+import { WorkspacesPanel } from "@repo/workspaces/WorkspacesPanel";
+
+const renderPanel = (
+ workspaces: readonly MockWorkspaceEntry[] = MOCK_WORKSPACES,
+): void => {
+ render(
+
+
+ ,
+ );
+};
+
+describe("WorkspacesPanel", () => {
+ it("lists owned workspaces and their agents in the tree", () => {
+ renderPanel();
+ expect(
+ screen.getByRole("tree", { name: "Workspaces" }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("treeitem", { name: "dev" })).toBeInTheDocument();
+ expect(
+ screen.getByRole("treeitem", { name: "staging" }),
+ ).toBeInTheDocument();
+ // The default "Mine" filter hides other owners.
+ expect(screen.queryByRole("treeitem", { name: "ci-pool" })).toBeNull();
+ });
+
+ it("filters the tree live from the search input", () => {
+ renderPanel();
+ fireEvent.change(
+ screen.getByRole("searchbox", { name: "Search workspaces" }),
+ { target: { value: "staging" } },
+ );
+ expect(screen.queryByRole("treeitem", { name: "dev" })).toBeNull();
+ expect(
+ screen.getByRole("treeitem", { name: "staging" }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows an empty state when the search matches nothing", () => {
+ renderPanel();
+ fireEvent.change(
+ screen.getByRole("searchbox", { name: "Search workspaces" }),
+ { target: { value: "does-not-exist" } },
+ );
+ expect(screen.getByText("No matching workspaces")).toBeInTheDocument();
+ expect(screen.queryByRole("tree")).toBeNull();
+ });
+
+ it("switches to the All filter to include other owners", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+ await user.click(screen.getByRole("button", { name: "Mine" }));
+ await user.click(screen.getByRole("menuitemradio", { name: "All" }));
+ expect(
+ screen.getByRole("treeitem", { name: "ci-pool (marcus)" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("treeitem", { name: "code-review (priya)" }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows app statuses and metadata inline under their agent", () => {
+ renderPanel();
+ const statuses = screen.getByRole("treeitem", { name: "App Statuses" });
+ fireEvent.click(statuses);
+ expect(
+ screen.getByRole("treeitem", {
+ name: "CI Watcher: Building packages/ui",
+ }),
+ ).toBeInTheDocument();
+ const metadata = screen.getByRole("treeitem", { name: "Agent Metadata" });
+ fireEvent.click(metadata);
+ expect(
+ screen.getByRole("treeitem", { name: "CPU Usage: 23%" }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows loading and error states", () => {
+ const loading = render();
+ expect(screen.getByText("Loading workspaces")).toBeInTheDocument();
+ loading.unmount();
+ const retry = (): void => undefined;
+ render();
+ expect(screen.getByText("Failed to load workspaces")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Try again" }),
+ ).toBeInTheDocument();
+ });
+
+ it("reveals hover actions on the focused workspace row", () => {
+ renderPanel();
+ const workspace = screen.getByRole("treeitem", { name: "dev" });
+ fireEvent.click(workspace);
+ expect(
+ screen.getByRole("button", { name: "Open dev" }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/vitest.config.mts b/vitest.config.mts
index 3cc835357f..88ec0b90d9 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -51,6 +51,10 @@ export default defineConfig({
"packages/tasks/src",
),
"@repo/ui": path.resolve(import.meta.dirname, "packages/ui/src"),
+ "@repo/workspaces": path.resolve(
+ import.meta.dirname,
+ "packages/workspaces/src",
+ ),
"@repo/netcheck": path.resolve(
import.meta.dirname,
"packages/netcheck/src",