diff --git a/package.json b/package.json index 5361df4d36..2a8932b244 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": [ @@ -856,7 +871,7 @@ "coder": "link:vendor/coder", "concurrently": "^10.0.6", "cross-env": "^10.1.0", - "dayjs": "^1.11.23", + "dayjs": "catalog:", "electron": "44.4.1", "esbuild": "^0.28.2", "eslint": "^10.12.0", diff --git a/packages/mocks/src/workspaces.ts b/packages/mocks/src/workspaces.ts index 77b3458f1b..424a635760 100644 --- a/packages/mocks/src/workspaces.ts +++ b/packages/mocks/src/workspaces.ts @@ -2,11 +2,13 @@ * Test factories for Coder SDK workspace types. */ -import type { AgentMetadataState } from "@repo/shared"; +import type { AgentMetadataState, WorkspacesState } from "@repo/shared"; import type { Workspace, WorkspaceAgent, WorkspaceAgentMetadata, + WorkspaceApp, + WorkspaceAppStatus, WorkspaceBuild, WorkspaceResource, } from "coder/site/src/api/typesGenerated"; @@ -136,6 +138,42 @@ export function agent(overrides: Partial = {}): WorkspaceAgent { }; } +/** Create a workspace app with no reported statuses. */ +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, + }; +} + +/** Create a workspace app status with a running task message. */ +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: { @@ -171,6 +209,22 @@ export const REPORTED_METADATA: AgentMetadataState = { metadata: [agentMetadata()], }; +/** A signed-in Workspaces panel state with an empty, ready Mine list. */ +export function workspacesState( + overrides: Partial = {}, +): WorkspacesState { + return { + capabilities: { authenticated: true, filters: ["mine", "shared"] }, + filter: "mine", + workspaces: [], + status: { kind: "ready" }, + metadata: {}, + listedAtMs: null, + chatWorkspaceIds: [], + ...overrides, + }; +} + /** Create a WorkspaceResource with sensible defaults. */ export function resource( overrides: Partial = {}, 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..9e3e35b2e7 100644 --- a/packages/shared/src/workspaces/types.ts +++ b/packages/shared/src/workspaces/types.ts @@ -1,11 +1,25 @@ import type { Workspace, WorkspaceAgent, + WorkspaceAgentLifecycle, WorkspaceAgentMetadata, + WorkspaceAgentStatus, + WorkspaceAppStatus, + WorkspaceAppStatusState, + WorkspaceStatus, } from "coder/site/src/api/typesGenerated"; // Re-export SDK types for convenience -export type { Workspace, WorkspaceAgent, WorkspaceAgentMetadata }; +export type { + Workspace, + WorkspaceAgent, + WorkspaceAgentLifecycle, + WorkspaceAgentMetadata, + WorkspaceAgentStatus, + WorkspaceAppStatus, + WorkspaceAppStatusState, + WorkspaceStatus, +}; export type WorkspaceFilter = "mine" | "shared" | "all"; @@ -47,6 +61,10 @@ export interface WorkspacesState { readonly workspaces: readonly Workspace[]; readonly status: WorkspaceListStatus; readonly metadata: AgentMetadataMap; + /** Epoch milliseconds of the last successful list; null before the first. */ + readonly listedAtMs: number | null; + /** Listed workspaces that have Agent chats. */ + readonly chatWorkspaceIds: 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..e119ea6e15 --- /dev/null +++ b/packages/ui/src/components/Banner/Banner.css @@ -0,0 +1,68 @@ +.ui-banner { + --ui-banner-accent: var(--ui-status-info-accent); + display: flex; + align-items: flex-start; + gap: var(--ui-spacing-60); + 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 { + margin-inline-start: 12px; + padding: 0 3px; + font: inherit; + color: inherit; + text-decoration: underline; + background: none; + border: none; + cursor: pointer; +} + +.ui-banner__action:focus-visible, +:where(:root[data-ui-style="stable"]) .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..6050aa9b7d --- /dev/null +++ b/packages/ui/src/components/Banner/Banner.stories.tsx @@ -0,0 +1,43 @@ +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 }, + { label: "Details", onClick: () => undefined }, + ]} + > + Couldn't refresh. Showing results from 5 minutes ago. + + + 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 = {}; + +export const MouseFocus: Story = { + parameters: { pseudo: { focus: [".ui-banner__action"] } }, +}; + +export const MouseFocusStable: Story = { + ...MouseFocus, + globals: { uiStyle: "stable" }, +}; diff --git a/packages/ui/src/components/Banner/Banner.tsx b/packages/ui/src/components/Banner/Banner.tsx new file mode 100644 index 0000000000..6101a9a6ec --- /dev/null +++ b/packages/ui/src/components/Banner/Banner.tsx @@ -0,0 +1,61 @@ +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 BannerAction { + readonly label: string; + readonly onClick: () => void; +} + +export interface BannerProps extends Omit, "role"> { + severity?: BannerSeverity; + /** Underlined links that follow the message, like VS Code's banner. */ + actions?: readonly BannerAction[]; + children: ReactNode; +} + +/** Errors are announced assertively; info and warnings politely. */ +export function Banner({ + severity = "info", + actions = [], + children, + className, + ...props +}: BannerProps): React.JSX.Element { + return ( +
+ +
+ {children} + {actions.map(({ label, onClick }) => ( + + ))} +
+
+ ); +} diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx index 95ae4539bb..b70329492b 100644 --- a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx +++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx @@ -2,7 +2,18 @@ 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 by default: a modal menu locks page scroll and covers the window + * with a backdrop, and undoing both on close makes the webview flash. + */ +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..f79285159a 100644 --- a/packages/ui/src/components/IconButton/IconButton.css +++ b/packages/ui/src/components/IconButton/IconButton.css @@ -31,8 +31,9 @@ outline-style: solid; } -/* Last: native's ring is an outline, the others border colors, so it wins */ -.ui-icon-button:focus { +/* Last so focus wins over hover and toggled outlines. */ +.ui-icon-button:focus-visible, +:where(:root[data-ui-style="stable"]) .ui-icon-button:focus { outline-color: var(--ui-focus-border); outline-style: solid; } diff --git a/packages/ui/src/components/IconButton/IconButton.stories.tsx b/packages/ui/src/components/IconButton/IconButton.stories.tsx index 710251293b..ad5c9e27bc 100644 --- a/packages/ui/src/components/IconButton/IconButton.stories.tsx +++ b/packages/ui/src/components/IconButton/IconButton.stories.tsx @@ -36,3 +36,12 @@ export const States: Story = { await userEvent.unhover(refreshButton); }, }; + +export const MouseFocus: Story = { + parameters: { pseudo: { focus: [".ui-icon-button:not(:disabled)"] } }, +}; + +export const MouseFocusStable: Story = { + ...MouseFocus, + globals: { uiStyle: "stable" }, +}; 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..0e62d4777c 100644 --- a/packages/ui/src/components/Tree/Tree.css +++ b/packages/ui/src/components/Tree/Tree.css @@ -185,10 +185,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/control.css b/packages/ui/src/components/control.css index b7d01eeaf5..5bd1f271be 100644 --- a/packages/ui/src/components/control.css +++ b/packages/ui/src/components/control.css @@ -1,6 +1,5 @@ /* Shared control geometry: fixed heights and flex centering, never - line-height or vertical padding math, which drift with font metrics. - Focus rings use :focus, not :focus-visible, like VS Code's button:focus. */ + line-height or vertical padding math, which drift with font metrics. */ .ui-control { display: inline-flex; align-items: center; diff --git a/packages/ui/src/components/overlay.css b/packages/ui/src/components/overlay.css index 0ad6c0a891..5df3163b90 100644 --- a/packages/ui/src/components/overlay.css +++ b/packages/ui/src/components/overlay.css @@ -23,23 +23,32 @@ /* An open submenu keeps its parent row highlighted, like native; the selection border only resolves in high contrast. */ -.ui-overlay__item:is([data-highlighted], [data-popup-open]) { +.ui-overlay__item:is([data-highlighted], [data-popup-open]):not( + [data-disabled] + ) { forced-color-adjust: none; color: var(--ui-overlay-highlight-foreground); background: var(--ui-overlay-highlight-background); outline-color: var(--ui-overlay-highlight-outline, transparent); } +/* Native menus never highlight a disabled item; keyboard focus keeps a ring. */ .ui-overlay__item[data-disabled] { color: var(--ui-disabled-foreground); } +.ui-overlay__item[data-disabled]:focus-visible { + outline-color: var(--ui-focus-border); +} + @media (forced-colors: active) { .ui-overlay { border-color: CanvasText; } - .ui-overlay__item:is([data-highlighted], [data-popup-open]) { + .ui-overlay__item:is([data-highlighted], [data-popup-open]):not( + [data-disabled] + ) { color: HighlightText; background: Highlight; } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 507bc8a49d..41e0b6b1d6 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,3 +1,8 @@ +export { + Banner, + type BannerAction, + type BannerProps, +} from "./components/Banner/Banner"; export { Button, type ButtonProps } from "./components/Button/Button"; export { Checkbox, type CheckboxProps } from "./components/Checkbox/Checkbox"; export { diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 7a15db40ca..03e5895ba6 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -205,6 +205,10 @@ --vscode-list-activeSelectionForeground, var(--ui-foreground) ); + --ui-list-active-selection-icon-foreground: var( + --vscode-list-activeSelectionIconForeground, + var(--ui-foreground) + ); --ui-list-inactive-selection-background: var( --vscode-list-inactiveSelectionBackground, var(--ui-list-active-selection-background) @@ -276,6 +280,44 @@ /* Only defined by high contrast themes; transparent elsewhere */ --ui-contrast-border: var(--vscode-contrastBorder, transparent); + /* Banner: VS Code's input validation colors */ + --ui-message-info-background: var( + --vscode-inputValidation-infoBackground, + transparent + ); + --ui-message-info-border: var( + --vscode-inputValidation-infoBorder, + var(--ui-contrast-border) + ); + --ui-message-info-foreground: var( + --vscode-inputValidation-infoForeground, + var(--ui-foreground) + ); + --ui-message-warning-background: var( + --vscode-inputValidation-warningBackground, + transparent + ); + --ui-message-warning-border: var( + --vscode-inputValidation-warningBorder, + var(--ui-contrast-border) + ); + --ui-message-warning-foreground: var( + --vscode-inputValidation-warningForeground, + var(--ui-foreground) + ); + --ui-message-error-background: var( + --vscode-inputValidation-errorBackground, + transparent + ); + --ui-message-error-border: var( + --vscode-inputValidation-errorBorder, + var(--ui-contrast-border) + ); + --ui-message-error-foreground: var( + --vscode-inputValidation-errorForeground, + var(--ui-foreground) + ); + /* Portalled overlays; matches the native hover widget's stacking */ --ui-z-index-overlay: 40; } diff --git a/packages/workspaces/README.md b/packages/workspaces/README.md index f83b897a93..e45eadbfc8 100644 --- a/packages/workspaces/README.md +++ b/packages/workspaces/README.md @@ -19,4 +19,4 @@ To enable via settings.json: 3. Reload VS Code -A new activity bar icon labeled **"Coder Remote (New)"** will appear when the setting is enabled. +The **Coder Workspaces** activity bar icon opens the **Coder Workspaces** view when the setting is enabled. diff --git a/packages/workspaces/package.json b/packages/workspaces/package.json index d7a3b34591..8bbf6271d0 100644 --- a/packages/workspaces/package.json +++ b/packages/workspaces/package.json @@ -11,11 +11,12 @@ }, "dependencies": { "@repo/shared": "workspace:*", + "@repo/ui": "workspace:*", "@repo/webview-shared": "workspace:*", "@tanstack/react-query": "catalog:", - "@vscode-elements/react-elements": "catalog:", "@vscode/codicons": "catalog:", "date-fns": "catalog:", + "dayjs": "catalog:", "react": "catalog:", "react-dom": "catalog:" }, diff --git a/packages/workspaces/src/App.tsx b/packages/workspaces/src/App.tsx index fe16f87c2d..8703edb121 100644 --- a/packages/workspaces/src/App.tsx +++ b/packages/workspaces/src/App.tsx @@ -1,11 +1,16 @@ +import { WorkspacesPanel } from "./components/WorkspacesPanel"; import { useWorkspaces } from "./hooks/useWorkspaces"; -/** Placeholder: renders the pushed state until the panel UI lands. */ export default function App() { - const { state } = useWorkspaces(); - - if (!state) { - return

Loading workspaces...

; - } - return
{JSON.stringify(state, null, 2)}
; + const { state, api } = useWorkspaces(); + return ( +
+ +
+ ); } diff --git a/packages/workspaces/src/components/FilterBar.css b/packages/workspaces/src/components/FilterBar.css new file mode 100644 index 0000000000..b36f0f0403 --- /dev/null +++ b/packages/workspaces/src/components/FilterBar.css @@ -0,0 +1,58 @@ +/* The start padding lines the picker's text up with the tree's chevrons. */ +.workspaces-filter-bar { + display: flex; + align-items: center; + padding: 0 var(--ui-spacing-120) var(--ui-spacing-40) 14px; + font-size: var(--ui-font-size-small); + color: var(--ui-description-foreground); + white-space: nowrap; +} + +/* Sized and colored like a view toolbar action, not a button. */ +.workspaces-filter.ui-button { + height: 22px; + padding: 0 var(--ui-spacing-40); + gap: var(--ui-spacing-40); + font-size: inherit; + color: inherit; + background: transparent; + border-color: transparent; +} + +.workspaces-filter.ui-button:is(:hover, [data-popup-open]) { + background: var(--ui-toolbar-hover-background); +} + +:where(:root[data-ui-style="stable"]) .workspaces-filter-bar { + padding-inline-start: 10px; +} + +:where(:root[data-ui-style="stable"]) .workspaces-filter.ui-button { + border-radius: var(--ui-radius-medium); +} + +:where(:root:not([data-ui-style="stable"])) + .workspaces-filter.ui-button:focus:not(:focus-visible) { + outline: none; +} + +.workspaces-filter > .ui-icon { + width: auto; + font-size: inherit; +} + +/* The picker's end padding already spaces the dot from the chevron. */ +.workspaces-filter-bar__separator { + flex: none; + width: 4px; + height: 4px; + margin-inline-end: var(--ui-spacing-60); + border-radius: 50%; + background: currentColor; +} + +.workspaces-filter-bar__updated { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/packages/workspaces/src/components/FilterBar.tsx b/packages/workspaces/src/components/FilterBar.tsx new file mode 100644 index 0000000000..fbeb4b8531 --- /dev/null +++ b/packages/workspaces/src/components/FilterBar.tsx @@ -0,0 +1,82 @@ +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + Icon, + MenuRadioGroup, + MenuRadioItem, +} from "@repo/ui"; + +import "./FilterBar.css"; +import { RelativeTime } from "./RelativeTime"; + +import type { WorkspaceFilter } from "@repo/shared"; + +const FILTERS = [ + { value: "all", label: "All" }, + { value: "mine", label: "Mine" }, + { value: "shared", label: "Shared" }, +] as const satisfies ReadonlyArray<{ value: WorkspaceFilter; label: string }>; + +export function FilterBar({ + filter, + filters, + listedAtMs, + onFilterChange, +}: { + filter: WorkspaceFilter; + filters: readonly WorkspaceFilter[]; + listedAtMs: number | null; + onFilterChange: (filter: WorkspaceFilter) => void; +}) { + const label = FILTERS.find(({ value }) => value === filter)?.label; + return ( +
+ + + {label} + + + } + /> + + { + const choice = FILTERS.find((option) => option.value === value); + if (choice) onFilterChange(choice.value); + }} + > + {FILTERS.map(({ value, label }) => ( + + {label} + + ))} + + + + {listedAtMs !== null && ( + <> +
+ ); +} diff --git a/packages/workspaces/src/components/RelativeTime.css b/packages/workspaces/src/components/RelativeTime.css new file mode 100644 index 0000000000..9760320f88 --- /dev/null +++ b/packages/workspaces/src/components/RelativeTime.css @@ -0,0 +1,5 @@ +.workspace-relative-time:focus-visible, +:where(:root[data-ui-style="stable"]) .workspace-relative-time:focus { + outline: 1px solid var(--ui-focus-border); + outline-offset: 2px; +} diff --git a/packages/workspaces/src/components/RelativeTime.tsx b/packages/workspaces/src/components/RelativeTime.tsx new file mode 100644 index 0000000000..96ebe76fbc --- /dev/null +++ b/packages/workspaces/src/components/RelativeTime.tsx @@ -0,0 +1,43 @@ +import { Tooltip } from "@repo/ui"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { useEffect, useState } from "react"; + +import "./RelativeTime.css"; + +dayjs.extend(relativeTime); + +/** Keeps a timestamp's age current without re-rendering the workspace tree. */ +export function RelativeTime({ timestamp }: { timestamp: number }) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1_000); + return () => clearInterval(timer); + }, []); + + const date = new Date(timestamp); + // A pushed timestamp can be newer than the most recent clock tick. + const relativeNow = Math.max(now, timestamp); + return ( + + + + ); +} diff --git a/packages/workspaces/src/components/WorkspacesPanel.css b/packages/workspaces/src/components/WorkspacesPanel.css new file mode 100644 index 0000000000..6d95c7bb57 --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesPanel.css @@ -0,0 +1,21 @@ +.workspaces-panel { + position: relative; + box-sizing: border-box; + height: 100%; + overflow-y: auto; + padding-block: var(--ui-spacing-40); +} + +.workspaces-refresh-progress.ui-progress-bar { + position: absolute; + inset-block-start: 0; + inset-inline: 0; +} + +.workspaces-stale { + margin: 0 var(--ui-spacing-120) var(--ui-spacing-60); +} + +.workspaces-stale__error { + color: var(--ui-description-foreground); +} diff --git a/packages/workspaces/src/components/WorkspacesPanel.stories.tsx b/packages/workspaces/src/components/WorkspacesPanel.stories.tsx new file mode 100644 index 0000000000..c9b33faf6a --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesPanel.stories.tsx @@ -0,0 +1,300 @@ +import { + agent, + agentMetadata, + app, + appStatus, + PENDING_METADATA, + workspace, + workspacesState, +} 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"; + +import type { WorkspacesApiClient } from "../hooks/useWorkspaces"; + +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", + chatWorkspaceIds: ["ws-api"], + workspaces: WORKSPACES, + listedAtMs: Date.now(), + 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: WorkspacesApiClient = { + onStateChanged: () => () => undefined, + ready: fn(), + login: fn(), + openWorkspace: fn(), + viewInDashboard: fn(), + refresh: fn(), + setFilter: fn(), + watchAgents: fn(), +}; + +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 Stable: Story = { globals: { uiStyle: "stable" } }; + +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." }, + listedAtMs: Date.now() - 5 * 60_000, + }, + ], +]; + +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..dbb7d0dede --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesPanel.tsx @@ -0,0 +1,109 @@ +import { + Banner, + Button, + EmptyState, + ErrorState, + LoadingState, + ProgressBar, + TooltipProvider, +} from "@repo/ui"; + +import { FilterBar } from "./FilterBar"; +import { RelativeTime } from "./RelativeTime"; +import "./WorkspacesPanel.css"; +import { WorkspacesTree } from "./WorkspacesTree/WorkspacesTree"; + +import type { WorkspacesState } from "@repo/shared"; +import type { ReactNode } from "react"; + +import type { WorkspacesApiClient } from "../hooks/useWorkspaces"; + +export function WorkspacesPanel({ + state, + api, +}: { + state: WorkspacesState | undefined; + api: WorkspacesApiClient; +}) { + if (state && !state.capabilities.authenticated) { + return ( + api.login()}>Login} + /> + ); + } + + const workspaces = state?.workspaces ?? []; + const listedAtMs = state?.listedAtMs ?? null; + const loading = !state || state.status.kind === "loading"; + const failed = state?.status.kind === "failed" ? state.status : undefined; + const hasWorkspaces = workspaces.length > 0; + let placeholder: ReactNode; + if (hasWorkspaces) { + placeholder = null; + } else if (loading) { + placeholder = ; + } else if (failed) { + placeholder = ( + api.refresh()} + /> + ); + } else { + placeholder = ( + + ); + } + + return ( + +
+ {state && ( + api.setFilter({ filter })} + /> + )} + {failed && hasWorkspaces && ( + api.refresh() }]} + > + Couldn't refresh. + {listedAtMs !== null && ( + <> + {" Showing results from "} + . + + )} + {failed.error} + + )} + {loading && hasWorkspaces && ( + + )} + {placeholder} + +
+
+ ); +} diff --git a/packages/workspaces/src/components/WorkspacesTree/AgentHover.tsx b/packages/workspaces/src/components/WorkspacesTree/AgentHover.tsx new file mode 100644 index 0000000000..d7d5a10620 --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/AgentHover.tsx @@ -0,0 +1,16 @@ +import { RowHover } from "./RowHover"; +import { agentStatus, formatStatus } from "./status"; + +import type { WorkspaceAgent } from "@repo/shared"; + +export function AgentHover({ agent }: { agent: WorkspaceAgent }) { + return ( + + ); +} diff --git a/packages/workspaces/src/components/WorkspacesTree/AgentRow.tsx b/packages/workspaces/src/components/WorkspacesTree/AgentRow.tsx new file mode 100644 index 0000000000..cca1bcb231 --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/AgentRow.tsx @@ -0,0 +1,21 @@ +import { Icon } from "@repo/ui"; + +import { RowLabel } from "./RowLabel"; +import { agentStatus } from "./status"; +import { StatusDot } from "./StatusDot"; + +import type { WorkspaceAgent } from "@repo/shared"; + +export function AgentRow({ agent }: { agent: WorkspaceAgent }) { + return ( + } + name={agent.name || "Unknown"} + > + + + ); +} diff --git a/packages/workspaces/src/components/WorkspacesTree/MetadataRow.tsx b/packages/workspaces/src/components/WorkspacesTree/MetadataRow.tsx new file mode 100644 index 0000000000..ed14255d77 --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/MetadataRow.tsx @@ -0,0 +1,18 @@ +export function MetadataRow({ + name, + value, + failed, +}: { + name: string; + value: string; + failed: boolean; +}) { + return ( + + {name}: + {value} + + ); +} diff --git a/packages/workspaces/src/components/WorkspacesTree/OpenActions.tsx b/packages/workspaces/src/components/WorkspacesTree/OpenActions.tsx new file mode 100644 index 0000000000..c5404fbea1 --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/OpenActions.tsx @@ -0,0 +1,37 @@ +import { IconButton } from "@repo/ui"; + +import type { OpenWorkspaceParams } from "@repo/shared"; + +import type { WorkspacesApiClient } from "../../hooks/useWorkspaces"; + +export function OpenActions({ + name, + target, + api, +}: { + name: string; + target: OpenWorkspaceParams; + api: WorkspacesApiClient; +}) { + return ( + <> + api.openWorkspace(target)} + /> + + api.viewInDashboard({ + workspaceId: target.workspaceId, + page: "workspace", + }) + } + /> + + ); +} diff --git a/packages/workspaces/src/components/WorkspacesTree/RowHover.tsx b/packages/workspaces/src/components/WorkspacesTree/RowHover.tsx new file mode 100644 index 0000000000..6fcba645cb --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/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/WorkspacesTree/RowLabel.tsx b/packages/workspaces/src/components/WorkspacesTree/RowLabel.tsx new file mode 100644 index 0000000000..883adf38bd --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/RowLabel.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; + +export function RowLabel({ + icon, + name, + children, +}: { + icon: ReactNode; + name: string; + children?: ReactNode; +}) { + return ( + + {icon} + {name} + {children} + + ); +} diff --git a/packages/workspaces/src/components/WorkspacesTree/StatusDot.tsx b/packages/workspaces/src/components/WorkspacesTree/StatusDot.tsx new file mode 100644 index 0000000000..7d367d7879 --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/StatusDot.tsx @@ -0,0 +1,13 @@ +import { Icon } from "@repo/ui"; + +import { formatStatus, statusTone, type RowStatus } from "./status"; + +export function StatusDot({ status }: { status: RowStatus }) { + return ( + + ); +} diff --git a/packages/workspaces/src/components/WorkspacesTree/WorkspaceActions.tsx b/packages/workspaces/src/components/WorkspacesTree/WorkspaceActions.tsx new file mode 100644 index 0000000000..04184db88f --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/WorkspaceActions.tsx @@ -0,0 +1,112 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + Icon, + IconButton, + MenuItem, + MenuSeparator, + type CodiconName, +} from "@repo/ui"; + +import { OpenActions } from "./OpenActions"; + +import type { Workspace, WorkspaceStatus } from "@repo/shared"; + +import type { WorkspacesApiClient } from "../../hooks/useWorkspaces"; + +interface LifecycleAction { + readonly label: string; + readonly icon: CodiconName; +} + +const RETRY: LifecycleAction = { label: "Retry", icon: "debug-restart" }; + +const LIFECYCLE_ACTIONS: Readonly< + Partial> +> = { + running: [ + { label: "Stop", icon: "primitive-square" }, + { label: "Restart", icon: "debug-restart" }, + ], + stopped: [{ label: "Start", icon: "debug-continue" }], + failed: [RETRY], + canceled: [RETRY], +}; + +export function WorkspaceActions({ + workspace, + api, +}: { + workspace: Workspace; + api: WorkspacesApiClient; +}) { + const name = workspace.name || "Unknown"; + const lifecycleActions = + LIFECYCLE_ACTIONS[workspace.latest_build.status] ?? []; + return ( + <> + + + + } + /> + + {lifecycleActions.map(({ label, icon }) => ( + + + {label} + + ))} + {lifecycleActions.length > 0 && } + + + Share + + + api.viewInDashboard({ + workspaceId: workspace.id, + page: "settings", + }) + } + > + + Settings + + + + + Ping + + + + Speed Test + + + + Support Bundle + + + + + Delete... + + + + + ); +} diff --git a/packages/workspaces/src/components/WorkspacesTree/WorkspaceHover.tsx b/packages/workspaces/src/components/WorkspacesTree/WorkspaceHover.tsx new file mode 100644 index 0000000000..5f1235c24b --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/WorkspaceHover.tsx @@ -0,0 +1,27 @@ +import { RowHover } from "./RowHover"; +import { formatStatus, workspaceStatus } from "./status"; + +import type { Workspace } from "@repo/shared"; + +export function WorkspaceHover({ + workspace, + showOwner, +}: { + workspace: Workspace; + showOwner: boolean; +}) { + const template = + workspace.template_display_name || workspace.template_name || "Unknown"; + return ( + + ); +} diff --git a/packages/workspaces/src/components/WorkspacesTree/WorkspaceRow.tsx b/packages/workspaces/src/components/WorkspacesTree/WorkspaceRow.tsx new file mode 100644 index 0000000000..f979bbaea8 --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/WorkspaceRow.tsx @@ -0,0 +1,35 @@ +import { StatusPill } from "@repo/ui"; + +import { RowLabel } from "./RowLabel"; +import { workspaceStatus } from "./status"; +import { StatusDot } from "./StatusDot"; + +import type { Workspace } from "@repo/shared"; + +export function WorkspaceRow({ + workspace, + showOwner, + hasAgentChat, +}: { + workspace: Workspace; + showOwner: boolean; + hasAgentChat: boolean; +}) { + return ( + } + name={workspace.name || "Unknown"} + > + {hasAgentChat && ( + + Agent + + )} + {showOwner && ( + + {workspace.owner_name || "Unknown"} + + )} + + ); +} diff --git a/packages/workspaces/src/components/WorkspacesTree/WorkspacesTree.css b/packages/workspaces/src/components/WorkspacesTree/WorkspacesTree.css new file mode 100644 index 0000000000..d599290903 --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/WorkspacesTree.css @@ -0,0 +1,97 @@ +.workspace-label, +.workspace-metadata { + display: flex; + width: 100%; + min-width: 0; +} + +.workspace-label { + align-items: center; + gap: var(--ui-spacing-60); +} + +.workspace-label > :not(.workspace-label__text) { + flex: none; +} + +.workspace-label__text, +.workspace-metadata > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.workspace-label__muted, +.workspace-metadata-heading { + color: var(--ui-description-foreground); +} + +.workspace-metadata { + justify-content: space-between; + gap: var(--ui-spacing-120); +} + +.workspace-metadata__value { + max-width: 65%; + text-align: end; + font-variant-numeric: tabular-nums; +} + +.workspace-metadata--error { + color: var(--ui-error-foreground); +} + +.ui-tree-item[aria-selected="true"] + :is( + .workspace-label__muted, + .workspace-agent-badge, + .workspace-metadata--error + ) { + color: inherit; +} + +.workspace-hover { + max-width: 280px; + overflow-wrap: anywhere; +} + +.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 > dt { + color: var(--ui-description-foreground); +} + +.workspace-hover__details > dd { + margin: 0; +} + +.workspace-status { + color: var(--ui-description-foreground); +} + +.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)); +} + +@media (forced-colors: active) { + .workspace-status { + color: CanvasText; + } +} diff --git a/packages/workspaces/src/components/WorkspacesTree/WorkspacesTree.tsx b/packages/workspaces/src/components/WorkspacesTree/WorkspacesTree.tsx new file mode 100644 index 0000000000..dd75f9a367 --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/WorkspacesTree.tsx @@ -0,0 +1,61 @@ +import { Tree } from "@repo/ui"; +import { useEffect, useState } from "react"; + +import { watchedAgentIds, workspaceAgents, workspaceNodes } from "./treeNodes"; +import "./WorkspacesTree.css"; + +import type { AgentMetadataMap, Workspace } from "@repo/shared"; + +import type { WorkspacesApiClient } from "../../hooks/useWorkspaces"; + +export function WorkspacesTree({ + workspaces, + metadata, + chatWorkspaceIds, + showOwner, + api, +}: { + workspaces: readonly Workspace[]; + metadata: AgentMetadataMap; + chatWorkspaceIds: readonly string[]; + showOwner: boolean; + api: WorkspacesApiClient; +}) { + const [expandedIds, setExpandedIds] = useState([]); + const [selectedItemId, setSelectedItemId] = useState(); + const nodes = workspaceNodes({ + workspaces, + metadata, + chatWorkspaceIds, + showOwner, + api, + }); + + useEffect(() => () => api.watchAgents({ agentIds: [] }), [api]); + + function expand(ids: readonly string[]) { + // Opening a workspace opens its agents too. + const openedAgentIds = workspaces + .filter(({ id }) => ids.includes(id) && !expandedIds.includes(id)) + .flatMap(workspaceAgents) + .map(({ id }) => id) + .filter((id) => !ids.includes(id)); + const next = [...ids, ...openedAgentIds]; + setExpandedIds(next); + api.watchAgents({ agentIds: watchedAgentIds(nodes, next) }); + } + + if (nodes.length === 0) { + return null; + } + return ( + + ); +} diff --git a/packages/workspaces/src/components/WorkspacesTree/status.ts b/packages/workspaces/src/components/WorkspacesTree/status.ts new file mode 100644 index 0000000000..766e6aa16a --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/status.ts @@ -0,0 +1,91 @@ +import type { + Workspace, + WorkspaceAgent, + WorkspaceAgentLifecycle, + WorkspaceAgentStatus, + WorkspaceAppStatus, + WorkspaceAppStatusState, + WorkspaceStatus, +} from "@repo/shared"; + +export type RowStatus = + | WorkspaceStatus + | WorkspaceAgentStatus + | WorkspaceAgentLifecycle + | WorkspaceAppStatusState + | "unhealthy"; + +export type StatusTone = "running" | "pending" | "error" | "stopped"; + +const TONES = { + running: "running", + connected: "running", + complete: "running", + pending: "pending", + working: "pending", + connecting: "pending", + starting: "pending", + stopping: "pending", + deleting: "pending", + canceling: "pending", + created: "pending", + shutting_down: "pending", + failed: "error", + failure: "error", + unhealthy: "error", + timeout: "error", + start_error: "error", + start_timeout: "error", + shutdown_error: "error", + shutdown_timeout: "error", + stopped: "stopped", + canceled: "stopped", + deleted: "stopped", + disconnected: "stopped", + off: "stopped", + ready: "stopped", + idle: "stopped", +} as const satisfies Record; + +/** Health is only meaningful for the build that is running. */ +export function workspaceStatus(workspace: Workspace): RowStatus { + const status = workspace.latest_build.status; + return status === "running" && !workspace.health.healthy + ? "unhealthy" + : status; +} + +/** A connected agent reports its lifecycle until ready, then its health. */ +export function agentStatus(agent: WorkspaceAgent): RowStatus { + if (agent.status !== "connected") { + return agent.status; + } + if (agent.lifecycle_state !== "ready") { + return agent.lifecycle_state; + } + return agent.health.healthy ? "connected" : "unhealthy"; +} + +/** Apps report a history of statuses; only the newest is current. */ +export function latestAppStatus( + statuses: readonly WorkspaceAppStatus[], +): WorkspaceAppStatus | undefined { + return statuses.reduce( + (latest, candidate) => + !latest || + Date.parse(candidate.created_at) > Date.parse(latest.created_at) + ? candidate + : latest, + undefined, + ); +} + +export function statusTone(status: RowStatus): StatusTone { + return TONES[status]; +} + +/** `start_timeout` reads as "Start timeout". */ +export function formatStatus(status: RowStatus): string { + const words = status.replaceAll("_", " "); + return words.charAt(0).toUpperCase() + words.slice(1); +} diff --git a/packages/workspaces/src/components/WorkspacesTree/treeNodes.tsx b/packages/workspaces/src/components/WorkspacesTree/treeNodes.tsx new file mode 100644 index 0000000000..20a473fb6d --- /dev/null +++ b/packages/workspaces/src/components/WorkspacesTree/treeNodes.tsx @@ -0,0 +1,192 @@ +import { Spinner, type TreeNode } from "@repo/ui"; +import { formatDistanceToNowStrict } from "date-fns"; + +import { AgentHover } from "./AgentHover"; +import { AgentRow } from "./AgentRow"; +import { MetadataRow } from "./MetadataRow"; +import { OpenActions } from "./OpenActions"; +import { RowLabel } from "./RowLabel"; +import { formatStatus, latestAppStatus } from "./status"; +import { StatusDot } from "./StatusDot"; +import { WorkspaceActions } from "./WorkspaceActions"; +import { WorkspaceHover } from "./WorkspaceHover"; +import { WorkspaceRow } from "./WorkspaceRow"; + +import type { + AgentMetadataMap, + AgentMetadataState, + Workspace, + WorkspaceAgent, +} from "@repo/shared"; + +import type { WorkspacesApiClient } from "../../hooks/useWorkspaces"; + +const METADATA_PREFIX = "metadata:"; + +export interface WorkspaceNodesInput { + readonly workspaces: readonly Workspace[]; + readonly metadata: AgentMetadataMap; + readonly chatWorkspaceIds: readonly string[]; + readonly showOwner: boolean; + readonly api: WorkspacesApiClient; +} + +export function workspaceNodes(input: WorkspaceNodesInput): TreeNode[] { + return input.workspaces.map((workspace) => workspaceNode(workspace, input)); +} + +/** Agents whose metadata heading is expanded under expanded ancestors. */ +export function watchedAgentIds( + nodes: readonly TreeNode[], + expandedIds: readonly string[], +): string[] { + return nodes.flatMap((node) => { + if (!node.children || !expandedIds.includes(node.id)) { + return []; + } + const watched = watchedAgentIds(node.children, expandedIds); + if (node.id.startsWith(METADATA_PREFIX)) { + watched.push(node.id.slice(METADATA_PREFIX.length)); + } + return watched; + }); +} + +export function workspaceAgents(workspace: Workspace): WorkspaceAgent[] { + return workspace.latest_build.resources.flatMap( + (resource) => resource.agents ?? [], + ); +} + +function workspaceNode( + workspace: Workspace, + { metadata, chatWorkspaceIds, showOwner, api }: WorkspaceNodesInput, +): TreeNode { + const name = workspace.name || "Unknown"; + const owner = workspace.owner_name || "Unknown"; + const hasAgentChat = chatWorkspaceIds.includes(workspace.id); + const agents = workspaceAgents(workspace); + // An agent whose parent is not listed shows at the top level. + const children = agents + .filter((agent) => !agents.some(({ id }) => id === agent.parent_id)) + .map((agent) => agentNode(agent, { workspace, agents, metadata, api })); + return { + id: workspace.id, + textValue: [name, hasAgentChat && "Agent", showOwner && owner] + .filter(Boolean) + .join(" "), + tooltip: , + label: ( + + ), + action: , + children: children.length + ? children + : [{ id: `agents:${workspace.id}`, label: "No workspace agents" }], + }; +} + +interface AgentContext { + readonly workspace: Workspace; + readonly agents: readonly WorkspaceAgent[]; + readonly metadata: AgentMetadataMap; + readonly api: WorkspacesApiClient; +} + +function agentNode(agent: WorkspaceAgent, ctx: AgentContext): TreeNode { + const name = agent.name || "Unknown"; + const metadataId = `${METADATA_PREFIX}${agent.id}`; + return { + id: agent.id, + textValue: name, + tooltip: , + label: , + action: ( + + ), + children: [ + ...appStatusNodes(agent), + ...ctx.agents + .filter((child) => child.parent_id === agent.id) + .map((child) => agentNode(child, ctx)), + { + id: metadataId, + label: "Metadata", + className: "workspace-metadata-heading", + children: metadataNodes(metadataId, ctx.metadata[agent.id]), + }, + ], + }; +} + +function appStatusNodes(agent: WorkspaceAgent): TreeNode[] { + return agent.apps.flatMap((app) => { + const status = latestAppStatus(app.statuses); + if (!status) { + return []; + } + const text = `${app.display_name || app.slug}: ${status.message || formatStatus(status.state)}`; + return { + id: `app-status:${app.id}`, + textValue: text, + label: ( + } name={text} /> + ), + }; + }); +} + +function metadataNodes( + prefix: string, + state: AgentMetadataState | undefined, +): TreeNode[] { + if (!state || state.kind === "pending") { + return [ + { + id: `${prefix}:loading`, + textValue: "Loading metadata", + label: ( + } + name="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: ( + + ), + tooltip: ( + <> + {name}: {value} +
+ Collected{" "} + {formatDistanceToNowStrict(new Date(result.collected_at), { + addSuffix: true, + })} + + ), + }; + }); +} diff --git a/packages/workspaces/src/hooks/useWorkspaces.ts b/packages/workspaces/src/hooks/useWorkspaces.ts index 86efb089ef..e4ffd29efa 100644 --- a/packages/workspaces/src/hooks/useWorkspaces.ts +++ b/packages/workspaces/src/hooks/useWorkspaces.ts @@ -1,24 +1,35 @@ import { buildApiHook, WorkspacesApi, + type ApiHook, type WorkspacesState, } from "@repo/shared"; import { useIpc } from "@repo/webview-shared/react"; +import { replaceEqualDeep } from "@tanstack/react-query"; import { useEffect, useState } from "react"; +export type WorkspacesApiClient = ApiHook; + /** - * The state the extension pushes, and the commands to send back. The state is - * undefined until the extension answers `ready`. + * The state the extension pushes, and a stable set of commands to send back. + * The state is undefined until the extension answers `ready`. Parts of a push + * equal to the last one keep their identity, so unchanged rows stay memoized. */ -export function useWorkspaces() { - const api = buildApiHook(WorkspacesApi, useIpc()); +export function useWorkspaces(): { + state: WorkspacesState | undefined; + api: WorkspacesApiClient; +} { + const ipc = useIpc(); + const [api] = useState(() => buildApiHook(WorkspacesApi, ipc)); const [state, setState] = useState(); useEffect(() => { - const unsubscribe = api.onStateChanged(setState); + const unsubscribe = api.onStateChanged((next) => + setState((previous) => replaceEqualDeep(previous, next)), + ); api.ready(); return unsubscribe; - }, []); + }, [api]); return { state, api }; } diff --git a/packages/workspaces/src/index.css b/packages/workspaces/src/index.css index 8f414f586f..5f61b7af05 100644 --- a/packages/workspaces/src/index.css +++ b/packages/workspaces/src/index.css @@ -1 +1,12 @@ -/* 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; +} diff --git a/packages/workspaces/src/index.tsx b/packages/workspaces/src/index.tsx index e6bb115928..d10bcd3bc2 100644 --- a/packages/workspaces/src/index.tsx +++ b/packages/workspaces/src/index.tsx @@ -1,13 +1,12 @@ +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"; import { createRoot } from "react-dom/client"; import App from "./App"; import "./index.css"; -const queryClient = new QueryClient(); - const root = document.getElementById("root"); if (!root) { throw new Error( @@ -17,10 +16,8 @@ if (!root) { createRoot(root).render( - - - - - + + + , ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 065ae0621a..f14f33dde5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ catalogs: date-fns: specifier: ^4.4.0 version: 4.4.0 + dayjs: + specifier: ^1.11.23 + version: 1.11.23 react: specifier: ^19.3.1 version: 19.3.1 @@ -403,7 +406,7 @@ importers: specifier: ^10.1.0 version: 10.1.0 dayjs: - specifier: ^1.11.23 + specifier: 'catalog:' version: 1.11.23 electron: specifier: 44.4.1 @@ -664,21 +667,24 @@ importers: '@repo/shared': specifier: workspace:* version: link:../shared + '@repo/ui': + specifier: workspace:* + version: link:../ui '@repo/webview-shared': specifier: workspace:* version: link:../webview-shared '@tanstack/react-query': specifier: 'catalog:' version: 5.104.2(react@19.3.1) - '@vscode-elements/react-elements': - specifier: 'catalog:' - version: 2.4.0(@types/react@19.3.0)(@vscode/codicons@0.0.46-24)(react-dom@19.3.1)(react@19.3.1) '@vscode/codicons': specifier: 'catalog:' version: 0.0.46-24 date-fns: specifier: 'catalog:' version: 4.4.0 + dayjs: + specifier: 'catalog:' + version: 1.11.23 react: specifier: 'catalog:' version: 19.3.1 @@ -5690,8 +5696,6 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - '@coder/coder@https://codeload.github.com/coder/coder/tar.gz/22f4284f8fa67bc3af6fc38e0079616d39128566': {} - '@coder/pixel-storybook@0.3.0(playwright-core@1.64.1)(storybook@10.6.2)': dependencies: get-port-please: 3.1.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6b07f3861d..130ba88513 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,7 @@ catalog: "@vscode/codicons": 0.0.46-24 babel-plugin-react-compiler: ^1.0.0 date-fns: ^4.4.0 + dayjs: ^1.11.23 react: ^19.3.1 react-dom: ^19.3.1 storybook: ^10.6.2 diff --git a/src/extension.ts b/src/extension.ts index 751cacf93f..3f6004ebd9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -312,6 +312,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..3c32f67333 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,17 +37,8 @@ interface WorkspacesClient extends AgentMetadataClient { const RETRY: RetryOptions = { initialDelayMs: 5_000, maxDelayMs: 60_000 }; const LOADING: WorkspaceListStatus = { kind: "loading" }; const READY: WorkspaceListStatus = { kind: "ready" }; - -function listedAgentIds( - requested: readonly string[], - workspaces: readonly Workspace[], -): readonly string[] { - if (requested.length === 0) { - return []; - } - const listed = new Set(extractAllAgents(workspaces).map((agent) => agent.id)); - return requested.filter((agentId) => listed.has(agentId)); -} +/** The deployment rejects chat lookups for more workspaces than this. */ +const CHAT_LOOKUP_BATCH_SIZE = 25; /** * Owns the state the Workspaces panel renders: lists the active filter while @@ -62,6 +56,9 @@ export class WorkspaceStore implements vscode.Disposable { private filter: WorkspaceFilter = DEFAULT_WORKSPACE_FILTER; private workspaces: readonly Workspace[] = []; + private listedAtMs: number | null = null; + private chatWorkspaceIds: readonly string[] = []; + private chatLookupSupported = true; private status = LOADING; /** Agents the webview is showing, listed or not. */ private requestedAgents: readonly string[] = []; @@ -95,6 +92,8 @@ export class WorkspaceStore implements vscode.Disposable { workspaces: this.workspaces, status: this.status, metadata: this.agents.metadata, + listedAtMs: this.listedAtMs, + chatWorkspaceIds: this.chatWorkspaceIds, }; } @@ -125,7 +124,8 @@ export class WorkspaceStore implements vscode.Disposable { return this.settled; } this.filter = filter; - return this.reload(); + this.clearList(); + return this.poller.run(); } /** Watch exactly these agents, as far as the list has them. */ @@ -134,14 +134,16 @@ export class WorkspaceStore implements vscode.Disposable { return this.watchListedAgents(); } - /** List now, keeping the list on show and offering rejected filters again. */ + /** List now, keeping the list on show and retrying what was unsupported. */ public refresh(): Promise { // A hidden panel cannot re-validate a filter it offers again. if (this.disposed || !this.visible) { return this.settled; } this.unsupportedFilters.clear(); - return this.reload(this.workspaces); + this.chatLookupSupported = true; + this.setList(this.workspaces, LOADING); + return this.poller.run(); } public dispose(): void { @@ -152,10 +154,6 @@ export class WorkspaceStore implements vscode.Disposable { this.changeEmitter.dispose(); } - private get filters(): readonly WorkspaceFilter[] { - return availableFilters(this.sessionState.current, this.unsupportedFilters); - } - private async list(token: vscode.CancellationToken): Promise { if (this.disposed) { return "idle"; @@ -177,7 +175,9 @@ export class WorkspaceStore implements vscode.Disposable { if (token.isCancellationRequested) { return "idle"; } + this.listedAtMs = Date.now(); this.setList(workspaces); + await this.loadChatWorkspaces(workspaces, token); return this.visible && pollIntervalMs !== undefined ? { delayMs: pollIntervalMs } : "idle"; @@ -189,11 +189,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.setList([], LOADING); + this.clearList(); return this.list(token); } this.logger.warn("Failed to fetch workspaces:", error); - this.setList([], { + // Keep the last list for this filter and session, marked out of date. + this.setList(this.workspaces, { kind: "failed", error: errToStr(error, "Failed to fetch workspaces"), }); @@ -201,12 +202,6 @@ export class WorkspaceStore implements vscode.Disposable { } } - /** Show `workspaces` as loading, then list again. */ - private reload(workspaces: readonly Workspace[] = []): Promise { - this.setList(workspaces, LOADING); - return this.poller.run(); - } - private setList(workspaces: readonly Workspace[], status = READY): void { this.workspaces = workspaces; this.status = status; @@ -234,14 +229,80 @@ export class WorkspaceStore implements vscode.Disposable { } } + /** Forget the list, so the next one shows as loading from scratch. */ + private clearList(): void { + this.listedAtMs = null; + this.chatWorkspaceIds = []; + this.setList([], LOADING); + } + + /** Replaces the chat associations only once every batch succeeds. */ + private async loadChatWorkspaces( + workspaces: readonly Workspace[], + token: vscode.CancellationToken, + ): Promise { + if (!this.chatLookupSupported) { + return; + } + const workspaceIds = workspaces.map((workspace) => workspace.id); + const chatWorkspaceIds: string[] = []; + try { + for (let i = 0; i < workspaceIds.length; i += CHAT_LOOKUP_BATCH_SIZE) { + const batch = workspaceIds.slice(i, i + CHAT_LOOKUP_BATCH_SIZE); + const chats = await this.client.experimental.getChatsByWorkspace(batch); + if (token.isCancellationRequested) { + return; + } + chatWorkspaceIds.push(...batch.filter((id) => Boolean(chats[id]))); + } + this.chatWorkspaceIds = chatWorkspaceIds; + } catch (error) { + if (token.isCancellationRequested) { + return; + } + if (!isUnsupported(error)) { + this.logger.warn("Failed to look up Agent chat workspaces:", error); + return; + } + this.chatLookupSupported = false; + this.chatWorkspaceIds = []; + } + this.publish(); + } + private handleSessionChange(): void { this.unsupportedFilters.clear(); + this.chatLookupSupported = true; this.requestedAgents = []; if (!this.filters.includes(this.filter)) { this.filter = DEFAULT_WORKSPACE_FILTER; } - void this.reload(); + this.clearList(); + void this.poller.run(); // Another session's sockets must not linger. this.agents.clear(); } + + private get filters(): readonly WorkspaceFilter[] { + return availableFilters(this.sessionState.current, this.unsupportedFilters); + } +} + +function listedAgentIds( + requested: readonly string[], + workspaces: readonly Workspace[], +): readonly string[] { + if (requested.length === 0) { + return []; + } + const listed = new Set(extractAllAgents(workspaces).map((agent) => agent.id)); + return requested.filter((agentId) => listed.has(agentId)); +} + +/** Deployments without Agent chats, or users without access to them. */ +function isUnsupported(error: unknown): boolean { + const status = isAxiosError(error) ? error.response?.status : undefined; + return ( + status === HttpStatusCode.FORBIDDEN || status === HttpStatusCode.NOT_FOUND + ); } 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 1ad36bbc48..afa007b625 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -1587,6 +1587,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..2763b785c5 100644 --- a/test/unit/webviews/workspaces/workspaceStore.test.ts +++ b/test/unit/webviews/workspaces/workspaceStore.test.ts @@ -28,6 +28,9 @@ const withAgent = () => const ids = (state?: WorkspacesState) => state?.workspaces.map((w) => w.id); +const many = (count: number) => + Array.from({ length: count }, (_, i) => workspace({ id: `workspace-${i}` })); + function setup() { const client = new MockWorkspacesClient(); const session = new TestSessionStore(); @@ -82,24 +85,28 @@ describe("WorkspaceStore", () => { workspaces: [expect.objectContaining({ id: "workspace-1" })], status: { kind: "ready" }, metadata: {}, + listedAtMs: expect.any(Number), + chatWorkspaceIds: [], }, ]); 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, reporting when each list arrived", async () => { + vi.useFakeTimers({ now: 1_234 }); onTestFinished(() => { vi.useRealTimers(); }); - const second = () => [workspace({ id: "second" })]; - const h = await shown([workspace({ id: "first" })], second(), second()); - - await vi.advanceTimersByTimeAsync(5_000); - expect(h.states.map(ids)).toEqual([["first"], ["second"]]); - await vi.advanceTimersByTimeAsync(5_000); - expect(h.states).toHaveLength(2); + // Later polls list nothing, which still counts as listing. + const h = await shown([workspace({ id: "first" })]); + + await vi.advanceTimersByTimeAsync(10_000); + expect(h.states.map((state) => [ids(state), state.listedAtMs])).toEqual([ + [["first"], 1_234], + [[], 6_234], + [[], 11_234], + ]); await h.store.setFilter("shared"); const fetches = h.client.getWorkspaces.mock.calls.length; @@ -163,19 +170,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_500); 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" }, + listedAtMs: 90_500, }); 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" }, + listedAtMs: null, + }); + }); + it("stops offering a filter the deployment rejects, until a visible refresh", async () => { const h = await shown(); h.client.getWorkspaces.mockRejectedValueOnce(queryRejected()); @@ -209,6 +234,105 @@ 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.chatWorkspaceIds).toEqual(["workspace-1"]); + await h.store.watchAgents(["agent-1"]); + pushMetadata(h); + expect(h.client.experimental.getChatsByWorkspace).toHaveBeenCalledOnce(); + }); + + it("looks up long lists in batches, keeping the last associations unless every batch succeeds", async () => { + const h = setup(); + const lookup = h.client.experimental.getChatsByWorkspace; + lookup + .mockResolvedValueOnce({ "workspace-3": "chat-1" }) + .mockResolvedValueOnce({ "workspace-27": "chat-2" }); + await h.show(many(30)); + expect(lookup.mock.calls.map(([ids]) => ids.length)).toEqual([25, 5]); + expect(h.store.state.chatWorkspaceIds).toEqual([ + "workspace-3", + "workspace-27", + ]); + + h.client.respondOnce(many(30)); + lookup + .mockResolvedValueOnce({ "workspace-4": "chat-3" }) + .mockRejectedValueOnce(new Error("network down")); + await h.store.refresh(); + expect(h.store.state).toMatchObject({ + status: { kind: "ready" }, + chatWorkspaceIds: ["workspace-3", "workspace-27"], + }); + }); + + 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.chatWorkspaceIds).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.chatWorkspaceIds).toEqual(["workspace-1"]); + }); + + it("does not apply another session's associations, even partway through the batches", async () => { + const h = setup(); + const lastBatch = Promise.withResolvers>(); + const lastBatchStarted = Promise.withResolvers(); + h.client.experimental.getChatsByWorkspace + .mockResolvedValueOnce({ "workspace-3": "chat-1" }) + .mockImplementationOnce(() => { + lastBatchStarted.resolve(); + return lastBatch.promise; + }); + const listing = h.show(many(30)); + await lastBatchStarted.promise; + // Associations replace the last ones whole, not batch by batch. + expect(h.store.state.chatWorkspaceIds).toEqual([]); + h.session.signOut(); + lastBatch.resolve({ "workspace-27": "chat-2" }); + await listing; + expect(h.store.state.chatWorkspaceIds).toEqual([]); + 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 +373,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/components.test.tsx b/test/webview/ui/components/components.test.tsx index a660d87b4b..35a707d4a7 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, @@ -135,3 +136,31 @@ describe("state panels", () => { expect(onRetry).toHaveBeenCalledOnce(); }); }); + +describe("Banner", () => { + it("announces warnings politely and runs each action", async () => { + const user = userEvent.setup(); + const retry = vi.fn(); + const details = vi.fn(); + render( + + Out of date + , + ); + expect(screen.getByRole("status")).toHaveTextContent("Out of date"); + await user.click(screen.getByRole("button", { name: "Details" })); + expect(details).toHaveBeenCalledOnce(); + expect(retry).not.toHaveBeenCalled(); + }); + + 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..4e45fae380 --- /dev/null +++ b/test/webview/workspaces/components/WorkspacesPanel.test.tsx @@ -0,0 +1,509 @@ +import { act, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + agent, + agentMetadata, + app, + appStatus, + workspace, + workspacesState, +} from "@repo/mocks"; +import { + WorkspacesApi, + type WorkspacesState, + type WorkspaceStatus, +} from "@repo/shared"; + +import { + activeRow, + row, + stubElementBoxes, +} from "../../ui/components/Tree/treeTestHelpers"; +import { pushState, renderApp } from "../ipc"; + +import type { UserEvent } from "@testing-library/user-event"; + +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({ workspaces: [ALPHA, BETA] }); + +const SIGNED_OUT = workspacesState({ + capabilities: { authenticated: false, filters: [] }, +}); + +const setup = (state: WorkspacesState | null = STATE) => renderApp(state); + +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: UserEvent) => { + await user.keyboard("{Control>}k{/Control}{Control>}i{/Control}"); + return screen.findByRole("tooltip"); +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("WorkspacesPanel", () => { + it.each([{ workspaces: [] }, { workspaces: [ALPHA] }])( + "advances the Shared list age without another state push ($workspaces.length rows)", + async ({ workspaces }) => { + vi.useFakeTimers({ + now: new Date("2024-06-15T12:00:30Z"), + toFake: ["Date", "setInterval", "clearInterval"], + }); + const listedAtMs = Date.now(); + const { unmount } = await setup({ + ...STATE, + filter: "shared", + workspaces, + listedAtMs, + }); + expect(screen.getByText("a few seconds ago")).toBeVisible(); + await act(() => vi.advanceTimersByTimeAsync(44_000)); + expect(screen.getByText("a few seconds ago")).toBeVisible(); + await act(() => vi.advanceTimersByTimeAsync(1_000)); + expect(screen.getByText("a minute ago")).toBeVisible(); + await act(() => vi.advanceTimersByTimeAsync(45_000)); + expect(screen.getByText("2 minutes ago")).toBeVisible(); + await pushState({ + ...STATE, + filter: "shared", + workspaces, + listedAtMs: Date.now(), + }); + expect(screen.getByText("a few seconds ago")).toBeVisible(); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it("shows when the list arrived in full on hover", async () => { + vi.useFakeTimers({ + now: new Date("2024-06-15T12:00:30Z"), + toFake: ["Date"], + }); + const { user } = await setup( + workspacesState({ filter: "shared", listedAtMs: Date.now() }), + ); + const time = screen.getByText("a few seconds ago"); + expect(time).toHaveAttribute("datetime", "2024-06-15T12:00:30.000Z"); + await user.hover(time); + expect(await screen.findByRole("tooltip")).toHaveTextContent(/2024.*30/); + }); + + 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(); + }); + + interface HealthCase { + status: WorkspaceStatus; + label: string; + } + it.each([ + { status: "starting", label: "Starting" }, + { status: "stopped", label: "Stopped" }, + ])( + "shows $status 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, chatWorkspaceIds: [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 and menu 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" }, + ]); + 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-alpha", page: "workspace" }, + { workspaceId: "ws-beta", page: "settings" }, + ]); + }); + + interface LifecycleCase { + status: WorkspaceStatus; + actions: string[]; + } + it.each([ + { status: "running", actions: ["Stop", "Restart"] }, + { status: "stopped", actions: ["Start"] }, + { status: "canceled", actions: ["Retry"] }, + { status: "starting", actions: [] }, + ])( + "leads the menu with lifecycle actions for $status", + 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 names = within(await screen.findByRole("menu")) + .getAllByRole("menuitem") + .map((item) => item.textContent); + expect(names.slice(0, names.indexOf("Share"))).toEqual(actions); + }, + ); + + it("describes workspaces, workspace agents and metadata on hover", async () => { + stubElementBoxes(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime("2024-01-01T00:00:05Z"); + const { user } = await setup({ + ...STATE, + filter: "all", + chatWorkspaceIds: ["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, unmount } = 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([{ agentIds: [] }]); + + // 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: [] }]); + await user.click(row("alpha")); + expect(watches()).toEqual([{ agentIds: ["agent-dev", "agent-main"] }]); + unmount(); + 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(); + }); + + interface PlaceholderCase { + name: string; + state: WorkspacesState | null; + visible: { role: string; name: string }; + } + it.each([ + { + name: "loading before the first state", + state: null, + visible: { role: "status", name: "Loading workspaces" }, + }, + { + name: "loading the first list", + state: workspacesState({ status: { kind: "loading" } }), + visible: { role: "status", name: "Loading workspaces" }, + }, + { + name: "an empty list", + state: workspacesState(), + 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( + workspacesState({ + 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]); + const watches = () => drain(WorkspacesApi.watchAgents.method); + expect(watches()).toEqual([{ agentIds: [] }, { 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"]); + + await pushState(workspacesState({ status: { kind: "loading" } })); + expect(screen.queryByRole("tree")).not.toBeInTheDocument(); + await pushState({ ...STATE, workspaces: [gamma, alpha] }); + expect(row("alpha")).toHaveAttribute("aria-expanded", "true"); + expect(row("main")).toHaveAttribute("aria-expanded", "true"); + // The store matches watches against each list, so pushes send none. + expect(watches()).toEqual([]); + }); + + it("keeps the tree and its expansion while refreshing and after a failure", async () => { + vi.useFakeTimers({ + now: new Date("2024-06-15T12:00:00Z"), + toFake: ["Date", "setInterval", "clearInterval"], + }); + const { user, drain } = await setup(); + await user.click(row("alpha")); + + const listedAtMs = Date.now() - 5 * 60_000; + await pushState({ ...STATE, listedAtMs, status: { kind: "loading" } }); + expect( + screen.getByRole("progressbar", { name: "Refreshing workspaces" }), + ).toBeInTheDocument(); + expect(row("main")).toBeInTheDocument(); + expect(screen.getByText("5 minutes ago")).toBeInTheDocument(); + + await pushState({ + ...STATE, + listedAtMs, + 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 5 minutes ago. Timed out", + ); + await act(() => vi.advanceTimersByTimeAsync(45_000)); + expect(banner).toHaveTextContent("Showing results from 6 minutes ago."); + expect(row("main")).toBeInTheDocument(); + await user.click(within(banner).getByRole("button", { name: "Retry" })); + expect(drain(WorkspacesApi.refresh.method)).toEqual([undefined]); + }); +}); diff --git a/test/webview/workspaces/components/WorkspacesTree/WorkspacesTree.renderCost.test.tsx b/test/webview/workspaces/components/WorkspacesTree/WorkspacesTree.renderCost.test.tsx new file mode 100644 index 0000000000..1fad0aff6b --- /dev/null +++ b/test/webview/workspaces/components/WorkspacesTree/WorkspacesTree.renderCost.test.tsx @@ -0,0 +1,94 @@ +import { act, screen } from "@testing-library/react"; +import { Profiler, type ComponentProps } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { agent, workspace, workspacesState } from "@repo/mocks"; +import { type WorkspacesState } from "@repo/shared"; +import { workspaceNodes } from "@repo/workspaces/components/WorkspacesTree/treeNodes"; + +import { row } from "../../../ui/components/Tree/treeTestHelpers"; +import { pushState, renderApp } from "../../ipc"; + +// Holds only because the compiled panel and tree skip unchanged props; a +// compiler bailout or a whole-state dependency fails it. +const treeCommits = vi.hoisted(() => vi.fn()); + +vi.mock( + "@repo/workspaces/components/WorkspacesTree/treeNodes", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@repo/workspaces/components/WorkspacesTree/treeNodes") + >(); + return { ...actual, workspaceNodes: vi.fn(actual.workspaceNodes) }; + }, +); + +vi.mock("@repo/ui", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Tree: (props: ComponentProps) => ( + + + + ), + }; +}); + +/** Mounts the real App, then counts only tree work after the first state. */ +async function setup() { + vi.useFakeTimers({ + now: new Date("2024-06-15T12:00:00Z"), + toFake: ["Date", "setInterval", "clearInterval"], + }); + const state = workspacesState({ + workspaces: [workspace({ name: "alpha", agents: [agent()] })], + listedAtMs: Date.now(), + }); + await renderApp(state); + expect(row("alpha")).toBeInTheDocument(); + vi.mocked(workspaceNodes).mockClear(); + treeCommits.mockClear(); + const work = () => [ + vi.mocked(workspaceNodes).mock.calls.length, + treeCommits.mock.calls.length, + ]; + return { state, work }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("WorkspacesTree render cost", () => { + it("skips the tree while only the list age changes", async () => { + const { state, work } = await setup(); + + await act(() => vi.advanceTimersByTimeAsync(60_000)); + expect(screen.getByText("a minute ago")).toBeVisible(); + expect(work()).toEqual([0, 0]); + + // A poll that finds nothing new, deserialized like a posted message. + // structuredClone would build objects from another realm under vmForks. + await pushState( + JSON.parse( + JSON.stringify({ ...state, listedAtMs: Date.now() }), + ) as WorkspacesState, + ); + expect(screen.getByText("a few seconds ago")).toBeVisible(); + expect(work()).toEqual([0, 0]); + }); + + it("rebuilds the tree when a workspace changes", async () => { + const { state, work } = await setup(); + await pushState({ + ...state, + workspaces: [{ ...state.workspaces[0], name: "renamed" }], + }); + expect(row("renamed")).toBeInTheDocument(); + const [builds, commits] = work(); + expect(builds).toBeGreaterThan(0); + expect(commits).toBeGreaterThan(0); + }); +}); diff --git a/test/webview/workspaces/hooks/useWorkspaces.test.tsx b/test/webview/workspaces/hooks/useWorkspaces.test.tsx index cb783d70e5..aeab4c863b 100644 --- a/test/webview/workspaces/hooks/useWorkspaces.test.tsx +++ b/test/webview/workspaces/hooks/useWorkspaces.test.tsx @@ -1,67 +1,62 @@ -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 { workspace, workspacesState } from "@repo/mocks"; 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"] }, - filter: "mine", - workspaces: [], - status: { kind: "ready" }, - metadata: {}, -}; +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("keeps the identity of whatever a push leaves unchanged", async () => { + const { result } = setup(); + await pushState({ ...STATE, workspaces: [workspace({ id: "a" })] }); + const first = result.current.state; + await pushState({ + ...STATE, + workspaces: [workspace({ id: "a" })], + listedAtMs: 60_000, + }); + expect(result.current.state?.listedAtMs).toBe(60_000); + expect(result.current.state?.workspaces).toBe(first?.workspaces); }); 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.tsx b/test/webview/workspaces/ipc.tsx new file mode 100644 index 0000000000..6bd57b7848 --- /dev/null +++ b/test/webview/workspaces/ipc.tsx @@ -0,0 +1,62 @@ +import { act, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StrictMode } from "react"; +import { vi } from "vitest"; + +import { WorkspacesApi, type WorkspacesState } from "@repo/shared"; +import App from "@repo/workspaces/App"; + +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(); + }); + +/** + * Mounts the panel as `index.tsx` does, then pushes `state` unless null. + * `drain` returns the params sent for a method since its last call. + */ +export async function renderApp(state: WorkspacesState | null) { + const sent = captureSent(); + const user = userEvent.setup(); + const view = render( + + + , + ); + if (state) { + await pushState(state); + } + sent.length = 0; + 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 }; +} diff --git a/vitest.config.mts b/vitest.config.mts index a455a1848a..23854bcd53 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,3 +1,5 @@ +import babel from "@rolldown/plugin-babel"; +import { reactCompilerPreset } from "@vitejs/plugin-react"; import path from "node:path"; import { defineConfig } from "vitest/config"; @@ -36,6 +38,14 @@ export default defineConfig({ }, { extends: true, + // Webview sources compile as in createReactWebviewConfig; tests stay + // uncompiled so their instrumentation is not memoized. + plugins: [ + babel({ + include: [/[\\/]packages[\\/].*\.tsx?$/], + presets: [reactCompilerPreset()], + }), + ], test: { name: "webview", include: ["test/webview/**/*.test.{ts,tsx}"],