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
31 changes: 23 additions & 8 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 Expand Up @@ -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",
Expand Down
56 changes: 55 additions & 1 deletion packages/mocks/src/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -136,6 +138,42 @@ export function agent(overrides: Partial<WorkspaceAgent> = {}): WorkspaceAgent {
};
}

/** Create a workspace app with no reported statuses. */
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,
};
}

/** Create a workspace app status with a running task message. */
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 Expand Up @@ -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> = {},
): 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<WorkspaceResource> = {},
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
20 changes: 19 additions & 1 deletion packages/shared/src/workspaces/types.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 {
Expand Down
68 changes: 68 additions & 0 deletions packages/ui/src/components/Banner/Banner.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
43 changes: 43 additions & 0 deletions packages/ui/src/components/Banner/Banner.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 => (
<div style={{ display: "grid", gap: "8px", width: "280px" }}>
<Banner>A new version of the agent is available.</Banner>
<Banner
severity="warning"
actions={[
{ label: "Retry", onClick: () => undefined },
{ label: "Details", onClick: () => undefined },
]}
>
Couldn't refresh. Showing results from 5 minutes ago.
</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 = {};

export const MouseFocus: Story = {
parameters: { pseudo: { focus: [".ui-banner__action"] } },
};

export const MouseFocusStable: Story = {
...MouseFocus,
globals: { uiStyle: "stable" },
};
61 changes: 61 additions & 0 deletions packages/ui/src/components/Banner/Banner.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<BannerSeverity, CodiconName>> = {
info: "info",
warning: "alert",
error: "error",
};

export interface BannerAction {
readonly label: string;
readonly onClick: () => void;
}

export interface BannerProps extends Omit<ComponentProps<"div">, "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 (
<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}
{actions.map(({ label, onClick }) => (
<button
key={label}
type="button"
className="ui-banner__action"
onClick={onClick}
>
{label}
</button>
))}
</div>
</div>
);
}
Loading