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 ( +
+
+ + +
+ {body} +
+ ); +} 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",