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 ? ( + + ) : 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]) => ( +
+

{label}

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