From 8014f35d7b393d91c4d178ea7136b2e0a3967949 Mon Sep 17 00:00:00 2001
From: Ehab Younes
Date: Fri, 30 Oct 2026 13:43:54 +0000
Subject: [PATCH] feat(workspaces): implement the workspace panel components
Generated by Coder Agents on behalf of @EhabY.
---
CHANGELOG.md | 5 +
package.json | 29 +-
packages/mocks/src/workspaces.ts | 36 ++
packages/shared/src/workspaces/api.ts | 1 +
packages/shared/src/workspaces/types.ts | 9 +-
packages/ui/src/components/Banner/Banner.css | 73 +++
.../src/components/Banner/Banner.stories.tsx | 28 +
packages/ui/src/components/Banner/Banner.tsx | 49 ++
.../components/DropdownMenu/DropdownMenu.tsx | 10 +-
.../src/components/IconButton/IconButton.css | 7 +-
.../src/components/IconButton/IconButton.tsx | 5 +
packages/ui/src/components/Menu/Menu.css | 5 +
packages/ui/src/components/Menu/Menu.tsx | 9 +-
.../components/ProgressBar/ProgressBar.css | 1 +
packages/ui/src/components/Tree/Tree.css | 18 +-
.../ui/src/components/Tree/Tree.stories.tsx | 33 ++
packages/ui/src/components/Tree/Tree.tsx | 11 +-
.../components/Tree/sticky/StickyScroll.tsx | 12 +-
packages/ui/src/index.ts | 1 +
packages/ui/src/tokens.css | 46 ++
packages/workspaces/README.md | 2 +-
packages/workspaces/package.json | 1 +
packages/workspaces/src/App.tsx | 19 +-
.../workspaces/src/components/AgentRow.tsx | 121 +++++
.../workspaces/src/components/RowHover.tsx | 23 +
.../workspaces/src/components/StatusDot.tsx | 48 ++
.../src/components/WorkspaceActions.tsx | 117 +++++
.../src/components/WorkspaceRow.tsx | 65 +++
.../components/WorkspacesPanel.stories.tsx | 294 +++++++++++
.../src/components/WorkspacesPanel.tsx | 283 ++++++++++
.../workspaces/src/hooks/useWorkspaces.ts | 10 +-
packages/workspaces/src/index.css | 192 ++++++-
packages/workspaces/src/index.tsx | 2 +
pnpm-lock.yaml | 3 +
src/extension.ts | 4 +
src/vscode/commandManager.ts | 1 +
src/webviews/workspaces/workspaceStore.ts | 67 ++-
.../workspaces/workspacesPanelProvider.ts | 16 +-
test/mocks/testHelpers.ts | 6 +
.../workspaces/workspaceStore.test.ts | 110 +++-
.../workspacesPanelProvider.test.ts | 22 +-
.../ui/components/Tree/Tree.sticky.test.tsx | 15 +-
.../webview/ui/components/components.test.tsx | 37 ++
.../components/WorkspacesPanel.test.tsx | 484 ++++++++++++++++++
.../workspaces/hooks/useWorkspaces.test.tsx | 45 +-
test/webview/workspaces/ipc.ts | 35 ++
46 files changed, 2334 insertions(+), 76 deletions(-)
create mode 100644 packages/ui/src/components/Banner/Banner.css
create mode 100644 packages/ui/src/components/Banner/Banner.stories.tsx
create mode 100644 packages/ui/src/components/Banner/Banner.tsx
create mode 100644 packages/workspaces/src/components/AgentRow.tsx
create mode 100644 packages/workspaces/src/components/RowHover.tsx
create mode 100644 packages/workspaces/src/components/StatusDot.tsx
create mode 100644 packages/workspaces/src/components/WorkspaceActions.tsx
create mode 100644 packages/workspaces/src/components/WorkspaceRow.tsx
create mode 100644 packages/workspaces/src/components/WorkspacesPanel.stories.tsx
create mode 100644 packages/workspaces/src/components/WorkspacesPanel.tsx
create mode 100644 test/webview/workspaces/components/WorkspacesPanel.test.tsx
create mode 100644 test/webview/workspaces/ipc.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2152386948..1828565fb4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,11 @@
## Unreleased
+### Added
+
+- The experimental Workspaces panel lists workspace agents, metadata and app
+ status. Updated times expose the full date and time on hover or keyboard focus.
+
### Changed
- **Breaking:** the extension no longer follows a redirect to a different
diff --git a/package.json b/package.json
index 38609b33b2..ac16c1c970 100644
--- a/package.json
+++ b/package.json
@@ -335,7 +335,7 @@
},
{
"id": "coderExperimentalWorkspaces",
- "title": "Coder Remote (New)",
+ "title": "Coder Workspaces",
"icon": "media/shorthand-logo.svg"
}
]
@@ -385,9 +385,9 @@
{
"type": "webview",
"id": "coder.workspacesPanel",
- "name": "Workspaces",
+ "name": "Coder Workspaces",
"icon": "media/shorthand-logo.svg",
- "when": "coder.authenticated && coder.workspacesPanelEnabled"
+ "when": "coder.workspacesPanelEnabled"
}
]
},
@@ -552,6 +552,12 @@
"category": "Coder",
"icon": "$(refresh)"
},
+ {
+ "command": "coder.workspaces.refresh",
+ "title": "Refresh Workspaces",
+ "category": "Coder",
+ "icon": "$(refresh)"
+ },
{
"command": "coder.applyRecommendedSettings",
"title": "Apply Recommended SSH Settings",
@@ -688,6 +694,10 @@
"command": "coder.tasks.refresh",
"when": "false"
},
+ {
+ "command": "coder.workspaces.refresh",
+ "when": "false"
+ },
{
"command": "coder.applyRecommendedSettings"
}
@@ -696,21 +706,21 @@
{
"command": "coder.logout",
"group": "deployment@2",
- "when": "coder.authenticated && view == myWorkspaces"
+ "when": "coder.authenticated && (view == myWorkspaces || view == coder.workspacesPanel)"
},
{
"command": "coder.netcheck",
"group": "diagnostics@1",
- "when": "coder.authenticated && view == myWorkspaces"
+ "when": "coder.authenticated && (view == myWorkspaces || view == coder.workspacesPanel)"
},
{
"command": "coder.switchDeployment",
"group": "deployment@1",
- "when": "coder.authenticated && view == myWorkspaces"
+ "when": "coder.authenticated && (view == myWorkspaces || view == coder.workspacesPanel)"
},
{
"command": "coder.login",
- "when": "!coder.authenticated && view == myWorkspaces"
+ "when": "!coder.authenticated && (view == myWorkspaces || view == coder.workspacesPanel)"
},
{
"command": "coder.createWorkspace",
@@ -746,6 +756,11 @@
"command": "coder.tasks.refresh",
"when": "coder.authenticated && view == coder.tasksPanel",
"group": "navigation@1"
+ },
+ {
+ "command": "coder.workspaces.refresh",
+ "when": "coder.authenticated && view == coder.workspacesPanel",
+ "group": "navigation@1"
}
],
"view/item/context": [
diff --git a/packages/mocks/src/workspaces.ts b/packages/mocks/src/workspaces.ts
index 77b3458f1b..ea670cf637 100644
--- a/packages/mocks/src/workspaces.ts
+++ b/packages/mocks/src/workspaces.ts
@@ -7,6 +7,8 @@ import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentMetadata,
+ WorkspaceApp,
+ WorkspaceAppStatus,
WorkspaceBuild,
WorkspaceResource,
} from "coder/site/src/api/typesGenerated";
@@ -136,6 +138,40 @@ export function agent(overrides: Partial = {}): WorkspaceAgent {
};
}
+export function app(overrides: Partial = {}): WorkspaceApp {
+ return {
+ id: "app-1",
+ slug: "claude",
+ display_name: "Claude Code",
+ external: false,
+ subdomain: false,
+ sharing_level: "owner",
+ health: "healthy",
+ hidden: false,
+ open_in: "slim-window",
+ statuses: [],
+ ...overrides,
+ };
+}
+
+export function appStatus(
+ overrides: Partial = {},
+): WorkspaceAppStatus {
+ return {
+ id: "status-1",
+ created_at: "2024-01-01T00:00:00Z",
+ workspace_id: "workspace-1",
+ agent_id: "agent-1",
+ app_id: "app-1",
+ state: "working",
+ message: "Running tests",
+ uri: "",
+ icon: "",
+ needs_user_attention: false,
+ ...overrides,
+ };
+}
+
/** Create a WorkspaceAgentMetadata report with sensible defaults. */
export function agentMetadata(
overrides: {
diff --git a/packages/shared/src/workspaces/api.ts b/packages/shared/src/workspaces/api.ts
index 2f0b6a20f5..f6219c58f4 100644
--- a/packages/shared/src/workspaces/api.ts
+++ b/packages/shared/src/workspaces/api.ts
@@ -22,6 +22,7 @@ export const WorkspacesApi = {
// Commands
/** Webview signals its subscription is live and asks for the state */
ready: defineCommand("ready"),
+ login: defineCommand("login"),
openWorkspace: defineCommand("openWorkspace"),
viewInDashboard: defineCommand("viewInDashboard"),
refresh: defineCommand("refresh"),
diff --git a/packages/shared/src/workspaces/types.ts b/packages/shared/src/workspaces/types.ts
index 41eb4539d7..907e7248cd 100644
--- a/packages/shared/src/workspaces/types.ts
+++ b/packages/shared/src/workspaces/types.ts
@@ -26,7 +26,10 @@ export interface WorkspacesCapabilities {
export type WorkspaceListStatus =
| { readonly kind: "loading" }
| { readonly kind: "ready" }
- | { readonly kind: "failed"; readonly error: string };
+ | {
+ readonly kind: "failed";
+ readonly error: string;
+ };
/** What one agent reports. A failure replaces its metadata in the UI. */
export type AgentMetadataState =
@@ -47,6 +50,10 @@ export interface WorkspacesState {
readonly workspaces: readonly Workspace[];
readonly status: WorkspaceListStatus;
readonly metadata: AgentMetadataMap;
+ /** Epoch ms, rounded down to the minute; unset before the first list. */
+ readonly listedAt?: number;
+ /** Listed workspace IDs associated with Agent chats. */
+ readonly agentWorkspaceIds?: readonly string[];
}
export interface OpenWorkspaceParams {
diff --git a/packages/ui/src/components/Banner/Banner.css b/packages/ui/src/components/Banner/Banner.css
new file mode 100644
index 0000000000..e67eb2d48c
--- /dev/null
+++ b/packages/ui/src/components/Banner/Banner.css
@@ -0,0 +1,73 @@
+.ui-banner {
+ --ui-banner-accent: var(--ui-status-info-accent);
+ display: flex;
+ align-items: flex-start;
+ gap: var(--ui-spacing-60);
+ box-sizing: border-box;
+ padding: var(--ui-spacing-40) var(--ui-spacing-60);
+ color: var(--ui-message-info-foreground);
+ background: var(--ui-message-info-background);
+ border: 1px solid var(--ui-message-info-border);
+ line-height: 18px;
+ overflow-wrap: anywhere;
+}
+
+.ui-banner--warning {
+ --ui-banner-accent: var(--ui-status-warning-accent);
+ color: var(--ui-message-warning-foreground);
+ background: var(--ui-message-warning-background);
+ border-color: var(--ui-message-warning-border);
+}
+
+.ui-banner--error {
+ --ui-banner-accent: var(--ui-status-danger-accent);
+ color: var(--ui-message-error-foreground);
+ background: var(--ui-message-error-background);
+ border-color: var(--ui-message-error-border);
+}
+
+.ui-banner__icon {
+ flex: none;
+ height: 18px;
+ color: var(--ui-banner-accent);
+}
+
+.ui-banner__message {
+ flex: 1;
+ min-width: 0;
+}
+
+.ui-banner__action {
+ flex: none;
+ align-self: center;
+ padding: 0;
+ font: inherit;
+ color: var(--ui-link-foreground);
+ background: none;
+ border: none;
+ cursor: pointer;
+}
+
+.ui-banner__action:hover {
+ color: var(--ui-link-active-foreground);
+ text-decoration: underline;
+}
+
+.ui-banner__action:focus {
+ outline: 1px solid var(--ui-focus-border);
+ outline-offset: 1px;
+}
+
+@media (forced-colors: active) {
+ .ui-banner {
+ border-color: CanvasText;
+ }
+
+ .ui-banner__icon {
+ color: CanvasText;
+ }
+
+ .ui-banner__action {
+ color: LinkText;
+ }
+}
diff --git a/packages/ui/src/components/Banner/Banner.stories.tsx b/packages/ui/src/components/Banner/Banner.stories.tsx
new file mode 100644
index 0000000000..74c676f15f
--- /dev/null
+++ b/packages/ui/src/components/Banner/Banner.stories.tsx
@@ -0,0 +1,28 @@
+import { PIXEL_ALL_THEMES } from "#storybook";
+
+import { Banner } from "./Banner";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+const Banners = (): React.JSX.Element => (
+
+ A new version of the agent is available.
+ undefined}>
+ Couldn't refresh. Showing results from 2:05 PM.
+
+
+ The deployment rejected the request: a message long enough to wrap onto a
+ second line.
+
+
+);
+
+const meta: Meta = {
+ title: "UI/Banner",
+ component: Banners,
+ parameters: { pixel: PIXEL_ALL_THEMES },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Severities: Story = {};
diff --git a/packages/ui/src/components/Banner/Banner.tsx b/packages/ui/src/components/Banner/Banner.tsx
new file mode 100644
index 0000000000..f602ef96db
--- /dev/null
+++ b/packages/ui/src/components/Banner/Banner.tsx
@@ -0,0 +1,49 @@
+import { type ComponentProps, type ReactNode } from "react";
+
+import { cx } from "#cx";
+
+import { Icon } from "../Icon/Icon";
+
+import "./Banner.css";
+
+import type { CodiconName } from "#codicons";
+
+type BannerSeverity = "info" | "warning" | "error";
+
+const ICONS: Readonly> = {
+ info: "info",
+ warning: "alert",
+ error: "error",
+};
+
+export interface BannerProps extends Omit, "role"> {
+ severity?: BannerSeverity;
+ children: ReactNode;
+ actionLabel?: string;
+ onAction?: () => void;
+}
+
+export function Banner({
+ severity = "info",
+ children,
+ actionLabel,
+ onAction,
+ className,
+ ...props
+}: BannerProps): React.JSX.Element {
+ return (
+
+
+
{children}
+ {actionLabel && onAction ? (
+
+ {actionLabel}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx
index 95ae4539bb..48c13e0d78 100644
--- a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx
+++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx
@@ -2,7 +2,15 @@ import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { MenuContent, type MenuContentProps } from "../Menu/Menu";
-export const DropdownMenu = MenuPrimitive.Root;
+import type { ComponentProps } from "react";
+
+/** Non-modal: undoing a modal menu's scroll lock and backdrop flashes the webview. */
+export function DropdownMenu({
+ modal = false,
+ ...props
+}: ComponentProps): React.JSX.Element {
+ return ;
+}
export const DropdownMenuTrigger = MenuPrimitive.Trigger;
diff --git a/packages/ui/src/components/IconButton/IconButton.css b/packages/ui/src/components/IconButton/IconButton.css
index 487981abc7..875d290d42 100644
--- a/packages/ui/src/components/IconButton/IconButton.css
+++ b/packages/ui/src/components/IconButton/IconButton.css
@@ -13,7 +13,9 @@
/* One outline serves hover, toggled, and focus, so :where() keeps the
exclusions weightless and source order alone decides. */
-.ui-icon-button:hover:where(:not(:disabled, [aria-pressed="true"])) {
+.ui-icon-button:is(:hover, [data-popup-open]):where(
+ :not(:disabled, [aria-pressed="true"])
+ ) {
background: var(--ui-toolbar-hover-background);
outline-color: var(--ui-toolbar-hover-outline);
outline-style: dashed;
@@ -31,8 +33,7 @@
outline-style: solid;
}
-/* Last: native's ring is an outline, the others border colors, so it wins */
-.ui-icon-button:focus {
+.ui-icon-button:focus-visible {
outline-color: var(--ui-focus-border);
outline-style: solid;
}
diff --git a/packages/ui/src/components/IconButton/IconButton.tsx b/packages/ui/src/components/IconButton/IconButton.tsx
index 2ec58815d9..477eb20f1e 100644
--- a/packages/ui/src/components/IconButton/IconButton.tsx
+++ b/packages/ui/src/components/IconButton/IconButton.tsx
@@ -27,6 +27,7 @@ export function IconButton({
tooltip = label,
className,
type = "button",
+ onMouseDown,
...props
}: IconButtonProps): React.JSX.Element {
const button = (
@@ -35,6 +36,10 @@ export function IconButton({
type={type}
aria-label={label}
className={cx("ui-control", "ui-icon-button", className)}
+ onMouseDown={(event) => {
+ onMouseDown?.(event);
+ event.preventDefault();
+ }}
>
diff --git a/packages/ui/src/components/Menu/Menu.css b/packages/ui/src/components/Menu/Menu.css
index 80b7877199..08d885bd6f 100644
--- a/packages/ui/src/components/Menu/Menu.css
+++ b/packages/ui/src/components/Menu/Menu.css
@@ -68,6 +68,11 @@
background: var(--ui-menu-separator);
}
+/* A hidden trigger otherwise drops the closing menu into the corner. */
+[data-anchor-hidden] > .ui-menu {
+ visibility: hidden;
+}
+
/* Modern UI menu motion (contextview.ts). The positioner supplies the
anchor-corner origin, and holds unmount until the close animation ends. */
@media (prefers-reduced-motion: no-preference) {
diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx
index ff0bd99ded..9da33bebb3 100644
--- a/packages/ui/src/components/Menu/Menu.tsx
+++ b/packages/ui/src/components/Menu/Menu.tsx
@@ -77,6 +77,7 @@ export function MenuSeparator({
export function MenuCheckboxItem({
className,
children,
+ closeOnClick = true,
...props
}: Styled<
ComponentPropsWithRef
@@ -84,6 +85,7 @@ export function MenuCheckboxItem({
return (
} />
@@ -95,12 +97,17 @@ export function MenuCheckboxItem({
export function MenuRadioItem({
className,
children,
+ closeOnClick = true,
...props
}: Styled<
ComponentPropsWithRef
>): React.JSX.Element {
return (
-
+
} />
{children}
diff --git a/packages/ui/src/components/ProgressBar/ProgressBar.css b/packages/ui/src/components/ProgressBar/ProgressBar.css
index 5dcdd4453b..5d4d770afe 100644
--- a/packages/ui/src/components/ProgressBar/ProgressBar.css
+++ b/packages/ui/src/components/ProgressBar/ProgressBar.css
@@ -55,5 +55,6 @@
/* data-pixel is set in .storybook/preview.ts via isPixel(). */
:root[data-pixel] .ui-progress-bar--indeterminate .ui-progress-bar__indicator {
+ inset-inline-start: 0;
animation: none;
}
diff --git a/packages/ui/src/components/Tree/Tree.css b/packages/ui/src/components/Tree/Tree.css
index 565068923c..4a530e8b06 100644
--- a/packages/ui/src/components/Tree/Tree.css
+++ b/packages/ui/src/components/Tree/Tree.css
@@ -97,11 +97,19 @@
}
.ui-tree--focused
+ .ui-tree-item--focused[aria-selected="true"]
+ > .ui-tree-item__row,
+.ui-tree-sticky:focus
.ui-tree-item--focused[aria-selected="true"]
> .ui-tree-item__row {
outline-color: var(--ui-list-focus-and-selection-outline);
}
+.ui-tree-sticky:focus .ui-tree-item--focused > .ui-tree-item__row {
+ outline-style: solid;
+ outline-color: var(--ui-list-focus-outline);
+}
+
.ui-tree-item__indent {
position: absolute;
inset-block: 0;
@@ -185,10 +193,18 @@
.ui-tree-item:is([aria-selected="true"], .ui-tree-item--focused)
> .ui-tree-item__row
.ui-tree-item__action,
-.ui-tree-item__row:is(:hover, :focus-within) .ui-tree-item__action {
+.ui-tree-item__row:is(:hover, :focus-within, :has([data-popup-open]))
+ .ui-tree-item__action {
display: inline-flex;
}
+.ui-tree--focused
+ .ui-tree-item[aria-selected="true"]
+ > .ui-tree-item__row
+ .ui-tree-item__action {
+ --ui-toolbar-foreground: var(--ui-list-active-selection-icon-foreground);
+}
+
/* Modern UI insets the rows; data-ui-style="stable" keeps them edge to edge. */
:where(:root:not([data-ui-style="stable"])) .ui-tree-item__row {
margin-inline: var(--ui-spacing-40);
diff --git a/packages/ui/src/components/Tree/Tree.stories.tsx b/packages/ui/src/components/Tree/Tree.stories.tsx
index 1933042671..d34f9f7a14 100644
--- a/packages/ui/src/components/Tree/Tree.stories.tsx
+++ b/packages/ui/src/components/Tree/Tree.stories.tsx
@@ -1,3 +1,4 @@
+import { useId } from "react";
import {
expect,
fireEvent,
@@ -10,7 +11,13 @@ import {
import { PIXEL_ALL_THEMES } from "#storybook";
import { TreeDemo } from "../../../storybook/Tree.demo";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "../DropdownMenu/DropdownMenu";
import { IconButton } from "../IconButton/IconButton";
+import { MenuItem } from "../Menu/Menu";
import type { Meta, StoryObj } from "@storybook/react-vite";
@@ -92,6 +99,32 @@ const exerciseTree: NonNullable = async ({ canvasElement }) => {
await user.unhover(readme);
};
+export const OpenRowMenu: Story = {
+ render: function OpenMenuTree() {
+ const triggerId = useId();
+ return tree({
+ "aria-label": "Row actions",
+ selectedItemId: "first",
+ nodes: [
+ node("first"),
+ node("second", {
+ action: (
+
+ }
+ />
+
+ Settings
+
+
+ ),
+ }),
+ ],
+ });
+ },
+};
+
export const States: Story = { play: exerciseTree };
export const Stable: Story = {
globals: { uiStyle: "stable" },
diff --git a/packages/ui/src/components/Tree/Tree.tsx b/packages/ui/src/components/Tree/Tree.tsx
index e5939edde6..411e6a52b2 100644
--- a/packages/ui/src/components/Tree/Tree.tsx
+++ b/packages/ui/src/components/Tree/Tree.tsx
@@ -36,9 +36,16 @@ type TreeContainerProps = Omit<
export type TreeProps = TreeOwnProps & SelectionProps & TreeContainerProps;
-/** Whether the focus or blur target is inside the tree rather than a portal. */
+/**
+ * Whether the focus or blur target is inside the tree rather than a portal.
+ * Like native, focusing the sticky widget leaves the tree's rows inactive.
+ */
function ownsTarget(tree: HTMLElement, target: EventTarget | null): boolean {
- return target instanceof Node && tree.contains(target);
+ return (
+ target instanceof Element &&
+ tree.contains(target) &&
+ target.closest(".ui-tree-sticky") === null
+ );
}
/** A controlled tree following the current VS Code workbench behavior. */
diff --git a/packages/ui/src/components/Tree/sticky/StickyScroll.tsx b/packages/ui/src/components/Tree/sticky/StickyScroll.tsx
index 05490aba2e..1c97b1e07c 100644
--- a/packages/ui/src/components/Tree/sticky/StickyScroll.tsx
+++ b/packages/ui/src/components/Tree/sticky/StickyScroll.tsx
@@ -74,6 +74,7 @@ export function StickyScroll({
.filter((row) => row !== undefined);
const pinnedHeight = pinnedRows.length * ROW_HEIGHT_PX + state.pushOffset;
const [requestedIndex, setRequestedIndex] = useState(0);
+ const [hasFocus, setHasFocus] = useState(false);
const focusedIndex = Math.max(
0,
Math.min(requestedIndex, pinnedRows.length - 1),
@@ -113,9 +114,16 @@ export function StickyScroll({
ref={widgetRef}
tabIndex={pinnedRows.length > 0 ? 0 : -1}
onFocus={(event) => {
- if (event.target === event.currentTarget)
+ if (event.target === event.currentTarget) {
setRequestedIndex(focusedIndex);
+ setHasFocus(true);
+ }
}}
+ onBlur={(event) => {
+ if (event.target === event.currentTarget) setHasFocus(false);
+ }}
+ // Like native, a press leaves focus where it was; the click moves it.
+ onMouseDown={(event) => event.preventDefault()}
onKeyDown={(event) => {
const row = pinnedRows[focusedIndex];
if (!row) return;
@@ -184,7 +192,7 @@ export function StickyScroll({
Loading workspaces...
;
- }
- return {JSON.stringify(state, null, 2)} ;
+ const { state, api } = useWorkspaces();
+ return (
+
+
+
+ );
}
diff --git a/packages/workspaces/src/components/AgentRow.tsx b/packages/workspaces/src/components/AgentRow.tsx
new file mode 100644
index 0000000000..432ba85a8f
--- /dev/null
+++ b/packages/workspaces/src/components/AgentRow.tsx
@@ -0,0 +1,121 @@
+import { Icon, Spinner, type TreeNode } from "@repo/ui";
+import { formatDistanceToNowStrict } from "date-fns";
+
+import { RowHover } from "./RowHover";
+import { formatStatus, StatusDot } from "./StatusDot";
+
+import type { AgentMetadataState, WorkspaceAgent } from "@repo/shared";
+
+function agentStatus(agent: WorkspaceAgent): string {
+ if (agent.status !== "connected") {
+ return agent.status;
+ }
+ if (agent.lifecycle_state !== "ready") {
+ return agent.lifecycle_state;
+ }
+ return agent.health.healthy ? "connected" : "unhealthy";
+}
+
+export function AgentHover({ agent }: { agent: WorkspaceAgent }) {
+ return (
+
+ );
+}
+
+export function AgentRow({ agent }: { agent: WorkspaceAgent }) {
+ return (
+
+
+ {agent.name || "Unknown"}
+
+
+ );
+}
+
+export function appStatusNodes(agent: WorkspaceAgent): readonly TreeNode[] {
+ return agent.apps.flatMap((app) => {
+ if (app.statuses.length === 0) {
+ return [];
+ }
+ const status = app.statuses.reduce((latest, candidate) =>
+ Date.parse(candidate.created_at) > Date.parse(latest.created_at)
+ ? candidate
+ : latest,
+ );
+ const text = `${app.display_name || app.slug}: ${status.message || formatStatus(status.state)}`;
+ return [
+ {
+ id: `app-status:${app.id}`,
+ textValue: text,
+ label: (
+
+
+ {text}
+
+ ),
+ },
+ ];
+ });
+}
+
+export function metadataNodes(
+ agentId: string,
+ state: AgentMetadataState | undefined,
+): readonly TreeNode[] {
+ const prefix = `metadata:${agentId}`;
+ if (!state || state.kind === "pending") {
+ return [
+ {
+ id: `${prefix}:loading`,
+ textValue: "Loading metadata",
+ label: (
+
+
+ Loading metadata...
+
+ ),
+ },
+ ];
+ }
+ if (state.kind === "failed") {
+ return [{ id: `${prefix}:error`, label: state.error, icon: "error" }];
+ }
+ if (state.metadata.length === 0) {
+ return [{ id: `${prefix}:empty`, label: "No metadata reported" }];
+ }
+ return state.metadata.map(({ description, result }) => {
+ const name = description.display_name || description.key;
+ const value = result.error || result.value || "Unknown";
+ return {
+ id: `${prefix}:${description.key}`,
+ textValue: `${name}: ${value}`,
+ label: (
+
+ {name}:
+ {value}
+
+ ),
+ tooltip: (
+ <>
+ {name}: {value}
+
+ Collected{" "}
+ {formatDistanceToNowStrict(new Date(result.collected_at), {
+ addSuffix: true,
+ })}
+ >
+ ),
+ };
+ });
+}
diff --git a/packages/workspaces/src/components/RowHover.tsx b/packages/workspaces/src/components/RowHover.tsx
new file mode 100644
index 0000000000..6fcba645cb
--- /dev/null
+++ b/packages/workspaces/src/components/RowHover.tsx
@@ -0,0 +1,23 @@
+import { Fragment } from "react";
+
+export function RowHover({
+ title,
+ details,
+}: {
+ title: string;
+ details: ReadonlyArray;
+}) {
+ return (
+
+
{title}
+
+ {details.map(([label, value]) => (
+
+ {label}
+ {value}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/workspaces/src/components/StatusDot.tsx b/packages/workspaces/src/components/StatusDot.tsx
new file mode 100644
index 0000000000..a4d42ad7e5
--- /dev/null
+++ b/packages/workspaces/src/components/StatusDot.tsx
@@ -0,0 +1,48 @@
+import { Icon } from "@repo/ui";
+
+type StatusTone = "running" | "pending" | "error" | "stopped";
+
+export function formatStatus(status: string): string {
+ const words = status.replaceAll("_", " ");
+ return words.charAt(0).toUpperCase() + words.slice(1);
+}
+
+export function StatusDot({ status }: { status: string }) {
+ return (
+
+ );
+}
+
+function statusTone(status: string): StatusTone {
+ switch (status) {
+ case "running":
+ case "connected":
+ case "complete":
+ return "running";
+ case "pending":
+ case "working":
+ case "connecting":
+ case "starting":
+ case "stopping":
+ case "deleting":
+ case "canceling":
+ case "created":
+ case "shutting_down":
+ return "pending";
+ case "failed":
+ case "failure":
+ case "unhealthy":
+ case "timeout":
+ case "start_error":
+ case "start_timeout":
+ case "shutdown_error":
+ case "shutdown_timeout":
+ return "error";
+ default:
+ return "stopped";
+ }
+}
diff --git a/packages/workspaces/src/components/WorkspaceActions.tsx b/packages/workspaces/src/components/WorkspaceActions.tsx
new file mode 100644
index 0000000000..5e3f8a106e
--- /dev/null
+++ b/packages/workspaces/src/components/WorkspaceActions.tsx
@@ -0,0 +1,117 @@
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+ Icon,
+ IconButton,
+ MenuItem,
+ MenuSeparator,
+} from "@repo/ui";
+
+import type { Workspace } from "@repo/shared";
+
+import type { WorkspaceCommands } from "./WorkspacesPanel";
+
+export function WorkspaceActions({
+ workspaceId,
+ name,
+ agentId,
+ status,
+ api,
+}: {
+ workspaceId: string;
+ name: string;
+ agentId?: string;
+ status?: Workspace["latest_build"]["status"];
+ api: Pick;
+}) {
+ const lifecycleActions =
+ status === "running"
+ ? ["Stop", "Restart"]
+ : status === "stopped"
+ ? ["Start"]
+ : status === "failed" || status === "canceled"
+ ? ["Retry"]
+ : [];
+ return (
+ <>
+
+ api.openWorkspace({ workspaceId, ...(agentId ? { agentId } : {}) })
+ }
+ />
+ api.viewInDashboard({ workspaceId, page: "workspace" })}
+ />
+ {!agentId && (
+
+
+ }
+ />
+
+ {lifecycleActions.map((label) => (
+
+
+ {label}
+
+ ))}
+ {lifecycleActions.length > 0 && }
+
+
+ Share
+
+
+ api.viewInDashboard({ workspaceId, page: "settings" })
+ }
+ >
+
+ Settings
+
+
+
+
+ Ping
+
+
+
+ Speed Test
+
+
+
+ Support Bundle
+
+
+
+
+ Delete...
+
+
+
+ )}
+ >
+ );
+}
diff --git a/packages/workspaces/src/components/WorkspaceRow.tsx b/packages/workspaces/src/components/WorkspaceRow.tsx
new file mode 100644
index 0000000000..038fe392ec
--- /dev/null
+++ b/packages/workspaces/src/components/WorkspaceRow.tsx
@@ -0,0 +1,65 @@
+import { StatusPill } from "@repo/ui";
+
+import { RowHover } from "./RowHover";
+import { formatStatus, StatusDot } from "./StatusDot";
+
+import type { Workspace } from "@repo/shared";
+
+/** Health is only meaningful for the build that is running. */
+function workspaceStatus(workspace: Workspace): string {
+ const status = workspace.latest_build.status;
+ return status === "running" && !workspace.health.healthy
+ ? "unhealthy"
+ : status;
+}
+
+export function WorkspaceHover({
+ workspace,
+ showOwner,
+}: {
+ workspace: Workspace;
+ showOwner: boolean;
+}) {
+ const template =
+ workspace.template_display_name || workspace.template_name || "Unknown";
+ const status = formatStatus(workspaceStatus(workspace));
+ return (
+
+ );
+}
+
+export function WorkspaceRow({
+ workspace,
+ showOwner,
+ hasAgentChat,
+}: {
+ workspace: Workspace;
+ showOwner: boolean;
+ hasAgentChat: boolean;
+}) {
+ return (
+
+
+ {workspace.name || "Unknown"}
+ {hasAgentChat && (
+
+ Agent
+
+ )}
+ {showOwner && (
+
+ {workspace.owner_name || "Unknown"}
+
+ )}
+
+ );
+}
diff --git a/packages/workspaces/src/components/WorkspacesPanel.stories.tsx b/packages/workspaces/src/components/WorkspacesPanel.stories.tsx
new file mode 100644
index 0000000000..2afd7f690c
--- /dev/null
+++ b/packages/workspaces/src/components/WorkspacesPanel.stories.tsx
@@ -0,0 +1,294 @@
+import {
+ agent,
+ agentMetadata,
+ app,
+ appStatus,
+ PENDING_METADATA,
+ workspace,
+} from "@repo/mocks";
+import { fn, screen, userEvent, within } from "storybook/test";
+
+import { WorkspacesPanel } from "./WorkspacesPanel";
+
+import type { Workspace, WorkspacesState } from "@repo/shared";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+const WORKSPACES: readonly Workspace[] = [
+ workspace({
+ id: "ws-frontend",
+ name: "frontend",
+ owner_name: "alice",
+ agents: [
+ agent({
+ id: "agent-main",
+ name: "main",
+ apps: [
+ app({
+ id: "claude",
+ statuses: [appStatus({ id: "working", message: "Running tests" })],
+ }),
+ app({
+ id: "codex",
+ display_name: "Codex",
+ statuses: [
+ appStatus({
+ id: "complete",
+ state: "complete",
+ message: "Opened a pull request",
+ }),
+ ],
+ }),
+ app({
+ id: "aider",
+ display_name: "Aider",
+ statuses: [
+ appStatus({
+ id: "failure",
+ state: "failure",
+ message: "Build failed",
+ }),
+ ],
+ }),
+ app({
+ id: "goose",
+ display_name: "Goose",
+ statuses: [appStatus({ id: "idle", state: "idle", message: "" })],
+ }),
+ ],
+ }),
+ agent({
+ id: "agent-devcontainer",
+ name: "devcontainer",
+ parent_id: "agent-main",
+ }),
+ ],
+ }),
+ workspace({
+ id: "ws-api",
+ name: "api-server",
+ owner_name: "bob",
+ latest_build: { status: "starting" },
+ agents: [
+ agent({
+ id: "agent-api",
+ name: "main",
+ lifecycle_state: "starting",
+ }),
+ agent({ id: "agent-worker", name: "worker", status: "connecting" }),
+ ],
+ }),
+ workspace({
+ id: "ws-training",
+ name: "ml-training-cluster-with-a-really-long-name",
+ owner_name: "a-user-with-a-long-username",
+ latest_build: { status: "failed" },
+ agents: [
+ agent({
+ id: "agent-gpu",
+ name: "gpu-node-with-an-equally-long-agent-name",
+ status: "timeout",
+ }),
+ ],
+ }),
+ workspace({
+ id: "ws-unhealthy",
+ name: "unhealthy",
+ owner_name: "carol",
+ health: { healthy: false, failing_agents: ["agent-sick"] },
+ agents: [
+ agent({
+ id: "agent-sick",
+ name: "main",
+ health: { healthy: false, reason: "agent has lost connection" },
+ }),
+ agent({
+ id: "agent-offline",
+ name: "offline",
+ status: "disconnected",
+ }),
+ ],
+ }),
+ workspace({
+ id: "ws-docs",
+ name: "docs",
+ owner_name: "carol",
+ latest_build: { status: "stopped" },
+ }),
+ workspace({ id: "ws-unknown", name: "", owner_name: "" }),
+];
+
+const READY: WorkspacesState = {
+ capabilities: { authenticated: true, filters: ["mine", "shared", "all"] },
+ filter: "all",
+ agentWorkspaceIds: ["ws-api"],
+ workspaces: WORKSPACES,
+ status: { kind: "ready" },
+ // Local time, so it reads 2:05 PM in every time zone.
+ listedAt: new Date(2024, 0, 1, 14, 5).getTime(),
+ metadata: {
+ "agent-main": {
+ kind: "reported",
+ metadata: [
+ agentMetadata(),
+ agentMetadata({
+ description: { key: "memory", display_name: "Memory" },
+ result: { value: "3.1/8 GiB" },
+ }),
+ agentMetadata({
+ description: { key: "disk", display_name: "Disk" },
+ result: { value: "", error: "df: permission denied" },
+ }),
+ agentMetadata({
+ description: { key: "load", display_name: "" },
+ result: { value: "" },
+ }),
+ agentMetadata({
+ description: {
+ key: "branch",
+ display_name: "A metadata name long enough to truncate",
+ },
+ result: { value: "feature/a-branch-name-that-is-also-long" },
+ }),
+ ],
+ },
+ "agent-devcontainer": PENDING_METADATA,
+ "agent-api": { kind: "reported", metadata: [] },
+ "agent-gpu": { kind: "failed", error: "Agent unreachable" },
+ },
+};
+
+const api = {
+ login: fn(),
+ openWorkspace: fn(),
+ viewInDashboard: fn(),
+ refresh: fn(),
+ setFilter: fn(),
+ watchAgents: fn(),
+} as const;
+
+const meta: Meta = {
+ title: "Workspaces/WorkspacesPanel",
+ component: WorkspacesPanel,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ parameters: {
+ pixel: {
+ matrix: {
+ themes: ["light", "dark", "high-contrast", "high-contrast-light"],
+ },
+ },
+ },
+ args: { state: READY, api },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Collapsed: Story = {};
+
+export const Expanded: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ for (const name of [
+ "frontend alice",
+ "ml-training-cluster-with-a-really-long-name a-user-with-a-long-username",
+ ]) {
+ await userEvent.click(canvas.getByRole("treeitem", { name }));
+ }
+ for (const heading of canvas.getAllByRole("treeitem", {
+ name: "Metadata",
+ })) {
+ await userEvent.click(heading);
+ }
+ await canvas.findByRole("treeitem", { name: "Memory: 3.1/8 GiB" });
+ },
+};
+
+export const MenuOpen: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(
+ canvas.getByRole("treeitem", { name: "frontend alice" }),
+ );
+ await userEvent.click(
+ canvas.getByRole("button", { name: "More actions for frontend" }),
+ );
+ await screen.findByRole("menu");
+ },
+};
+
+export const FilterOpen: Story = {
+ play: async ({ canvasElement }) => {
+ await userEvent.click(
+ within(canvasElement).getByRole("button", {
+ name: "Workspace filter: All",
+ }),
+ );
+ await screen.findByRole("menuitemradio", { name: "Shared" });
+ },
+};
+
+const STATES: ReadonlyArray<
+ readonly [label: string, state: WorkspacesState | undefined]
+> = [
+ ["Loading", undefined],
+ ["Empty", { ...READY, workspaces: [] }],
+ [
+ "Signed out",
+ {
+ ...READY,
+ workspaces: [],
+ capabilities: { authenticated: false, filters: [] },
+ },
+ ],
+ [
+ "Failed",
+ {
+ ...READY,
+ workspaces: [],
+ status: { kind: "failed", error: "Could not reach the deployment." },
+ },
+ ],
+ ["Mine", { ...READY, filter: "mine" }],
+ ["Refreshing", { ...READY, status: { kind: "loading" } }],
+ [
+ "Out of date",
+ {
+ ...READY,
+ status: { kind: "failed", error: "Request timed out." },
+ },
+ ],
+];
+
+export const States: Story = {
+ parameters: { rootWidth: 640 },
+ render: () => (
+
+ {STATES.map(([label, state]) => (
+
+ ))}
+
+ ),
+};
diff --git a/packages/workspaces/src/components/WorkspacesPanel.tsx b/packages/workspaces/src/components/WorkspacesPanel.tsx
new file mode 100644
index 0000000000..7ba23a60f3
--- /dev/null
+++ b/packages/workspaces/src/components/WorkspacesPanel.tsx
@@ -0,0 +1,283 @@
+import {
+ Banner,
+ Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+ EmptyState,
+ ErrorState,
+ Icon,
+ LoadingState,
+ MenuRadioGroup,
+ MenuRadioItem,
+ ProgressBar,
+ Tooltip,
+ TooltipProvider,
+ Tree,
+ type TreeNode,
+} from "@repo/ui";
+import { format, formatISO } from "date-fns";
+import { useEffect, useEffectEvent, useState } from "react";
+
+import {
+ AgentHover,
+ AgentRow,
+ appStatusNodes,
+ metadataNodes,
+} from "./AgentRow";
+import { WorkspaceActions } from "./WorkspaceActions";
+import { WorkspaceHover, WorkspaceRow } from "./WorkspaceRow";
+
+import type { Workspace, WorkspaceAgent, WorkspacesState } from "@repo/shared";
+
+import type { useWorkspaces } from "../hooks/useWorkspaces";
+
+export type WorkspaceCommands = Pick<
+ ReturnType["api"],
+ | "login"
+ | "openWorkspace"
+ | "viewInDashboard"
+ | "refresh"
+ | "setFilter"
+ | "watchAgents"
+>;
+
+const FILTERS = [
+ { value: "all", label: "All" },
+ { value: "mine", label: "Mine" },
+ { value: "shared", label: "Shared" },
+] as const;
+
+function workspaceAgents(workspace: Workspace) {
+ return workspace.latest_build.resources.flatMap(
+ (resource) => resource.agents ?? [],
+ );
+}
+
+export function WorkspacesPanel({
+ state,
+ api,
+}: {
+ state?: WorkspacesState;
+ api: WorkspaceCommands;
+}) {
+ const [expandedIds, setExpandedIds] = useState([]);
+ const [selectedItemId, setSelectedItemId] = useState();
+ const workspaces = state?.capabilities.authenticated ? state.workspaces : [];
+ const showOwner = state?.filter !== "mine";
+ const filterLabel = FILTERS.find(
+ ({ value }) => value === state?.filter,
+ )?.label;
+ const agentWorkspaces = new Set(state?.agentWorkspaceIds ?? []);
+ const watched: string[] = [];
+
+ const nodes: readonly TreeNode[] = workspaces.map((workspace) => {
+ const agents = workspaceAgents(workspace);
+ const agentNode = (agent: WorkspaceAgent, visible: boolean): TreeNode => {
+ const metadataId = `metadata:${agent.id}`;
+ const childrenVisible = visible && expandedIds.includes(agent.id);
+ if (childrenVisible && expandedIds.includes(metadataId)) {
+ watched.push(agent.id);
+ }
+ const name = agent.name || "Unknown";
+ return {
+ id: agent.id,
+ textValue: name,
+ tooltip: ,
+ label: ,
+ action: (
+
+ ),
+ children: [
+ ...appStatusNodes(agent),
+ ...agents
+ .filter((child) => child.parent_id === agent.id)
+ .map((child) => agentNode(child, childrenVisible)),
+ {
+ id: metadataId,
+ label: "Metadata",
+ className: "workspace-metadata-heading",
+ children: metadataNodes(agent.id, state?.metadata[agent.id]),
+ },
+ ],
+ };
+ };
+ const children = agents
+ .filter(
+ (agent) =>
+ !agent.parent_id ||
+ !agents.some((parent) => parent.id === agent.parent_id),
+ )
+ .map((agent) => agentNode(agent, expandedIds.includes(workspace.id)));
+ const name = workspace.name || "Unknown";
+ const hasAgentChat = agentWorkspaces.has(workspace.id);
+ return {
+ id: workspace.id,
+ textValue: `${name}${hasAgentChat ? " Agent" : ""}${showOwner ? ` ${workspace.owner_name || "Unknown"}` : ""}`,
+ tooltip: ,
+ label: (
+
+ ),
+ action: (
+
+ ),
+ children: children.length
+ ? children
+ : [{ id: `agents:${workspace.id}`, label: "No workspace agents" }],
+ };
+ });
+
+ const watchedKey = JSON.stringify(watched.sort());
+ const syncWatch = useEffectEvent(() =>
+ api.watchAgents({ agentIds: watched }),
+ );
+ useEffect(() => {
+ syncWatch();
+ }, [watchedKey]);
+
+ if (state && !state.capabilities.authenticated) {
+ return (
+ api.login()}>Login}
+ />
+ );
+ }
+
+ const loading = !state || state.status.kind === "loading";
+ return (
+
+
+ {state && (
+
+
+
+ {filterLabel}
+
+
+ }
+ />
+
+ {
+ const choice = FILTERS.find(
+ ({ value }) => value === filter,
+ );
+ if (choice) api.setFilter({ filter: choice.value });
+ }}
+ >
+ {FILTERS.map(({ value, label }) => (
+
+ {label}
+
+ ))}
+
+
+
+ {state.listedAt !== undefined && state.status.kind !== "failed" && (
+
+
+
+
+ Updated {format(state.listedAt, "p")}
+
+
+
+ )}
+
+ )}
+ {state?.status.kind === "failed" &&
+ (nodes.length > 0 ? (
+ api.refresh()}
+ >
+ Couldn't refresh.
+ {state.listedAt !== undefined &&
+ ` Showing results from ${format(state.listedAt, "p")}.`}
+
+ {state.status.error}
+
+
+ ) : (
+ api.refresh()}
+ />
+ ))}
+ {nodes.length > 0 ? (
+ <>
+ {loading && (
+
+ )}
+ {
+ const opened = workspaces.filter(
+ (workspace) =>
+ ids.includes(workspace.id) &&
+ !expandedIds.includes(workspace.id),
+ );
+ setExpandedIds([
+ ...ids,
+ ...opened
+ .flatMap(workspaceAgents)
+ .map((agent) => agent.id)
+ .filter((id) => !ids.includes(id)),
+ ]);
+ }}
+ selectedItemId={selectedItemId}
+ onSelectedItemChange={setSelectedItemId}
+ />
+ >
+ ) : loading ? (
+
+ ) : (
+ state.status.kind !== "failed" && (
+
+ )
+ )}
+
+
+ );
+}
diff --git a/packages/workspaces/src/hooks/useWorkspaces.ts b/packages/workspaces/src/hooks/useWorkspaces.ts
index 86efb089ef..df454f232c 100644
--- a/packages/workspaces/src/hooks/useWorkspaces.ts
+++ b/packages/workspaces/src/hooks/useWorkspaces.ts
@@ -6,19 +6,17 @@ import {
import { useIpc } from "@repo/webview-shared/react";
import { useEffect, useState } from "react";
-/**
- * The state the extension pushes, and the commands to send back. The state is
- * undefined until the extension answers `ready`.
- */
+/** `state` is undefined until the extension answers `ready`. */
export function useWorkspaces() {
- const api = buildApiHook(WorkspacesApi, useIpc());
+ const ipc = useIpc();
+ const [api] = useState(() => buildApiHook(WorkspacesApi, ipc));
const [state, setState] = useState();
useEffect(() => {
const unsubscribe = api.onStateChanged(setState);
api.ready();
return unsubscribe;
- }, []);
+ }, [api]);
return { state, api };
}
diff --git a/packages/workspaces/src/index.css b/packages/workspaces/src/index.css
index 8f414f586f..8df0740b41 100644
--- a/packages/workspaces/src/index.css
+++ b/packages/workspaces/src/index.css
@@ -1 +1,191 @@
-/* TODO */
+body {
+ margin: 0;
+ padding: 0;
+ font-family: var(--ui-font-family, sans-serif);
+ font-size: var(--ui-font-size, 13px);
+ color: var(--ui-foreground);
+ background: var(--ui-background);
+}
+
+.workspaces-app {
+ height: 100vh;
+}
+
+.workspaces-panel {
+ position: relative;
+ box-sizing: border-box;
+ height: 100%;
+ overflow-y: auto;
+ padding-block: var(--ui-spacing-40);
+}
+
+.workspaces-filter-bar,
+.workspaces-updated {
+ display: flex;
+ align-items: center;
+}
+
+.workspaces-filter-bar {
+ padding: 0 var(--ui-spacing-120) var(--ui-spacing-40) 14px;
+ color: var(--ui-description-foreground);
+ white-space: nowrap;
+}
+
+.workspaces-filter-bar .workspace-filter {
+ flex: none;
+ height: 22px;
+ padding: 0 var(--ui-spacing-40);
+ gap: 2px;
+ font-size: 12px;
+ color: inherit;
+ background: transparent;
+ border-color: transparent;
+}
+
+.workspaces-filter-bar .workspace-filter:is(:hover, [data-popup-open]) {
+ background: var(--ui-toolbar-hover-background);
+}
+
+.workspaces-filter-bar .workspace-filter:focus:not(:focus-visible) {
+ outline: none;
+}
+
+.workspaces-filter-bar .workspace-filter > .ui-icon {
+ width: 12px;
+ font-size: 12px;
+}
+
+.workspaces-updated {
+ gap: var(--ui-spacing-60);
+ min-width: 0;
+ font-size: var(--ui-font-size-small);
+}
+
+.workspaces-updated__text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.workspaces-updated__separator {
+ width: 4px;
+ height: 4px;
+ margin-inline: var(--ui-spacing-40);
+ flex: none;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.workspaces-panel .workspaces-refresh-progress {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline: 0;
+}
+
+.workspaces-stale {
+ margin: 0 var(--ui-spacing-120) var(--ui-spacing-60);
+}
+
+.workspace-label {
+ display: flex;
+ align-items: center;
+ gap: var(--ui-spacing-60);
+ min-width: 0;
+ width: 100%;
+}
+
+.workspace-name,
+.workspace-owner {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.workspaces-stale__error,
+.workspace-owner,
+.workspace-agent-icon,
+.workspace-status,
+.workspace-metadata-heading,
+.workspace-hover__details > dt {
+ color: var(--ui-description-foreground);
+}
+
+.workspace-agent-badge,
+.workspace-label > .ui-spinner,
+.workspace-agent-icon,
+.workspace-status {
+ flex: none;
+}
+
+.workspace-status--running {
+ color: var(--vscode-testing-iconPassed, var(--ui-status-success-accent));
+}
+
+.workspace-status--pending {
+ color: var(--vscode-testing-messagePeekBorder, var(--ui-status-info-accent));
+}
+
+.workspace-status--error {
+ color: var(--vscode-testing-iconFailed, var(--ui-status-danger-accent));
+}
+
+.workspace-metadata {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--ui-spacing-120);
+ width: 100%;
+ min-width: 0;
+}
+
+.workspace-metadata > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.workspace-metadata > span:last-child {
+ max-width: 65%;
+ text-align: end;
+ font-variant-numeric: tabular-nums;
+}
+
+.workspace-metadata--error,
+.workspace-delete {
+ color: var(--ui-error-foreground);
+}
+
+.ui-tree-item[aria-selected="true"]
+ :is(
+ .workspace-owner,
+ .workspace-agent-icon,
+ .workspace-metadata--error,
+ .workspace-agent-badge
+ ) {
+ color: inherit;
+}
+
+@media (forced-colors: active) {
+ .workspace-status {
+ color: CanvasText;
+ }
+}
+
+.workspace-hover {
+ max-width: 280px;
+}
+
+.workspace-hover__title {
+ font-weight: var(--ui-font-weight-semibold);
+}
+
+.workspace-hover__details {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ column-gap: var(--ui-spacing-120);
+ margin: 0;
+}
+
+.workspace-hover__details > dd {
+ margin: 0;
+ min-width: 0;
+}
diff --git a/packages/workspaces/src/index.tsx b/packages/workspaces/src/index.tsx
index e6bb115928..c673e9c219 100644
--- a/packages/workspaces/src/index.tsx
+++ b/packages/workspaces/src/index.tsx
@@ -1,3 +1,5 @@
+import "@repo/ui/codicon.css";
+import "@repo/ui/tokens.css";
import { ErrorBoundary } from "@repo/webview-shared/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2aa366a5f4..719f7bcb55 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -661,6 +661,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 f73c77cd4d..f742878ffc 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -313,6 +313,10 @@ async function doActivate(
output,
);
+ commandManager.register("coder.workspaces.refresh", () =>
+ workspacesPanelProvider.refresh(),
+ );
+
ctx.subscriptions.push(
workspaceStore,
workspacesPanelProvider,
diff --git a/src/vscode/commandManager.ts b/src/vscode/commandManager.ts
index c61cff95c0..544b0e8739 100644
--- a/src/vscode/commandManager.ts
+++ b/src/vscode/commandManager.ts
@@ -40,6 +40,7 @@ export const CODER_COMMAND_IDS = [
"coder.supportBundle",
"coder.supportBundle:views",
"coder.tasks.refresh",
+ "coder.workspaces.refresh",
] as const;
export type CoderCommandId = (typeof CODER_COMMAND_IDS)[number];
diff --git a/src/webviews/workspaces/workspaceStore.ts b/src/webviews/workspaces/workspaceStore.ts
index bd4bf86fe3..b136093b0f 100644
--- a/src/webviews/workspaces/workspaceStore.ts
+++ b/src/webviews/workspaces/workspaceStore.ts
@@ -1,6 +1,8 @@
+import { isAxiosError } from "axios";
import { isDeepStrictEqual } from "node:util";
import * as vscode from "vscode";
+import { HttpStatusCode } from "../../api/httpStatusCode";
import { extractAllAgents } from "../../api/models";
import { errToStr } from "../../error/normalize";
import { AgentMetadataTracker } from "../../workspace/agentMetadataTracker";
@@ -26,6 +28,7 @@ import type { SessionState } from "../../deployment/sessionStore";
import type { Logger } from "../../logging/logger";
interface WorkspacesClient extends AgentMetadataClient {
+ readonly experimental: Pick;
getWorkspaces(
request: Parameters[0],
): Promise<{ readonly workspaces: readonly Workspace[] }>;
@@ -34,6 +37,7 @@ interface WorkspacesClient extends AgentMetadataClient {
const RETRY: RetryOptions = { initialDelayMs: 5_000, maxDelayMs: 60_000 };
const LOADING: WorkspaceListStatus = { kind: "loading" };
const READY: WorkspaceListStatus = { kind: "ready" };
+const MINUTE_MS = 60_000;
function listedAgentIds(
requested: readonly string[],
@@ -62,6 +66,9 @@ export class WorkspaceStore implements vscode.Disposable {
private filter: WorkspaceFilter = DEFAULT_WORKSPACE_FILTER;
private workspaces: readonly Workspace[] = [];
+ private listedAt: number | undefined;
+ private agentWorkspaceIds: readonly string[] = [];
+ private agentLookupSupported = true;
private status = LOADING;
/** Agents the webview is showing, listed or not. */
private requestedAgents: readonly string[] = [];
@@ -95,6 +102,10 @@ export class WorkspaceStore implements vscode.Disposable {
workspaces: this.workspaces,
status: this.status,
metadata: this.agents.metadata,
+ ...(this.listedAt === undefined ? {} : { listedAt: this.listedAt }),
+ ...(this.agentWorkspaceIds.length
+ ? { agentWorkspaceIds: this.agentWorkspaceIds }
+ : {}),
};
}
@@ -125,6 +136,8 @@ export class WorkspaceStore implements vscode.Disposable {
return this.settled;
}
this.filter = filter;
+ this.listedAt = undefined;
+ this.agentLookupSupported = true;
return this.reload();
}
@@ -141,6 +154,7 @@ export class WorkspaceStore implements vscode.Disposable {
return this.settled;
}
this.unsupportedFilters.clear();
+ this.agentLookupSupported = true;
return this.reload(this.workspaces);
}
@@ -177,7 +191,10 @@ export class WorkspaceStore implements vscode.Disposable {
if (token.isCancellationRequested) {
return "idle";
}
+ // Polls that change nothing then publish at most once a minute.
+ this.listedAt = Math.floor(Date.now() / MINUTE_MS) * MINUTE_MS;
this.setList(workspaces);
+ await this.loadAgentWorkspaces(workspaces, token);
return this.visible && pollIntervalMs !== undefined
? { delayMs: pollIntervalMs }
: "idle";
@@ -189,11 +206,12 @@ export class WorkspaceStore implements vscode.Disposable {
if (this.filter !== DEFAULT_WORKSPACE_FILTER && isQueryRejected(error)) {
this.unsupportedFilters.add(this.filter);
this.filter = DEFAULT_WORKSPACE_FILTER;
+ this.listedAt = undefined;
this.setList([], LOADING);
return this.list(token);
}
this.logger.warn("Failed to fetch workspaces:", error);
- this.setList([], {
+ this.setList(this.workspaces, {
kind: "failed",
error: errToStr(error, "Failed to fetch workspaces"),
});
@@ -201,6 +219,47 @@ export class WorkspaceStore implements vscode.Disposable {
}
}
+ private async loadAgentWorkspaces(
+ workspaces: readonly Workspace[],
+ token: vscode.CancellationToken,
+ ): Promise {
+ if (
+ !this.agentLookupSupported ||
+ !this.visible ||
+ token.isCancellationRequested ||
+ workspaces.length === 0
+ ) {
+ return;
+ }
+ try {
+ const chats = await this.client.experimental.getChatsByWorkspace(
+ workspaces.map((workspace) => workspace.id),
+ );
+ if (this.disposed || token.isCancellationRequested) {
+ return;
+ }
+ this.agentWorkspaceIds = workspaces
+ .filter((workspace) => Boolean(chats[workspace.id]))
+ .map((workspace) => workspace.id);
+ this.publish();
+ } catch (error) {
+ if (this.disposed || token.isCancellationRequested) {
+ return;
+ }
+ const status = isAxiosError(error) ? error.response?.status : undefined;
+ if (
+ status === HttpStatusCode.FORBIDDEN ||
+ status === HttpStatusCode.NOT_FOUND
+ ) {
+ this.agentLookupSupported = false;
+ this.agentWorkspaceIds = [];
+ this.publish();
+ } else {
+ this.logger.warn("Failed to look up Agent workspaces:", error);
+ }
+ }
+ }
+
/** Show `workspaces` as loading, then list again. */
private reload(workspaces: readonly Workspace[] = []): Promise {
this.setList(workspaces, LOADING);
@@ -209,6 +268,9 @@ export class WorkspaceStore implements vscode.Disposable {
private setList(workspaces: readonly Workspace[], status = READY): void {
this.workspaces = workspaces;
+ this.agentWorkspaceIds = this.agentWorkspaceIds.filter((id) =>
+ workspaces.some((workspace) => workspace.id === id),
+ );
this.status = status;
void this.watchListedAgents();
this.publish();
@@ -236,6 +298,9 @@ export class WorkspaceStore implements vscode.Disposable {
private handleSessionChange(): void {
this.unsupportedFilters.clear();
+ this.listedAt = undefined;
+ this.agentLookupSupported = true;
+ this.agentWorkspaceIds = [];
this.requestedAgents = [];
if (!this.filters.includes(this.filter)) {
this.filter = DEFAULT_WORKSPACE_FILTER;
diff --git a/src/webviews/workspaces/workspacesPanelProvider.ts b/src/webviews/workspaces/workspacesPanelProvider.ts
index ab837390b6..8112a5f5b6 100644
--- a/src/webviews/workspaces/workspacesPanelProvider.ts
+++ b/src/webviews/workspaces/workspacesPanelProvider.ts
@@ -45,7 +45,8 @@ export class WorkspacesPanelProvider
void this.store.watchAgents([]);
this.pushState();
},
- refresh: () => this.store.refresh(),
+ refresh: () => this.refresh(),
+ login: () => this.commands.login(),
setFilter: (p) => this.store.setFilter(p.filter),
watchAgents: (p) => this.store.watchAgents(p.agentIds),
openWorkspace: (p) => this.openWorkspace(p),
@@ -65,7 +66,7 @@ export class WorkspacesPanelProvider
private readonly store: WorkspaceStore,
private readonly commands: Pick<
Commands,
- "openWorkspaceFromSidebar" | "openWorkspaceInDashboard"
+ "login" | "openWorkspaceFromSidebar" | "openWorkspaceInDashboard"
>,
private readonly logger: Logger,
) {}
@@ -126,6 +127,17 @@ export class WorkspacesPanelProvider
this.syncVisibility();
}
+ /** List again, reporting a failure since the user asked for it. */
+ public async refresh(): Promise {
+ await this.store.refresh();
+ const { status } = this.store.state;
+ if (status.kind === "failed") {
+ void vscode.window.showErrorMessage(
+ `Failed to refresh workspaces: ${status.error}`,
+ );
+ }
+ }
+
private syncVisibility(): void {
void this.store.setVisible(this.view?.visible ?? false);
}
diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts
index 6c2bc77798..aee8667ef5 100644
--- a/test/mocks/testHelpers.ts
+++ b/test/mocks/testHelpers.ts
@@ -1605,6 +1605,12 @@ export type MockMetadataStream = MockEventStream<{
*/
export class MockWorkspacesClient {
readonly metadataStreams = new Map();
+ readonly experimental = {
+ getChatsByWorkspace: vi.fn(
+ (_ids: readonly string[]): Promise> =>
+ Promise.resolve({}),
+ ),
+ };
readonly getWorkspaces = vi.fn(
(_req: { q: string }): Promise =>
diff --git a/test/unit/webviews/workspaces/workspaceStore.test.ts b/test/unit/webviews/workspaces/workspaceStore.test.ts
index db2662adc6..2a9345e61d 100644
--- a/test/unit/webviews/workspaces/workspaceStore.test.ts
+++ b/test/unit/webviews/workspaces/workspaceStore.test.ts
@@ -82,24 +82,28 @@ describe("WorkspaceStore", () => {
workspaces: [expect.objectContaining({ id: "workspace-1" })],
status: { kind: "ready" },
metadata: {},
+ listedAt: expect.any(Number),
},
]);
expect(h.store.findWorkspace("workspace-1")).toBeDefined();
expect(h.store.findWorkspace("gone")).toBeUndefined();
});
- it("polls the filters that poll, saying nothing when nothing changed", async () => {
- vi.useFakeTimers();
+ it("polls the filters that poll, saying nothing until the list or the minute changes", async () => {
+ vi.useFakeTimers({ now: 0 });
onTestFinished(() => {
vi.useRealTimers();
});
- const second = () => [workspace({ id: "second" })];
- const h = await shown([workspace({ id: "first" })], second(), second());
+ const h = await shown([workspace({ id: "first" })]);
+ const second = [workspace({ id: "second" })];
+ h.client.getWorkspaces.mockResolvedValue({ workspaces: second, count: 1 });
await vi.advanceTimersByTimeAsync(5_000);
expect(h.states.map(ids)).toEqual([["first"], ["second"]]);
await vi.advanceTimersByTimeAsync(5_000);
expect(h.states).toHaveLength(2);
+ await vi.advanceTimersByTimeAsync(50_000);
+ expect(h.states.map((state) => state.listedAt)).toEqual([0, 0, 60_000]);
await h.store.setFilter("shared");
const fetches = h.client.getWorkspaces.mock.calls.length;
@@ -163,19 +167,37 @@ describe("WorkspaceStore", () => {
});
describe("failed fetches", () => {
- it("reports a failure, then clears it once a fetch succeeds", async () => {
+ it("keeps the last list through a failure, with when it was listed", async () => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ onTestFinished(() => {
+ vi.useRealTimers();
+ });
+ vi.setSystemTime(90_000);
const h = await shown([workspace({ id: "workspace-1" })]);
+ vi.setSystemTime(150_000);
h.client.getWorkspaces.mockRejectedValueOnce(new Error("network down"));
await h.store.refresh();
expect(h.last()).toMatchObject({
- workspaces: [],
+ workspaces: [expect.objectContaining({ id: "workspace-1" })],
status: { kind: "failed", error: "network down" },
+ listedAt: 60_000,
});
await h.store.refresh();
expect(h.last()?.status).toEqual({ kind: "ready" });
});
+ it("reports a failure with nothing listed when there is no list to keep", async () => {
+ const h = setup();
+ h.client.getWorkspaces.mockRejectedValueOnce(new Error("network down"));
+ await h.show();
+ expect(h.store.state).toMatchObject({
+ workspaces: [],
+ status: { kind: "failed", error: "network down" },
+ });
+ expect(h.store.state).not.toHaveProperty("listedAt");
+ });
+
it("stops offering a filter the deployment rejects, until a visible refresh", async () => {
const h = await shown();
h.client.getWorkspaces.mockRejectedValueOnce(queryRejected());
@@ -209,6 +231,78 @@ describe("WorkspaceStore", () => {
});
});
+ describe("Agent workspace associations", () => {
+ it("batches listed workspaces and does not refetch for metadata reports", async () => {
+ const h = setup();
+ h.client.experimental.getChatsByWorkspace.mockResolvedValueOnce({
+ "workspace-1": "chat-1",
+ unlisted: "chat-2",
+ });
+ await h.show([withAgent(), workspace({ id: "second" })]);
+ expect(
+ h.client.experimental.getChatsByWorkspace,
+ ).toHaveBeenCalledExactlyOnceWith(["workspace-1", "second"]);
+ expect(h.store.state.agentWorkspaceIds).toEqual(["workspace-1"]);
+ await h.store.watchAgents(["agent-1"]);
+ pushMetadata(h);
+ expect(h.client.experimental.getChatsByWorkspace).toHaveBeenCalledOnce();
+ });
+
+ it("shows the workspace list while associations are pending", async () => {
+ const h = setup();
+ const lookup = Promise.withResolvers>();
+ h.client.experimental.getChatsByWorkspace.mockReturnValueOnce(
+ lookup.promise,
+ );
+ const listing = h.show([withAgent()]);
+ await Promise.resolve();
+ expect(h.store.state.status).toEqual({ kind: "ready" });
+ expect(ids(h.store.state)).toEqual(["workspace-1"]);
+ lookup.resolve({ "workspace-1": "chat-1" });
+ await listing;
+ expect(h.store.state.agentWorkspaceIds).toEqual(["workspace-1"]);
+ });
+
+ it("stops unsupported lookups until a manual refresh", async () => {
+ vi.useFakeTimers();
+ onTestFinished(() => {
+ vi.useRealTimers();
+ });
+ const h = setup();
+ h.client.experimental.getChatsByWorkspace.mockRejectedValueOnce(
+ createAxiosError(404, "not found"),
+ );
+ await h.show([withAgent()], [withAgent()]);
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(h.client.getWorkspaces).toHaveBeenCalledTimes(2);
+ expect(h.client.experimental.getChatsByWorkspace).toHaveBeenCalledOnce();
+ h.client.respondOnce([withAgent()]);
+ h.client.experimental.getChatsByWorkspace.mockResolvedValueOnce({
+ "workspace-1": "chat-1",
+ });
+ await h.store.refresh();
+ expect(h.store.state.agentWorkspaceIds).toEqual(["workspace-1"]);
+ });
+
+ it("does not apply another session's association response", async () => {
+ const h = setup();
+ const lookup = Promise.withResolvers>();
+ h.client.experimental.getChatsByWorkspace.mockReturnValueOnce(
+ lookup.promise,
+ );
+ const listing = h.show([withAgent()]);
+ await Promise.resolve();
+ expect(
+ h.client.experimental.getChatsByWorkspace,
+ ).toHaveBeenCalledExactlyOnceWith(["workspace-1"]);
+ h.session.signOut();
+ lookup.resolve({ "workspace-1": "chat-1" });
+ await listing;
+ expect(h.store.state.agentWorkspaceIds).toBeUndefined();
+ expect(h.store.state.capabilities.authenticated).toBe(false);
+ });
+ });
+
describe("agent metadata", () => {
it("watches the listed agents the webview asks for, and reports what arrives", async () => {
const h = await shown([withAgent()]);
@@ -249,12 +343,12 @@ describe("WorkspaceStore", () => {
});
});
- it("releases agents a failed list drops, and closes them once the session ends", async () => {
+ it("keeps watching agents through a failed list, and closes them once the session ends", async () => {
const h = await watchingAgent();
const socket = h.client.metadataStream("agent-1");
h.client.getWorkspaces.mockRejectedValueOnce(new Error("offline"));
await h.store.refresh();
- expect(h.store.state.metadata).toEqual({});
+ expect(h.store.state.metadata).toHaveProperty("agent-1");
expect(socket.close).not.toHaveBeenCalled();
h.session.signOut();
diff --git a/test/unit/webviews/workspaces/workspacesPanelProvider.test.ts b/test/unit/webviews/workspaces/workspacesPanelProvider.test.ts
index 7d920c91a4..408be11d7d 100644
--- a/test/unit/webviews/workspaces/workspacesPanelProvider.test.ts
+++ b/test/unit/webviews/workspaces/workspacesPanelProvider.test.ts
@@ -57,11 +57,12 @@ function setup() {
new TestSessionStore(),
);
const commands = {
+ login: vi.fn(() => Promise.resolve()),
openWorkspaceFromSidebar: vi.fn(() => Promise.resolve()),
openWorkspaceInDashboard: vi.fn(() => Promise.resolve()),
} satisfies Pick<
Commands,
- "openWorkspaceFromSidebar" | "openWorkspaceInDashboard"
+ "login" | "openWorkspaceFromSidebar" | "openWorkspaceInDashboard"
>;
const provider = new WorkspacesPanelProvider(
vscode.Uri.file("/test"),
@@ -224,7 +225,7 @@ describe("WorkspacesPanelProvider", () => {
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled();
});
- it("forwards commands to the store, and dashboard pages to Commands", async () => {
+ it("forwards commands to the store, and dashboard pages and login to Commands", async () => {
const h = await shown([alice()]);
await h.send(WorkspacesApi.watchAgents, { agentIds: ["agent-1"] });
expect([...h.client.metadataStreams.keys()]).toEqual(["agent-1"]);
@@ -238,6 +239,23 @@ describe("WorkspacesPanelProvider", () => {
await h.send(WorkspacesApi.setFilter, { filter: "shared" });
expect(h.store.state.filter).toBe("shared");
+
+ await h.send(WorkspacesApi.login);
+ expect(h.commands.login).toHaveBeenCalledOnce();
+ });
+
+ it("reports a failed refresh the user asked for, but not a failed poll", async () => {
+ const h = setup();
+ h.client.getWorkspaces.mockRejectedValueOnce(new Error("offline"));
+ h.hooks.setVisible(true);
+ await h.store.settled;
+ expect(vscode.window.showErrorMessage).not.toHaveBeenCalled();
+
+ h.client.getWorkspaces.mockRejectedValueOnce(new Error("still offline"));
+ await h.send(WorkspacesApi.refresh);
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledExactlyOnceWith(
+ "Failed to refresh workspaces: still offline",
+ );
});
interface OpenCase {
diff --git a/test/webview/ui/components/Tree/Tree.sticky.test.tsx b/test/webview/ui/components/Tree/Tree.sticky.test.tsx
index 49aab8e0cc..629fccbe4c 100644
--- a/test/webview/ui/components/Tree/Tree.sticky.test.tsx
+++ b/test/webview/ui/components/Tree/Tree.sticky.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen } from "@testing-library/react";
+import { act, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Tree } from "@repo/ui";
@@ -71,6 +71,19 @@ describe("Tree sticky scroll", () => {
expect(pinned[0]).toHaveAttribute("aria-setsize", "1");
expect(pinned[0]).toHaveAttribute("aria-selected", "false");
expect(pinned[1]).toHaveAttribute("aria-expanded", "true");
+ const focusMarks = (): boolean[] =>
+ pinned.map((row) => row.classList.contains("ui-tree-item--focused"));
+ const tree = screen.getByRole("tree");
+ expect(focusMarks()).toEqual([false, false]);
+ act(() => tree.focus());
+ expect(tree).toHaveClass("ui-tree--focused");
+ act(() => widget.focus());
+ expect(focusMarks()).toEqual([true, false]);
+ expect(tree).not.toHaveClass("ui-tree--focused");
+ act(() => tree.focus());
+ expect(focusMarks()).toEqual([false, false]);
+ expect(tree).toHaveClass("ui-tree--focused");
+ expect(fireEvent.mouseDown(widget)).toBe(false);
const twistie = pinned[1]?.querySelector(".ui-tree-item__chevron");
expect(twistie).not.toBeNull();
fireEvent.click(twistie!);
diff --git a/test/webview/ui/components/components.test.tsx b/test/webview/ui/components/components.test.tsx
index a660d87b4b..1a0211e059 100644
--- a/test/webview/ui/components/components.test.tsx
+++ b/test/webview/ui/components/components.test.tsx
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
+ Banner,
EmptyState,
ErrorState,
Icon,
@@ -49,6 +50,22 @@ describe("IconButton", () => {
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(onClick).toHaveBeenCalledOnce();
});
+
+ it("leaves focus where it was when clicked", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+ <>
+
+
+ >,
+ );
+ await user.click(screen.getByRole("textbox", { name: "Search" }));
+ await user.click(screen.getByRole("button", { name: "Refresh" }));
+ expect(onClick).toHaveBeenCalledOnce();
+ expect(screen.getByRole("textbox", { name: "Search" })).toHaveFocus();
+ });
+
it("hovers with the label unless opted out", async () => {
const hoverText = async (
tooltip?: string | null,
@@ -135,3 +152,23 @@ describe("state panels", () => {
expect(onRetry).toHaveBeenCalledOnce();
});
});
+
+describe("Banner", () => {
+ it("announces warnings politely and runs its action", async () => {
+ const user = userEvent.setup();
+ const onAction = vi.fn();
+ render(
+
+ Out of date
+ ,
+ );
+ expect(screen.getByRole("status")).toHaveTextContent("Out of date");
+ await user.click(screen.getByRole("button", { name: "Retry" }));
+ expect(onAction).toHaveBeenCalledOnce();
+ });
+
+ it("announces errors as alerts", () => {
+ render(Failed );
+ expect(screen.getByRole("alert")).toHaveTextContent("Failed");
+ });
+});
diff --git a/test/webview/workspaces/components/WorkspacesPanel.test.tsx b/test/webview/workspaces/components/WorkspacesPanel.test.tsx
new file mode 100644
index 0000000000..3cd42ee51a
--- /dev/null
+++ b/test/webview/workspaces/components/WorkspacesPanel.test.tsx
@@ -0,0 +1,484 @@
+import { act, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { format, formatISO } from "date-fns";
+import { describe, expect, it, onTestFinished, vi } from "vitest";
+
+import { agent, agentMetadata, app, appStatus, workspace } from "@repo/mocks";
+import { WorkspacesApi, type WorkspacesState } from "@repo/shared";
+import App from "@repo/workspaces/App";
+
+import {
+ activeRow,
+ row,
+ stubElementBoxes,
+} from "../../ui/components/Tree/treeTestHelpers";
+import { captureSent, pushState } from "../ipc";
+
+const ALPHA = workspace({
+ id: "ws-alpha",
+ name: "alpha",
+ owner_name: "alice",
+ agents: [
+ agent({ id: "agent-main", name: "main" }),
+ agent({ id: "agent-dev", name: "dev", parent_id: "agent-main" }),
+ ],
+});
+const BETA = workspace({
+ id: "ws-beta",
+ name: "beta",
+ owner_name: "bob",
+ latest_build: { status: "stopped" },
+ agents: [
+ agent({
+ id: "agent-beta",
+ name: "beta-main",
+ apps: [
+ app({
+ statuses: [
+ appStatus({ id: "mid", created_at: "2024-01-01T00:01:00Z" }),
+ appStatus({
+ id: "newest",
+ created_at: "2024-01-01T00:02:00Z",
+ state: "complete",
+ message: "Opened a pull request",
+ }),
+ appStatus({ id: "oldest" }),
+ ],
+ }),
+ ],
+ }),
+ ],
+});
+
+const STATE: WorkspacesState = {
+ capabilities: { authenticated: true, filters: ["mine", "shared"] },
+ filter: "mine",
+ workspaces: [ALPHA, BETA],
+ status: { kind: "ready" },
+ metadata: {},
+};
+
+const SIGNED_OUT: WorkspacesState = {
+ ...STATE,
+ workspaces: [],
+ capabilities: { authenticated: false, filters: [] },
+};
+
+async function setup(state: WorkspacesState | null = STATE) {
+ const sent = captureSent();
+ const user = userEvent.setup();
+ const view = render( );
+ if (state) {
+ await pushState(state);
+ }
+ sent.length = 0;
+ /** Params of the `method` commands sent since the last call for it. */
+ const drain = (method: string) => {
+ const params = sent.filter((m) => m.method === method).map((m) => m.params);
+ sent.splice(0, sent.length, ...sent.filter((m) => m.method !== method));
+ return params;
+ };
+ return { user, drain, ...view };
+}
+
+const hoverDetails = (tooltip: HTMLElement) =>
+ within(tooltip)
+ .getAllByRole("term")
+ .map((term) => [term.textContent, term.nextElementSibling?.textContent]);
+
+/** Show the focused row's hover, as Ctrl+K Ctrl+I does in VS Code. */
+const showHover = async (user: ReturnType) => {
+ await user.keyboard("{Control>}k{/Control}{Control>}i{/Control}");
+ return screen.findByRole("tooltip");
+};
+
+describe("WorkspacesPanel", () => {
+ it("offers only the filters the session allows", async () => {
+ const { user, drain } = await setup();
+ await user.click(
+ screen.getByRole("button", { name: "Workspace filter: Mine" }),
+ );
+
+ expect(
+ await screen.findByRole("menuitemradio", { name: "All" }),
+ ).toHaveAttribute("aria-disabled", "true");
+ await user.click(screen.getByRole("menuitemradio", { name: "All" }));
+ expect(drain(WorkspacesApi.setFilter.method)).toEqual([]);
+
+ expect(screen.getByRole("menuitemradio", { name: "Mine" })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ await user.click(screen.getByRole("menuitemradio", { name: "Shared" }));
+ expect(drain(WorkspacesApi.setFilter.method)).toEqual([
+ { filter: "shared" },
+ ]);
+ await waitFor(() => {
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ });
+ await pushState({ ...STATE, filter: "shared" });
+ expect(
+ screen.getByRole("button", { name: "Workspace filter: Shared" }),
+ ).toBeVisible();
+ });
+
+ it("shows owners outside of Mine", async () => {
+ await setup();
+ expect(within(row("alpha")).queryByText("alice")).not.toBeInTheDocument();
+ await pushState({ ...STATE, filter: "all" });
+ expect(within(row("alpha alice")).getByText("alice")).toBeVisible();
+ });
+
+ it.each([
+ ["starting", "Starting"],
+ ["stopped", "Stopped"],
+ ] as const)(
+ "shows %s despite health from a previous build",
+ async (status, label) => {
+ await setup({
+ ...STATE,
+ workspaces: [
+ workspace({
+ latest_build: { status },
+ health: { healthy: false, failing_agents: [] },
+ }),
+ ],
+ });
+ expect(screen.getByRole("img", { name: label })).toBeInTheDocument();
+ },
+ );
+
+ it("marks workspaces associated with Agent chats", async () => {
+ await setup({ ...STATE, agentWorkspaceIds: [ALPHA.id] });
+ expect(
+ within(row("alpha Agent")).getByText("Agent", { exact: true }),
+ ).toBeVisible();
+ expect(
+ within(row("beta")).queryByText("Agent", { exact: true }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("sends row actions for their own row without toggling it", async () => {
+ const { user, drain } = await setup();
+ await user.click(
+ screen.getByRole("button", { name: "Open alpha in VS Code" }),
+ );
+ await user.click(
+ screen.getByRole("button", { name: "View alpha in dashboard" }),
+ );
+ expect(row("alpha")).toHaveAttribute("aria-expanded", "false");
+
+ await user.click(row("alpha"));
+ await user.click(
+ screen.getByRole("button", { name: "Open dev in VS Code" }),
+ );
+ expect(
+ screen.queryByRole("button", { name: "More actions for dev" }),
+ ).not.toBeInTheDocument();
+
+ expect(drain(WorkspacesApi.openWorkspace.method)).toEqual([
+ { workspaceId: "ws-alpha" },
+ { workspaceId: "ws-alpha", agentId: "agent-dev" },
+ ]);
+ expect(drain(WorkspacesApi.viewInDashboard.method)).toEqual([
+ { workspaceId: "ws-alpha", page: "workspace" },
+ ]);
+ });
+
+ it("opens workspace settings from the menu", async () => {
+ const { user, drain } = await setup();
+ await user.click(
+ screen.getByRole("button", { name: "More actions for beta" }),
+ );
+ const menu = await screen.findByRole("menu");
+ await user.click(within(menu).getByRole("menuitem", { name: "Settings" }));
+ expect(drain(WorkspacesApi.viewInDashboard.method)).toEqual([
+ { workspaceId: "ws-beta", page: "settings" },
+ ]);
+ });
+
+ it.each([
+ ["running", ["Stop", "Restart"]],
+ ["stopped", ["Start"]],
+ ["canceled", ["Retry"]],
+ ["starting", []],
+ ] as const)(
+ "offers lifecycle actions appropriate to %s",
+ async (status, actions) => {
+ const { user } = await setup({
+ ...STATE,
+ workspaces: [workspace({ latest_build: { status } })],
+ });
+ await user.click(
+ screen.getByRole("button", { name: "More actions for test-workspace" }),
+ );
+ const menu = await screen.findByRole("menu");
+ for (const name of ["Start", "Stop", "Restart", "Retry"]) {
+ const item = within(menu).queryByRole("menuitem", { name });
+ if (actions.some((action) => action === name)) {
+ expect(item).toHaveAttribute("aria-disabled", "true");
+ } else {
+ expect(item).not.toBeInTheDocument();
+ }
+ }
+ },
+ );
+
+ it("describes workspaces, workspace agents and metadata on hover", async () => {
+ stubElementBoxes();
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime("2024-01-01T00:00:05Z");
+ onTestFinished(() => {
+ vi.useRealTimers();
+ });
+ const { user } = await setup({
+ ...STATE,
+ filter: "all",
+ agentWorkspaceIds: ["workspace-1"],
+ workspaces: [
+ workspace({
+ owner_name: "alice",
+ template_display_name: "Go",
+ template_name: "go-template",
+ health: { healthy: false, failing_agents: ["agent-1"] },
+ agents: [agent({ name: "primary", status: "connecting" })],
+ }),
+ ],
+ metadata: {
+ "agent-1": { kind: "reported", metadata: [agentMetadata()] },
+ },
+ });
+ await user.click(row("test-workspace Agent alice"));
+ let hover = await showHover(user);
+ expect(within(hover).getByText("test-workspace")).toBeVisible();
+ expect(hoverDetails(hover)).toEqual([
+ ["Status", "Unhealthy"],
+ ["Owner", "alice"],
+ ["Template", "Go"],
+ ]);
+
+ await user.keyboard("{Escape}{ArrowDown}");
+ hover = await showHover(user);
+ expect(within(hover).getByText("primary")).toBeVisible();
+ expect(hoverDetails(hover)).toEqual([
+ ["Status", "Connecting"],
+ ["Type", "Workspace agent"],
+ ]);
+ await user.keyboard("{Escape}");
+
+ await user.click(row("Metadata"));
+ await user.click(row("CPU: 42"));
+ expect(await showHover(user)).toHaveTextContent(
+ "CPU: 42Collected 5 seconds ago",
+ );
+ });
+
+ it("watches metadata only for the workspace agents whose metadata is visible", async () => {
+ const { user, drain } = await setup();
+ const watches = () => drain(WorkspacesApi.watchAgents.method);
+ act(() => screen.getByRole("tree").focus());
+
+ await user.keyboard("{ArrowRight}{ArrowRight}");
+ expect(activeRow()).toBe("main");
+ expect(row("dev")).toHaveAttribute("aria-level", "3");
+ expect(watches()).toEqual([]);
+
+ // Below main: dev, dev's metadata, then main's metadata.
+ await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}");
+ expect(watches()).toEqual([{ agentIds: ["agent-main"] }]);
+ expect(
+ screen.getByRole("status", { name: "Loading metadata" }),
+ ).toBeInTheDocument();
+
+ await pushState({
+ ...STATE,
+ metadata: {
+ "agent-main": { kind: "reported", metadata: [agentMetadata()] },
+ },
+ });
+ expect(row("CPU: 42")).toBeInTheDocument();
+ expect(watches()).toEqual([]);
+
+ await user.keyboard("{ArrowUp}{ArrowRight}");
+ expect(watches()).toEqual([{ agentIds: ["agent-dev", "agent-main"] }]);
+
+ await user.click(row("main"));
+ expect(watches()).toEqual([{ agentIds: [] }]);
+ await user.click(row("main"));
+ expect(watches()).toEqual([{ agentIds: ["agent-dev", "agent-main"] }]);
+
+ await user.click(row("alpha"));
+ expect(watches()).toEqual([{ agentIds: [] }]);
+ });
+
+ it("shows only each app's latest status under the agent", async () => {
+ const { user } = await setup();
+ await user.click(row("beta"));
+ expect(row("Claude Code: Opened a pull request")).toHaveAttribute(
+ "aria-level",
+ "3",
+ );
+ expect(
+ screen.queryByRole("treeitem", { name: /Running tests/ }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("explains when an expanded workspace has no workspace agents", async () => {
+ const { user } = await setup({
+ ...STATE,
+ workspaces: [workspace({ name: "empty", owner_name: "alice" })],
+ });
+ await user.click(row("empty"));
+ expect(row("No workspace agents")).toBeInTheDocument();
+ });
+
+ it.each<{
+ name: string;
+ state: WorkspacesState | null;
+ visible: { role: string; name: string };
+ }>([
+ {
+ name: "loading before the first state",
+ state: null,
+ visible: { role: "status", name: "Loading workspaces" },
+ },
+ {
+ name: "loading the first list",
+ state: { ...STATE, workspaces: [], status: { kind: "loading" } },
+ visible: { role: "status", name: "Loading workspaces" },
+ },
+ {
+ name: "an empty list",
+ state: { ...STATE, workspaces: [] },
+ visible: { role: "heading", name: "No workspaces" },
+ },
+ ])("shows $name", async ({ state, visible }) => {
+ await setup(state);
+ expect(
+ screen.getByRole(visible.role, { name: visible.name }),
+ ).toBeVisible();
+ expect(screen.queryByRole("tree")).not.toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("offers to log in when signed out", async () => {
+ const { user, drain } = await setup(SIGNED_OUT);
+ expect(
+ screen.getByRole("heading", { name: "Welcome to Coder" }),
+ ).toBeVisible();
+ expect(
+ screen.queryByRole("button", { name: /^Workspace filter:/ }),
+ ).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Login" }));
+ expect(drain(WorkspacesApi.login.method)).toEqual([undefined]);
+ });
+
+ it("retries a failed first load", async () => {
+ const { user, drain } = await setup({
+ ...STATE,
+ workspaces: [],
+ status: { kind: "failed", error: "Deployment unreachable" },
+ });
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent("Deployment unreachable");
+ expect(
+ screen.queryByRole("heading", { name: "No workspaces" }),
+ ).not.toBeInTheDocument();
+
+ await user.click(within(alert).getByRole("button", { name: "Try again" }));
+ expect(drain(WorkspacesApi.refresh.method)).toEqual([undefined]);
+ });
+
+ it("keeps each remaining row's expansion when a refresh adds and removes rows", async () => {
+ const { user, drain } = await setup();
+ await user.click(row("alpha"));
+ await user.click(screen.getAllByRole("treeitem", { name: "Metadata" })[1]);
+ expect(drain(WorkspacesApi.watchAgents.method)).toEqual([
+ { agentIds: ["agent-main"] },
+ ]);
+
+ const gamma = workspace({
+ id: "ws-gamma",
+ name: "gamma",
+ agents: [agent({ id: "agent-gamma", name: "gamma-main" })],
+ });
+ const alpha = workspace({
+ id: ALPHA.id,
+ name: "alpha",
+ agents: [
+ agent({ id: "agent-main", name: "main" }),
+ agent({ id: "agent-dev", name: "dev", parent_id: "agent-main" }),
+ agent({ id: "agent-new", name: "new" }),
+ ],
+ });
+ await pushState({ ...STATE, workspaces: [gamma, alpha] });
+
+ expect(
+ screen.queryByRole("treeitem", { name: "beta" }),
+ ).not.toBeInTheDocument();
+ expect(row("gamma")).toHaveAttribute("aria-expanded", "false");
+ expect(row("alpha")).toHaveAttribute("aria-expanded", "true");
+ expect(row("main")).toHaveAttribute("aria-expanded", "true");
+ expect(row("dev")).toHaveAttribute("aria-expanded", "true");
+ expect(row("new")).toHaveAttribute("aria-expanded", "false");
+ expect(
+ screen
+ .getAllByRole("treeitem", { name: "Metadata" })
+ .map((item) => item.getAttribute("aria-expanded")),
+ ).toEqual(["false", "true"]);
+ expect(drain(WorkspacesApi.watchAgents.method)).toEqual([]);
+ });
+
+ it("keeps the tree and its expansion while refreshing and after a failure", async () => {
+ const { user, drain } = await setup();
+ await user.click(row("alpha"));
+
+ const listedAt = Date.parse("2024-01-01T14:05:00Z");
+ const updated = `Updated ${format(listedAt, "p")}`;
+ await pushState({ ...STATE, listedAt, status: { kind: "loading" } });
+ expect(
+ screen.getByRole("progressbar", { name: "Refreshing workspaces" }),
+ ).toBeInTheDocument();
+ expect(row("main")).toBeInTheDocument();
+ expect(screen.getByText(updated)).toBeInTheDocument();
+
+ await pushState({
+ ...STATE,
+ listedAt,
+ status: { kind: "failed", error: "Timed out" },
+ });
+ expect(row("main")).toBeInTheDocument();
+ expect(screen.queryByText(updated)).not.toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ const banner = screen.getByRole("status");
+ expect(banner).toHaveTextContent(
+ `Couldn't refresh. Showing results from ${format(listedAt, "p")}.Timed out`,
+ );
+ await user.click(within(banner).getByRole("button", { name: "Retry" }));
+ expect(drain(WorkspacesApi.refresh.method)).toEqual([undefined]);
+ });
+
+ it.each(["hover", "keyboard"] as const)(
+ "shows the full list time using $0",
+ async (interaction) => {
+ const { user } = await setup();
+ const listedAt = Date.parse("2024-01-01T14:05:00Z");
+ await pushState({ ...STATE, listedAt });
+
+ const updated = screen.getByText(`Updated ${format(listedAt, "p")}`);
+ if (interaction === "hover") {
+ await user.hover(updated);
+ } else {
+ await user.tab();
+ expect(
+ screen.getByRole("button", { name: "Workspace filter: Mine" }),
+ ).toHaveFocus();
+ await user.tab();
+ expect(updated).toHaveFocus();
+ }
+ expect(await screen.findByRole("tooltip")).toHaveTextContent(
+ formatISO(listedAt),
+ );
+ },
+ );
+});
diff --git a/test/webview/workspaces/hooks/useWorkspaces.test.tsx b/test/webview/workspaces/hooks/useWorkspaces.test.tsx
index cb783d70e5..a8cc427eda 100644
--- a/test/webview/workspaces/hooks/useWorkspaces.test.tsx
+++ b/test/webview/workspaces/hooks/useWorkspaces.test.tsx
@@ -1,17 +1,10 @@
-import { act, renderHook } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
import { WorkspacesApi, type WorkspacesState } from "@repo/shared";
import { useWorkspaces } from "@repo/workspaces/hooks/useWorkspaces";
-// The webview may acquire the VS Code API once per page, so one stub serves
-// every test and forwards to the sink the current test installed.
-let sink: unknown[] = [];
-vi.stubGlobal("acquireVsCodeApi", () => ({
- postMessage: (message: unknown) => sink.push(message),
- getState: () => undefined,
- setState: () => {},
-}));
+import { captureSent, pushState } from "../ipc";
const STATE: WorkspacesState = {
capabilities: { authenticated: true, filters: ["mine"] },
@@ -22,46 +15,40 @@ const STATE: WorkspacesState = {
};
function setup() {
- const sent: unknown[] = [];
- sink = sent;
+ const sent = captureSent();
return { sent, ...renderHook(() => useWorkspaces()) };
}
-/** Push the state the way the extension does. */
-const push = (state: WorkspacesState) =>
- act(async () => {
- window.dispatchEvent(
- new MessageEvent("message", {
- data: { type: WorkspacesApi.stateChanged.method, data: state },
- }),
- );
- await Promise.resolve();
- });
-
describe("useWorkspaces", () => {
- it("asks for the state, then replaces its own with each push", async () => {
- const { sent, result } = setup();
+ it("asks for state once and keeps its commands stable across updates", async () => {
+ const { sent, result, rerender } = setup();
+ const api = result.current.api;
expect(sent).toEqual([
{ method: WorkspacesApi.ready.method, params: undefined },
]);
expect(result.current.state).toBeUndefined();
- await push(STATE);
+ await pushState(STATE);
expect(result.current.state).toEqual(STATE);
const failed: WorkspacesState = {
...STATE,
status: { kind: "failed", error: "down" },
};
- await push(failed);
+ await pushState(failed);
expect(result.current.state).toEqual(failed);
+ rerender();
+ expect(result.current.api).toBe(api);
+ expect(sent).toEqual([
+ { method: WorkspacesApi.ready.method, params: undefined },
+ ]);
});
it("stops applying pushes once unmounted", async () => {
const { result, unmount } = setup();
- await push(STATE);
+ await pushState(STATE);
unmount();
- await push({ ...STATE, filter: "shared" });
+ await pushState({ ...STATE, filter: "shared" });
expect(result.current.state).toEqual(STATE);
});
});
diff --git a/test/webview/workspaces/ipc.ts b/test/webview/workspaces/ipc.ts
new file mode 100644
index 0000000000..f381bcc622
--- /dev/null
+++ b/test/webview/workspaces/ipc.ts
@@ -0,0 +1,35 @@
+import { act } from "@testing-library/react";
+import { vi } from "vitest";
+
+import { WorkspacesApi, type WorkspacesState } from "@repo/shared";
+
+export interface SentMessage {
+ method: string;
+ params: unknown;
+}
+
+// The webview may acquire the VS Code API once per page, so one stub serves
+// every test and forwards to the sink the current test installed.
+let sink: SentMessage[] = [];
+vi.stubGlobal("acquireVsCodeApi", () => ({
+ postMessage: (message: SentMessage) => sink.push(message),
+ getState: () => undefined,
+ setState: () => {},
+}));
+
+/** Collects the messages the webview sends from now on. */
+export function captureSent(): SentMessage[] {
+ const sent: SentMessage[] = [];
+ sink = sent;
+ return sent;
+}
+
+export const pushState = (state: WorkspacesState) =>
+ act(async () => {
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { type: WorkspacesApi.stateChanged.method, data: state },
+ }),
+ );
+ await Promise.resolve();
+ });