diff --git a/CHANGELOG.md b/CHANGELOG.md
index b10063021..dc5a7df93 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,14 @@
## Unreleased
+### Added
+
+- Show active deployment announcements in a megaphone status bar item, with a
+ popup for announcements you haven't seen yet (respecting
+ `coder.disableNotifications`; suppressed announcements highlight the status
+ bar item instead). A new **Coder: View Announcements** command opens the full
+ messages in a markdown preview.
+
### Fixed
- Apply a 60-second default timeout to REST requests, so requests hung on a
diff --git a/package.json b/package.json
index 87757a4f5..d8c4eb4ef 100644
--- a/package.json
+++ b/package.json
@@ -460,6 +460,12 @@
"title": "Coder: Export Telemetry",
"icon": "$(save)"
},
+ {
+ "command": "coder.viewAnnouncements",
+ "title": "View Announcements",
+ "category": "Coder",
+ "icon": "$(megaphone)"
+ },
{
"command": "coder.openAppStatus",
"title": "Open App Status",
@@ -588,6 +594,10 @@
"command": "coder.exportTelemetry",
"when": "true"
},
+ {
+ "command": "coder.viewAnnouncements",
+ "when": "coder.authenticated"
+ },
{
"command": "coder.openAppStatus",
"when": "false"
diff --git a/src/announcements/banners.ts b/src/announcements/banners.ts
new file mode 100644
index 000000000..066f35098
--- /dev/null
+++ b/src/announcements/banners.ts
@@ -0,0 +1,122 @@
+import { createHash } from "node:crypto";
+
+import type {
+ AppearanceConfig,
+ BannerConfig,
+} from "coder/site/src/api/typesGenerated";
+
+export interface Announcement {
+ readonly message: string;
+ /** Fingerprint used to track which banners have been surfaced. */
+ readonly key: string;
+ /** Admin-configured hex color (e.g. "#d32f2f"), if set and valid. */
+ readonly backgroundColor?: string;
+}
+
+/**
+ * Falls back to the deprecated service_banner only when announcement_banners
+ * is absent, not merely empty. Duplicate messages collapse into one.
+ */
+export function normalizeBanners(
+ appearance: AppearanceConfig,
+): readonly Announcement[] {
+ const banners = appearance.announcement_banners ?? [
+ appearance.service_banner,
+ ];
+ const announcements = banners
+ .map((banner) => toAnnouncement(banner))
+ .filter((banner): banner is Announcement => banner !== undefined);
+ return [...new Map(announcements.map((a) => [a.key, a])).values()];
+}
+
+/** Status bar label: a megaphone plus the count when there's more than one. */
+export function statusText(count: number): string {
+ return `$(megaphone) Coder${count === 1 ? "" : ` ${count}`}`;
+}
+
+/** Hover is a quick glance; cap the list before pointing at the full preview. */
+const HOVER_BANNER_LIMIT = 5;
+
+/** Hard-breaks lines in one paragraph instead of a list: no indent, no implied order. */
+export function hoverMarkdown(banners: readonly Announcement[]): string {
+ const shown = banners.slice(0, HOVER_BANNER_LIMIT);
+ const remaining = banners.length - shown.length;
+ const list = shown.map((banner) => banner.message).join(" \n");
+ return remaining > 0
+ ? `${list}\n\n+${remaining} more (click to view all)`
+ : list;
+}
+
+/** Full markdown for the preview tab: each banner boxed in its own styled div. */
+export function previewMarkdown(banners: readonly Announcement[]): string {
+ const boxes = banners.map((banner) => announcementBox(banner));
+ return [BOX_STYLE_RESET, ...boxes].join("\n\n");
+}
+
+/**
+ *
only gets bottom margin, skewing the box's padding. Custom-colored
+ * boxes also need links to inherit the text color instead of theme-blue.
+ */
+const BOX_STYLE_RESET =
+ "";
+
+/** Reuses the blockquote theme colors so the box still reads as native VS Code. */
+const BOX_STYLE =
+ "padding: 10px 14px; margin-bottom: 8px; border-radius: 4px; " +
+ "background: var(--vscode-textBlockQuote-background); " +
+ "border-left: 4px solid var(--vscode-textBlockQuote-border);";
+
+/** A blank line around the content keeps it as real markdown, not opaque HTML. */
+function announcementBox(banner: Announcement): string {
+ // Admin colors stay fixed across themes, like coder/coder's dashboard.
+ const className = banner.backgroundColor
+ ? "coder-announcement coder-announcement-custom-color"
+ : "coder-announcement";
+ const colorOverride = banner.backgroundColor
+ ? `background: ${banner.backgroundColor}; border-left-color: ${banner.backgroundColor}; color: ${readableForegroundColor(banner.backgroundColor)};`
+ : "";
+ return [
+ `
`,
+ "",
+ banner.message,
+ "",
+ "
",
+ ].join("\n");
+}
+
+/** Mirrors coder/coder's dashboard heuristic so banner colors read the same way. */
+function readableForegroundColor(hexColor: string): string {
+ const r = parseInt(hexColor.slice(1, 3), 16);
+ const g = parseInt(hexColor.slice(3, 5), 16);
+ const b = parseInt(hexColor.slice(5, 7), 16);
+ const yiq = (r * 299 + g * 587 + b * 114) / 1000;
+ return yiq >= 128 ? "#000" : "#fff";
+}
+
+/** Plain text, no markdown, so it's always a generic count, never banner content. */
+export function popupMessage(banners: readonly Announcement[]): string {
+ return banners.length === 1
+ ? "Coder has a new deployment announcement."
+ : `Coder has ${banners.length} new deployment announcements.`;
+}
+
+const HEX_COLOR = /^#[0-9a-f]{6}$/i;
+
+function toAnnouncement(
+ banner: BannerConfig | undefined,
+): Announcement | undefined {
+ const message = banner?.message?.trim();
+ if (!banner?.enabled || !message) {
+ return undefined;
+ }
+ const backgroundColor =
+ banner.background_color && HEX_COLOR.test(banner.background_color)
+ ? banner.background_color
+ : undefined;
+ return { message, key: bannerKey(message), backgroundColor };
+}
+
+function bannerKey(message: string): string {
+ return createHash("sha256").update(message).digest("hex").slice(0, 16);
+}
diff --git a/src/announcements/manager.ts b/src/announcements/manager.ts
new file mode 100644
index 000000000..08047b7e3
--- /dev/null
+++ b/src/announcements/manager.ts
@@ -0,0 +1,283 @@
+import * as vscode from "vscode";
+
+import { errToStr } from "../api/api-helper";
+import { withProgress } from "../progress";
+import { areNotificationsDisabled } from "../settings/notifications";
+import { createStatusBarItem } from "../util/statusBar";
+
+import {
+ type Announcement,
+ hoverMarkdown,
+ normalizeBanners,
+ popupMessage,
+ previewMarkdown,
+ statusText,
+} from "./banners";
+import { AnnouncementsPreview } from "./preview";
+
+import type { CoderApi } from "../api/coderApi";
+import type { SecretsManager } from "../core/secretsManager";
+import type { SessionState } from "../deployment/sessionStore";
+import type { Logger } from "../logging/logger";
+
+/** Background poll interval; sign-in and manual refresh happen immediately either way. */
+export const REFRESH_INTERVAL_MS = 30 * 60 * 1000;
+const VIEW_ACTION = "View";
+
+interface RefreshOptions {
+ readonly notify?: boolean;
+ readonly showErrors?: boolean;
+}
+
+/** Fetches, tracks, and surfaces Coder deployment announcement banners. */
+export class AnnouncementManager implements vscode.Disposable {
+ private readonly statusBarItem = createStatusBarItem("announcements");
+ private readonly sessionChangeDisposable: vscode.Disposable;
+ private readonly preview = new AnnouncementsPreview();
+ #banners: readonly Announcement[] = [];
+ private fetchGeneration = 0;
+ private refreshTimeout: NodeJS.Timeout | undefined;
+ private disposed = false;
+ private loadingAnnouncements: Promise | undefined;
+
+ public constructor(
+ private readonly client: Pick,
+ private readonly sessionState: SessionState,
+ private readonly secretsManager: SecretsManager,
+ private readonly logger: Logger,
+ ) {
+ this.statusBarItem.command = "coder.viewAnnouncements";
+ this.sessionChangeDisposable = this.sessionState.onDidChange(() => {
+ this.onSessionChange();
+ });
+ this.onSessionChange();
+ }
+
+ /** Refreshes the banners; resolves false when the refresh failed. */
+ public async refresh(options: RefreshOptions = {}): Promise {
+ if (this.disposed) {
+ return false;
+ }
+ this.cancelRefresh();
+ try {
+ await this.fetch(options);
+ return true;
+ } catch (error) {
+ this.logger.warn("Failed to refresh Coder announcements", error);
+ if (options.showErrors) {
+ void vscode.window.showErrorMessage(
+ `Failed to refresh Coder announcements: ${errToStr(error)}`,
+ );
+ }
+ return false;
+ } finally {
+ this.scheduleRefresh();
+ }
+ }
+
+ /** Concurrent calls share one in-flight load, shown via a window progress indicator. */
+ public showAnnouncements(): Promise {
+ this.loadingAnnouncements ??= this.loadAnnouncements().finally(() => {
+ this.loadingAnnouncements = undefined;
+ });
+ return this.loadingAnnouncements;
+ }
+
+ private async loadAnnouncements(): Promise {
+ await withProgress(
+ {
+ location: vscode.ProgressLocation.Window,
+ title: "Loading Coder announcements…",
+ },
+ async () => {
+ const refreshed = await this.refresh({
+ notify: false,
+ showErrors: true,
+ });
+ if (this.banners.length > 0) {
+ await this.showAnnouncementsPreview();
+ } else if (refreshed) {
+ // On failure the error popup is the whole story.
+ void vscode.window.showInformationMessage(
+ "No active Coder announcements.",
+ );
+ }
+ },
+ );
+ }
+
+ /** Releases listeners/timers and hides the status bar item. */
+ public dispose(): void {
+ this.disposed = true;
+ this.cancelRefresh();
+ this.sessionChangeDisposable.dispose();
+ this.preview.dispose();
+ this.banners = [];
+ this.statusBarItem.dispose();
+ }
+
+ /** Re-fetches on sign-in; clears the display on sign-out. */
+ private onSessionChange(): void {
+ this.cancelRefresh();
+ if (this.sessionState.current.kind !== "signedIn") {
+ this.banners = [];
+ return;
+ }
+ // Leaves current banners visible while refresh() below confirms them.
+ void this.refresh({ notify: true });
+ }
+
+ /** Fetches the latest banners and reconciles popup/surfaced/attention state. */
+ private async fetch(options: RefreshOptions): Promise {
+ const session = this.sessionState.current;
+ if (session.kind !== "signedIn") {
+ this.banners = [];
+ return;
+ }
+
+ const generation = ++this.fetchGeneration;
+ const appearance = await this.client.getAppearance();
+ // A newer fetch or a session change supersedes this response.
+ if (
+ this.disposed ||
+ generation !== this.fetchGeneration ||
+ this.sessionState.current !== session
+ ) {
+ return;
+ }
+ const banners = normalizeBanners(appearance);
+ this.banners = banners;
+
+ const surfacedKeys = new Set(
+ this.secretsManager.getSurfacedBanners(session.deployment.safeHostname),
+ );
+ const newBanners = banners.filter(
+ (banner) => !surfacedKeys.has(banner.key),
+ );
+ if (newBanners.length === 0) {
+ this.setAttentionIndicator(false);
+ return;
+ }
+ // Manual refreshes surface via the preview instead.
+ if (!options.notify) {
+ return;
+ }
+ if (areNotificationsDisabled(vscode.workspace.getConfiguration())) {
+ // Marks banners as surfaced, not read; suppressed ones notify later.
+ this.setAttentionIndicator(true);
+ return;
+ }
+ this.showPopup(newBanners);
+ await this.markSurfaced(banners, surfacedKeys);
+ }
+
+ /** Marks banners surfaced and clears the attention color; logs storage failures. */
+ private async markSurfaced(
+ banners: readonly Announcement[],
+ knownSurfacedKeys?: ReadonlySet,
+ ): Promise {
+ const session = this.sessionState.current;
+ if (session.kind !== "signedIn") {
+ return;
+ }
+ try {
+ const surfacedKeys =
+ knownSurfacedKeys ??
+ new Set(
+ this.secretsManager.getSurfacedBanners(
+ session.deployment.safeHostname,
+ ),
+ );
+ const merged = new Set([
+ ...surfacedKeys,
+ ...banners.map((banner) => banner.key),
+ ]);
+ if (merged.size > surfacedKeys.size) {
+ await this.secretsManager.setSurfacedBanners(
+ session.deployment.safeHostname,
+ [...merged],
+ );
+ }
+ this.setAttentionIndicator(false);
+ } catch (error) {
+ this.logger.warn("Failed to mark Coder announcements as surfaced", error);
+ }
+ }
+
+ /** Toggles a warning background so unsurfaced banners stand out in the status bar. */
+ private setAttentionIndicator(hasUnsurfaced: boolean): void {
+ this.statusBarItem.backgroundColor = hasUnsurfaced
+ ? new vscode.ThemeColor("statusBarItem.warningBackground")
+ : undefined;
+ }
+
+ /** The setter syncs the status bar so the two cannot drift apart. */
+ private get banners(): readonly Announcement[] {
+ return this.#banners;
+ }
+
+ private set banners(banners: readonly Announcement[]) {
+ this.#banners = banners;
+ if (banners.length === 0) {
+ this.statusBarItem.hide();
+ return;
+ }
+ this.statusBarItem.text = statusText(banners.length);
+ this.statusBarItem.tooltip = new vscode.MarkdownString(
+ hoverMarkdown(banners),
+ );
+ this.statusBarItem.show();
+ }
+
+ /** Non-modal messages may never settle, so chain instead of awaiting. */
+ private showPopup(banners: readonly Announcement[]): void {
+ vscode.window
+ .showInformationMessage(popupMessage(banners), VIEW_ACTION)
+ .then((action) => {
+ if (action === VIEW_ACTION) {
+ // Banners are seconds old; skip the refetch.
+ void this.showAnnouncementsPreview();
+ }
+ });
+ }
+
+ /** Skips the preview if banners were cleared (e.g. sign-out) before or during marking. */
+ private async showAnnouncementsPreview(): Promise {
+ if (this.banners.length === 0) {
+ return;
+ }
+ await this.markSurfaced(this.banners);
+ if (this.banners.length === 0) {
+ return;
+ }
+ try {
+ await this.preview.show(previewMarkdown(this.banners));
+ } catch (error) {
+ this.logger.warn("Failed to show Coder announcements preview", error);
+ void vscode.window.showErrorMessage(
+ `Failed to show Coder announcements: ${errToStr(error)}`,
+ );
+ }
+ }
+
+ private scheduleRefresh(): void {
+ if (
+ this.disposed ||
+ this.refreshTimeout ||
+ this.sessionState.current.kind !== "signedIn"
+ ) {
+ return;
+ }
+ this.refreshTimeout = setTimeout(() => {
+ this.refreshTimeout = undefined;
+ void this.refresh({ notify: true });
+ }, REFRESH_INTERVAL_MS);
+ }
+
+ private cancelRefresh(): void {
+ if (this.refreshTimeout) {
+ clearTimeout(this.refreshTimeout);
+ this.refreshTimeout = undefined;
+ }
+ }
+}
diff --git a/src/announcements/preview.ts b/src/announcements/preview.ts
new file mode 100644
index 000000000..f0e89d923
--- /dev/null
+++ b/src/announcements/preview.ts
@@ -0,0 +1,28 @@
+import * as vscode from "vscode";
+
+const SCHEME = "coder-announcements";
+// No .md extension: the preview renders content as markdown regardless.
+const URI = vscode.Uri.parse(`${SCHEME}:Coder Announcements`);
+
+/** Shows deployment announcements in VS Code's built-in Markdown Preview. */
+export class AnnouncementsPreview implements vscode.Disposable {
+ private markdown = "";
+ private readonly changeEmitter = new vscode.EventEmitter();
+ private readonly providerDisposable =
+ vscode.workspace.registerTextDocumentContentProvider(SCHEME, {
+ onDidChange: this.changeEmitter.event,
+ provideTextDocumentContent: () => this.markdown,
+ });
+
+ /** Opens (or refreshes, if already open) the preview tab with new content. */
+ public async show(markdown: string): Promise {
+ this.markdown = markdown;
+ this.changeEmitter.fire(URI);
+ await vscode.commands.executeCommand("markdown.showPreview", URI);
+ }
+
+ public dispose(): void {
+ this.changeEmitter.dispose();
+ this.providerDisposable.dispose();
+ }
+}
diff --git a/src/core/commandManager.ts b/src/core/commandManager.ts
index 723a4fe21..c9f721c3c 100644
--- a/src/core/commandManager.ts
+++ b/src/core/commandManager.ts
@@ -21,6 +21,7 @@ export const CODER_COMMAND_IDS = [
"coder.refreshWorkspaces",
"coder.viewLogs",
"coder.exportTelemetry",
+ "coder.viewAnnouncements",
"coder.searchMyWorkspaces",
"coder.searchSharedWorkspaces",
"coder.searchAllWorkspaces",
diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts
index 5255c122c..b5cbc7419 100644
--- a/src/core/secretsManager.ts
+++ b/src/core/secretsManager.ts
@@ -13,6 +13,7 @@ import type { Logger } from "../logging/logger";
const SESSION_KEY_PREFIX = "coder.session.";
const OAUTH_CLIENT_PREFIX = "coder.oauth.client.";
const DEPLOYMENT_ACCESS_PREFIX = "coder.access.";
+const SURFACED_BANNERS_PREFIX = "coder.surfacedBanners.";
type SecretKeyPrefix = typeof SESSION_KEY_PREFIX | typeof OAUTH_CLIENT_PREFIX;
@@ -29,6 +30,8 @@ export type CurrentDeploymentState = z.infer<
typeof CurrentDeploymentStateSchema
>;
+const SurfacedBannersSchema = z.array(z.string());
+
/**
* OAuth token data stored alongside session auth.
* When present, indicates the session is authenticated via OAuth.
@@ -235,6 +238,7 @@ export class SecretsManager {
await Promise.all([
this.clearSessionAuth(safeHostname),
this.clearOAuthClientRegistration(safeHostname),
+ this.clearSurfacedBanners(safeHostname),
this.memento.update(
`${DEPLOYMENT_ACCESS_PREFIX}${safeHostname}`,
undefined,
@@ -242,6 +246,30 @@ export class SecretsManager {
]);
}
+ public getSurfacedBanners(safeHostname: string): string[] {
+ const raw = this.memento.get(
+ `${SURFACED_BANNERS_PREFIX}${safeHostname}`,
+ );
+ const result = SurfacedBannersSchema.safeParse(raw);
+ return result.success ? result.data : [];
+ }
+
+ public async setSurfacedBanners(
+ safeHostname: string,
+ bannerKeys: readonly string[],
+ ): Promise {
+ await this.memento.update(`${SURFACED_BANNERS_PREFIX}${safeHostname}`, [
+ ...bannerKeys,
+ ]);
+ }
+
+ private async clearSurfacedBanners(safeHostname: string): Promise {
+ await this.memento.update(
+ `${SURFACED_BANNERS_PREFIX}${safeHostname}`,
+ undefined,
+ );
+ }
+
/**
* Get all known hostnames, ordered by most recently accessed.
* Derives the list from actual session secrets stored.
diff --git a/src/extension.ts b/src/extension.ts
index e097daa87..541331562 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -6,6 +6,7 @@ import { createRequire } from "node:module";
import * as path from "node:path";
import * as vscode from "vscode";
+import { AnnouncementManager } from "./announcements/manager";
import { errToStr } from "./api/api-helper";
import { AuthInterceptor } from "./api/authInterceptor";
import { CoderApi } from "./api/coderApi";
@@ -162,6 +163,14 @@ async function doActivate(
);
ctx.subscriptions.push(deploymentManager);
+ const announcementManager = new AnnouncementManager(
+ client,
+ deploymentManager.session,
+ secretsManager,
+ output,
+ );
+ ctx.subscriptions.push(announcementManager);
+
const myWorkspacesProvider = new WorkspaceProvider(
WorkspaceQuery.Mine,
client,
@@ -328,6 +337,10 @@ async function doActivate(
"coder.exportTelemetry",
commands.exportTelemetry.bind(commands),
);
+ commandManager.register(
+ "coder.viewAnnouncements",
+ announcementManager.showAnnouncements.bind(announcementManager),
+ );
commandManager.register("coder.searchMyWorkspaces", async () =>
showTreeViewSearch(MY_WORKSPACES_TREE_ID),
);
diff --git a/src/remote/remote.ts b/src/remote/remote.ts
index 14f7b4609..b08bc8d9f 100644
--- a/src/remote/remote.ts
+++ b/src/remote/remote.ts
@@ -43,6 +43,7 @@ import {
type AuthorityParts,
parseRemoteAuthority,
} from "../util/authority";
+import { createStatusBarItem } from "../util/statusBar";
import { vscodeProposed } from "../vscodeProposed";
import { WorkspaceMonitor } from "../workspace/workspaceMonitor";
@@ -1080,10 +1081,7 @@ export class Remote {
agent: WorkspaceAgent,
client: CoderApi,
): Promise {
- const statusBarItem = vscode.window.createStatusBarItem(
- "agentMetadata",
- vscode.StatusBarAlignment.Left,
- );
+ const statusBarItem = createStatusBarItem("agentMetadata");
const agentWatcher = await createAgentMetadataWatcher(agent.id, client);
diff --git a/src/remote/sshProcess.ts b/src/remote/sshProcess.ts
index 58d0fee49..3b4818996 100644
--- a/src/remote/sshProcess.ts
+++ b/src/remote/sshProcess.ts
@@ -6,6 +6,7 @@ import * as vscode from "vscode";
import { SshTelemetry, type ProcessLossCause } from "../instrumentation/ssh";
import { findPort } from "../util";
import { cleanupFiles } from "../util/fileCleanup";
+import { createStatusBarItem } from "../util/statusBar";
import { NetworkStatusReporter } from "./networkStatus";
import {
@@ -140,10 +141,7 @@ export class SshProcessMonitor implements vscode.Disposable {
networkPollInterval: options.networkPollInterval ?? 3000,
};
this.telemetry = new SshTelemetry(options.telemetry);
- this.statusBarItem = vscode.window.createStatusBarItem(
- vscode.StatusBarAlignment.Left,
- 1000,
- );
+ this.statusBarItem = createStatusBarItem("networkStatus");
this.reporter = new NetworkStatusReporter(this.statusBarItem);
}
diff --git a/src/util/statusBar.ts b/src/util/statusBar.ts
new file mode 100644
index 000000000..c39d99339
--- /dev/null
+++ b/src/util/statusBar.ts
@@ -0,0 +1,44 @@
+import * as vscode from "vscode";
+
+interface StatusBarItemSpec {
+ /** Omitted for pre-registry items; a new id orphans hide preferences. */
+ readonly id?: string;
+ readonly name: string;
+ readonly priority: number;
+}
+
+/** All Coder status bar items; higher priority renders further left. */
+const STATUS_BAR_ITEMS = {
+ networkStatus: { name: "Coder Network Status", priority: 1000 },
+ workspaceUpdate: { name: "Coder Workspace Update", priority: 999 },
+ announcements: {
+ id: "announcements",
+ name: "Coder Announcements",
+ priority: 998,
+ },
+ // Priority 0 preserves its pre-registry placement.
+ agentMetadata: {
+ id: "agentMetadata",
+ name: "Coder Agent Metadata",
+ priority: 0,
+ },
+} satisfies Record;
+
+/** Creates a registered status bar item with its id, name, and priority. */
+export function createStatusBarItem(
+ key: keyof typeof STATUS_BAR_ITEMS,
+): vscode.StatusBarItem {
+ const { id, name, priority }: StatusBarItemSpec = STATUS_BAR_ITEMS[key];
+ const item = id
+ ? vscode.window.createStatusBarItem(
+ id,
+ vscode.StatusBarAlignment.Left,
+ priority,
+ )
+ : vscode.window.createStatusBarItem(
+ vscode.StatusBarAlignment.Left,
+ priority,
+ );
+ item.name = name;
+ return item;
+}
diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts
index 150fac1e9..ff6d23da4 100644
--- a/src/workspace/workspaceMonitor.ts
+++ b/src/workspace/workspaceMonitor.ts
@@ -11,6 +11,7 @@ import {
areNotificationsDisabled,
areUpdateNotificationsDisabled,
} from "../settings/notifications";
+import { createStatusBarItem } from "../util/statusBar";
import { vscodeProposed } from "../vscodeProposed";
import type { CoderApi } from "../api/coderApi";
@@ -64,11 +65,7 @@ export class WorkspaceMonitor implements vscode.Disposable {
);
this.latestWorkspace = workspace;
- const statusBarItem = vscode.window.createStatusBarItem(
- vscode.StatusBarAlignment.Left,
- 999,
- );
- statusBarItem.name = "Coder Workspace Update";
+ const statusBarItem = createStatusBarItem("workspaceUpdate");
statusBarItem.text = "$(fold-up) Update Workspace";
statusBarItem.command = "coder.workspace.update";
diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts
index 03dc548d2..55cd482e7 100644
--- a/test/mocks/vscode.runtime.ts
+++ b/test/mocks/vscode.runtime.ts
@@ -148,9 +148,10 @@ export const window = {
window.activeColorTheme = { kind };
onDidChangeActiveColorTheme.fire({ kind });
},
- showInformationMessage: vi.fn(),
- showWarningMessage: vi.fn(),
- showErrorMessage: vi.fn(),
+ // The real API always returns a Thenable, so callers may chain .then().
+ showInformationMessage: vi.fn(() => Promise.resolve(undefined)),
+ showWarningMessage: vi.fn(() => Promise.resolve(undefined)),
+ showErrorMessage: vi.fn(() => Promise.resolve(undefined)),
showQuickPick: vi.fn(),
showInputBox: vi.fn(),
showSaveDialog: vi.fn(),
@@ -187,6 +188,7 @@ export const workspace = {
readDirectory: vi.fn(),
},
openTextDocument: vi.fn(),
+ registerTextDocumentContentProvider: vi.fn(() => ({ dispose: vi.fn() })),
onDidChangeConfiguration: onDidChangeConfiguration.event,
onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event,
diff --git a/test/unit/announcements/banners.test.ts b/test/unit/announcements/banners.test.ts
new file mode 100644
index 000000000..a09e885ce
--- /dev/null
+++ b/test/unit/announcements/banners.test.ts
@@ -0,0 +1,210 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ hoverMarkdown,
+ normalizeBanners,
+ popupMessage,
+ previewMarkdown,
+ statusText,
+} from "@/announcements/banners";
+
+import type {
+ AppearanceConfig,
+ BannerConfig,
+} from "coder/site/src/api/typesGenerated";
+
+function banner(overrides: Partial = {}): BannerConfig {
+ return {
+ enabled: true,
+ message: "Maintenance tonight",
+ background_color: "#004852",
+ ...overrides,
+ };
+}
+
+function appearance(
+ overrides: Partial = {},
+): AppearanceConfig {
+ return {
+ application_name: "Coder",
+ logo_url: "",
+ docs_url: "",
+ service_banner: { enabled: false },
+ announcement_banners: [],
+ ...overrides,
+ };
+}
+
+function announcements(...messages: string[]) {
+ return normalizeBanners(
+ appearance({
+ announcement_banners: messages.map((message) => banner({ message })),
+ }),
+ );
+}
+
+describe("normalizeBanners", () => {
+ it("returns active announcement banners trimmed", () => {
+ const banners = normalizeBanners(
+ appearance({
+ announcement_banners: [
+ banner({ message: " First " }),
+ banner({ enabled: false, message: "Disabled" }),
+ banner({ message: " " }),
+ banner({ message: "Second" }),
+ ],
+ }),
+ );
+
+ expect(banners.map((banner) => banner.message)).toEqual([
+ "First",
+ "Second",
+ ]);
+ });
+
+ it("ignores the service banner that modern deployments mirror from the first announcement", () => {
+ const banners = normalizeBanners(
+ appearance({
+ service_banner: banner(),
+ announcement_banners: [banner(), banner({ message: "Second" })],
+ }),
+ );
+
+ expect(banners.map((banner) => banner.message)).toEqual([
+ "Maintenance tonight",
+ "Second",
+ ]);
+ });
+
+ it("falls back to the service banner on older deployments", () => {
+ expect(normalizeBanners({} as AppearanceConfig)).toEqual([]);
+ expect(
+ normalizeBanners({ service_banner: banner() } as AppearanceConfig),
+ ).toMatchObject([{ message: "Maintenance tonight" }]);
+ });
+
+ it("keys follow the message and survive reordering", () => {
+ const [first, second] = announcements("First", "Second");
+ const [reorderedSecond, reorderedFirst] = announcements("Second", "First");
+
+ expect(reorderedFirst.key).toBe(first.key);
+ expect(reorderedSecond.key).toBe(second.key);
+ expect(first.key).not.toBe(second.key);
+ });
+
+ it("collapses banners with identical content", () => {
+ const banners = announcements("Same message", "Same message");
+
+ expect(banners).toHaveLength(1);
+ });
+
+ it("shows nothing when a modern deployment reports no announcements", () => {
+ const banners = normalizeBanners(
+ appearance({
+ service_banner: banner(),
+ announcement_banners: [],
+ }),
+ );
+
+ expect(banners).toEqual([]);
+ });
+});
+
+describe("banner copy", () => {
+ it("formats status bar text and the hover markdown", () => {
+ const banners = announcements("First", "Second");
+
+ expect(statusText(1)).toBe("$(megaphone) Coder");
+ expect(statusText(2)).toBe("$(megaphone) Coder 2");
+ expect(hoverMarkdown(banners)).toBe("First \nSecond");
+ });
+
+ it("caps the hover list and points at the full preview", () => {
+ const banners = announcements("A", "B", "C", "D", "E", "F", "G");
+
+ expect(hoverMarkdown(banners)).toBe(
+ "A \nB \nC \nD \nE\n\n+2 more (click to view all)",
+ );
+ });
+
+ it("boxes each announcement in its own styled div, blank-line separated so markdown still renders inside", () => {
+ const banners = announcements("First");
+
+ expect(previewMarkdown(banners)).toMatch(
+ /^