Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
from published versions since it shows up in the VS Code extension changelog
tab and is confusing to users. Add it back between releases if needed. -->

## Unreleased

### Added

- The experimental Workspaces panel lists workspace agents, metadata and app
status. Updated times expose the full date and time on hover or keyboard focus.

## [v1.17.2](https://github.com/coder/vscode-coder/releases/tag/v1.17.2) 2026-10-28

### Changed
Expand Down
29 changes: 22 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@
},
{
"id": "coderExperimentalWorkspaces",
"title": "Coder Remote (New)",
"title": "Coder Workspaces",
"icon": "media/shorthand-logo.svg"
}
]
Expand Down Expand Up @@ -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"
}
]
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -688,6 +694,10 @@
"command": "coder.tasks.refresh",
"when": "false"
},
{
"command": "coder.workspaces.refresh",
"when": "false"
},
{
"command": "coder.applyRecommendedSettings"
}
Expand All @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down
36 changes: 36 additions & 0 deletions packages/mocks/src/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentMetadata,
WorkspaceApp,
WorkspaceAppStatus,
WorkspaceBuild,
WorkspaceResource,
} from "coder/site/src/api/typesGenerated";
Expand Down Expand Up @@ -136,6 +138,40 @@ export function agent(overrides: Partial<WorkspaceAgent> = {}): WorkspaceAgent {
};
}

export function app(overrides: Partial<WorkspaceApp> = {}): WorkspaceApp {
return {
id: "app-1",
slug: "claude",
display_name: "Claude Code",
external: false,
subdomain: false,
sharing_level: "owner",
health: "healthy",
hidden: false,
open_in: "slim-window",
statuses: [],
...overrides,
};
}

export function appStatus(
overrides: Partial<WorkspaceAppStatus> = {},
): 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: {
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/workspaces/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const WorkspacesApi = {
// Commands
/** Webview signals its subscription is live and asks for the state */
ready: defineCommand<void>("ready"),
login: defineCommand<void>("login"),
openWorkspace: defineCommand<OpenWorkspaceParams>("openWorkspace"),
viewInDashboard: defineCommand<ViewInDashboardParams>("viewInDashboard"),
refresh: defineCommand<void>("refresh"),
Expand Down
9 changes: 8 additions & 1 deletion packages/shared/src/workspaces/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ export interface WorkspacesCapabilities {
export type WorkspaceListStatus =
| { readonly kind: "loading" }
| { readonly kind: "ready" }
| { readonly kind: "failed"; readonly error: string };
| {
readonly kind: "failed";
readonly error: string;
};

/** What one agent reports. A failure replaces its metadata in the UI. */
export type AgentMetadataState =
Expand All @@ -47,6 +50,10 @@ export interface WorkspacesState {
readonly workspaces: readonly Workspace[];
readonly status: WorkspaceListStatus;
readonly metadata: AgentMetadataMap;
/** Epoch ms, rounded down to the minute; unset before the first list. */
readonly listedAt?: number;
/** Listed workspace IDs associated with Agent chats. */
readonly agentWorkspaceIds?: readonly string[];
}

export interface OpenWorkspaceParams {
Expand Down
73 changes: 73 additions & 0 deletions packages/ui/src/components/Banner/Banner.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
.ui-banner {
--ui-banner-accent: var(--ui-status-info-accent);
display: flex;
align-items: flex-start;
gap: var(--ui-spacing-60);
box-sizing: border-box;
padding: var(--ui-spacing-40) var(--ui-spacing-60);
color: var(--ui-message-info-foreground);
background: var(--ui-message-info-background);
border: 1px solid var(--ui-message-info-border);
line-height: 18px;
overflow-wrap: anywhere;
}

.ui-banner--warning {
--ui-banner-accent: var(--ui-status-warning-accent);
color: var(--ui-message-warning-foreground);
background: var(--ui-message-warning-background);
border-color: var(--ui-message-warning-border);
}

.ui-banner--error {
--ui-banner-accent: var(--ui-status-danger-accent);
color: var(--ui-message-error-foreground);
background: var(--ui-message-error-background);
border-color: var(--ui-message-error-border);
}

.ui-banner__icon {
flex: none;
height: 18px;
color: var(--ui-banner-accent);
}

.ui-banner__message {
flex: 1;
min-width: 0;
}

.ui-banner__action {
flex: none;
align-self: center;
padding: 0;
font: inherit;
color: var(--ui-link-foreground);
background: none;
border: none;
cursor: pointer;
}

.ui-banner__action:hover {
color: var(--ui-link-active-foreground);
text-decoration: underline;
}

.ui-banner__action:focus {
outline: 1px solid var(--ui-focus-border);
outline-offset: 1px;
}

@media (forced-colors: active) {
.ui-banner {
border-color: CanvasText;
}

.ui-banner__icon {
color: CanvasText;
}

.ui-banner__action {
color: LinkText;
}
}
28 changes: 28 additions & 0 deletions packages/ui/src/components/Banner/Banner.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { PIXEL_ALL_THEMES } from "#storybook";

import { Banner } from "./Banner";

import type { Meta, StoryObj } from "@storybook/react-vite";

const Banners = (): React.JSX.Element => (
<div style={{ display: "grid", gap: "8px", width: "280px" }}>
<Banner>A new version of the agent is available.</Banner>
<Banner severity="warning" actionLabel="Retry" onAction={() => undefined}>
Couldn't refresh. Showing results from 2:05 PM.
</Banner>
<Banner severity="error">
The deployment rejected the request: a message long enough to wrap onto a
second line.
</Banner>
</div>
);

const meta: Meta<typeof Banners> = {
title: "UI/Banner",
component: Banners,
parameters: { pixel: PIXEL_ALL_THEMES },
};
export default meta;
type Story = StoryObj<typeof Banners>;

export const Severities: Story = {};
49 changes: 49 additions & 0 deletions packages/ui/src/components/Banner/Banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { type ComponentProps, type ReactNode } from "react";

import { cx } from "#cx";

import { Icon } from "../Icon/Icon";

import "./Banner.css";

import type { CodiconName } from "#codicons";

type BannerSeverity = "info" | "warning" | "error";

const ICONS: Readonly<Record<BannerSeverity, CodiconName>> = {
info: "info",
warning: "alert",
error: "error",
};

export interface BannerProps extends Omit<ComponentProps<"div">, "role"> {
severity?: BannerSeverity;
children: ReactNode;
actionLabel?: string;
onAction?: () => void;
}

export function Banner({
severity = "info",
children,
actionLabel,
onAction,
className,
...props
}: BannerProps): React.JSX.Element {
return (
<div
{...props}
role={severity === "error" ? "alert" : "status"}
className={cx("ui-banner", `ui-banner--${severity}`, className)}
>
<Icon name={ICONS[severity]} className="ui-banner__icon" />
<div className="ui-banner__message">{children}</div>
{actionLabel && onAction ? (
<button type="button" className="ui-banner__action" onClick={onAction}>
{actionLabel}
</button>
) : null}
</div>
);
}
10 changes: 9 additions & 1 deletion packages/ui/src/components/DropdownMenu/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { Menu as MenuPrimitive } from "@base-ui/react/menu";

import { MenuContent, type MenuContentProps } from "../Menu/Menu";

export const DropdownMenu = MenuPrimitive.Root;
import type { ComponentProps } from "react";

/** Non-modal: undoing a modal menu's scroll lock and backdrop flashes the webview. */
export function DropdownMenu({
modal = false,
...props
}: ComponentProps<typeof MenuPrimitive.Root>): React.JSX.Element {
return <MenuPrimitive.Root modal={modal} {...props} />;
}

export const DropdownMenuTrigger = MenuPrimitive.Trigger;

Expand Down
7 changes: 4 additions & 3 deletions packages/ui/src/components/IconButton/IconButton.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@

/* One outline serves hover, toggled, and focus, so :where() keeps the
exclusions weightless and source order alone decides. */
.ui-icon-button:hover:where(:not(:disabled, [aria-pressed="true"])) {
.ui-icon-button:is(:hover, [data-popup-open]):where(
:not(:disabled, [aria-pressed="true"])
) {
background: var(--ui-toolbar-hover-background);
outline-color: var(--ui-toolbar-hover-outline);
outline-style: dashed;
Expand All @@ -31,8 +33,7 @@
outline-style: solid;
}

/* Last: native's ring is an outline, the others border colors, so it wins */
.ui-icon-button:focus {
.ui-icon-button:focus-visible {
outline-color: var(--ui-focus-border);
outline-style: solid;
}
5 changes: 5 additions & 0 deletions packages/ui/src/components/IconButton/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function IconButton({
tooltip = label,
className,
type = "button",
onMouseDown,
...props
}: IconButtonProps): React.JSX.Element {
const button = (
Expand All @@ -35,6 +36,10 @@ export function IconButton({
type={type}
aria-label={label}
className={cx("ui-control", "ui-icon-button", className)}
onMouseDown={(event) => {
onMouseDown?.(event);
event.preventDefault();
}}
>
<Icon name={icon} />
</button>
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/components/Menu/Menu.css
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
background: var(--ui-menu-separator);
}

/* A hidden trigger otherwise drops the closing menu into the corner. */
[data-anchor-hidden] > .ui-menu {
visibility: hidden;
}

/* Modern UI menu motion (contextview.ts). The positioner supplies the
anchor-corner origin, and holds unmount until the close animation ends. */
@media (prefers-reduced-motion: no-preference) {
Expand Down
Loading
Loading