diff --git a/docs/specs/alert.md b/docs/specs/alert.md
index 0c2060e0..7a67ae09 100644
--- a/docs/specs/alert.md
+++ b/docs/specs/alert.md
@@ -177,7 +177,7 @@ Clearing behavior:
> See `docs/specs/glossary.md` for the Workspace / Window containers.
>
-> **Partially implemented.** The union *projection* is implemented as a pure function — `computeWorkspaceUnion(surfaceIds, activitySnapshot)` in `lib/src/lib/workspace-union.ts` (stage 2b). Its **surfacing** is not built yet: the standalone strip indicators (stage 3) and the VS Code per-webview union + native-chrome reflection (`docs/specs/vscode.md`). A browser Surface's user-set `todo` already round-trips to the activity store live (stage 2a), so it is counted by the projection and shows on the surface's door today.
+> **Partially implemented.** The union *projection* is a pure function — `computeWorkspaceUnion(surfaceIds, activitySnapshot)` in `lib/src/lib/workspace-union.ts` (stage 2b). **VS Code surfacing is implemented**: the editor-tab title and the bottom-panel view badge reflect the union (terminal ring/TODO only; `docs/specs/vscode.md`). Still not built: the **standalone strip indicators** (stage 3). A browser Surface's user-set `todo` round-trips to the activity store live (stage 2a), so it is counted by the projection and shows on the surface's door today.
A Workspace projects a **union status** over the attention state of the Surfaces it contains (terminal Sessions and browser Surfaces alike — see `docs/specs/glossary.md`):
diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md
index edf1c214..ad1be14e 100644
--- a/docs/specs/glossary.md
+++ b/docs/specs/glossary.md
@@ -82,7 +82,8 @@ This vocabulary is the target model; it lands in stages, so parts of it are spec
- **Implemented, live (stage 2a)** — **Pane** and **Surface** as first-class concepts, the terminal/browser surface kinds and their per-axis participation, `surfaceType` in the persisted snapshot, and the restore/resume handling that keeps browser Surfaces (no stray PTY, saved layout preserved; `docs/specs/transport.md`). A browser Surface's user-set `todo` already round-trips to the activity store live.
- **Implemented but dormant behind the `dormouse.flags.workspaces` flag (stage 2b)** — the **Workspace** and **Window** containers: `PersistedWorkspace` / `PersistedWindow` and the migration of a pre-workspace snapshot to a single "Workspace 1" (`readPersistedWindow`); the standalone persisting a `PersistedWindow` (the active Workspace's session wrapped, other Workspaces preserved — `window-persistence.ts`, wired into the standalone adapters); the in-memory workspace model and its container verbs (`createWorkspace` / `closeWorkspace` / `renameWorkspace` / `setActiveWorkspace` in `workspace-store.ts`); and the union projection (`computeWorkspaceUnion`). The flag is **off by default**, so the app persists a bare `PersistedSession` and runs exactly one implicit Workspace — behavior is byte-identical to stage 2a.
-- **Not yet implemented (stages 3–4)** — the standalone workspace **strip** and the **surfacing** of the union (strip indicators; VS Code per-webview union + native-chrome reflection); the actual Wall **mount/unmount on `switchWorkspace`** (today the model switches the active id but does not yet re-render the Wall); and lifting the cap so a user can create more than one Workspace. The switching UI is stage 3, real multi-Workspace activation is stage 4. Strip/surfacing passages in the area specs are forward-looking until then.
+- **Implemented for VS Code chrome** — each webview's union is reflected onto native chrome host-side: the editor-tab title gains a ` 🔔` / ` [TODO]` suffix and the bottom-panel view shows a presence badge (`docs/specs/vscode.md`). Terminal ring/TODO only (browser-surface TODO stays webview-local). Currently always-on in VS Code (the host can't read the localStorage flag).
+- **Not yet implemented (stages 3–4)** — the standalone workspace **strip** and its union **surfacing** (strip indicators); the actual Wall **mount/unmount on `switchWorkspace`** (today the model switches the active id but does not yet re-render the Wall); and lifting the cap so a user can create more than one Workspace. The switching UI is stage 3, real multi-Workspace activation is stage 4. Strip/surfacing passages in the standalone-facing specs are forward-looking until then.
## Layers
diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md
index 37241d8d..ac6ef5b5 100644
--- a/docs/specs/vscode.md
+++ b/docs/specs/vscode.md
@@ -107,18 +107,20 @@ PTY lifecycle, buffering, the reconnection sequence, and the full message protoc
> See `docs/specs/glossary.md` for the Workspace / Window containers and `docs/specs/alert.md` for the union status.
>
-> **Not yet implemented (VS Code side).** The webview↔Workspace mapping is the conceptual frame; VS Code already partitions PTYs per webview. The union *projection* exists as a shared primitive (`computeWorkspaceUnion`, stage 2b), but VS Code does not yet feed it per webview or reflect the result onto native chrome (see the "Not yet implemented" bullet below). The stage-2b Window persistence container is standalone-only and does not touch VS Code, which keeps one bare `PersistedSession` per webview. Stage 2a (`surfaceType` persistence, browser-surface restore/resume) is implemented and adapter-agnostic.
+> Each webview's union is computed host-side and reflected onto native chrome (see "Surfacing union status" below) — implemented. It is currently always-on: the extension host has no `localStorage` to read the standalone workspaces flag, so whether to add a host-side gate is a merge-time decision. The stage-2b Window persistence container is standalone-only and does not touch VS Code, which keeps one bare `PersistedSession` per webview.
In VS Code, **one webview is one Workspace**. The bottom-panel `WebviewView` ("Dormouse") is the default Workspace; each `dormouse.open` editor-tab `WebviewPanel` is an independent Workspace. Unlike standalone, several Workspaces are visible at once, and VS Code — not Dormouse — owns their tabs, creation, and closing: opening a Dormouse editor tab creates a Workspace and closing the tab closes it, so Dormouse adds no create/rename/close affordances here. A webview owns the terminal Sessions whose PTYs its router tracks (`ownedPtyIds`, `docs/specs/transport.md`) plus any browser surfaces rendered in it; together those are the Workspace's Surfaces.
#### Surfacing union status on native chrome
-The host computes each Workspace's native-chrome attention projection (`ringing` / `todo` / `count`) from the module-level `AlertManager`, scoped to that webview's `ownedPtyIds`. That means VS Code native chrome reflects terminal Session ring + TODO only. Browser-surface TODO remains webview-local Surface state and is not included in `panel.iconPath`, `panel.title`, or `view.badge` unless a future webview→host Surface-state channel is added; the existing `alert:state` channel is keyed by PTY-backed Session ids only.
+The host computes each webview's union (`ringing` / `todo`) from the module-level `AlertManager` scoped to that router's `ownedPtyIds` (`computeWorkspaceUnion`), delivered via the `attachRouter` `onUnion` callback. Because `ownedPtyIds` are PTY-backed terminals, **VS Code chrome reflects terminal Session ring + TODO only**; a browser surface's TODO stays webview-local until a future webview→host Surface-state channel exists (the `alert:state` channel is keyed by PTY-backed Session ids).
-- **Editor tab (`WebviewPanel`):** reassign `panel.iconPath` between normal / ringing / TODO icon variants, and optionally fold the Workspace name or a status marker into `panel.title`. Both properties are writable after creation (`title: string`, `iconPath?: Uri | { light, dark }`).
-- **Sidebar/panel view (`WebviewView`):** set `view.badge = { value: count, tooltip }` for a numeric attention badge on the activity-bar icon, visible even when the view is collapsed; `view.description` may carry status text. `view.title` is writable too but stays "Dormouse". `ViewBadge` is numeric only (no custom color or glyph), so the editor-tab icon swap carries the ringing-vs-TODO distinction the badge cannot.
+The two hosting primitives expose different chrome, so each uses what it supports, following the in-app `
[TODO]` pattern where possible:
-Reflection updates on every `AlertManager.onStateChange` for an owned PTY and on router attach/detach (a Workspace gaining or losing Sessions). When a Workspace's union is clear, the badge is set to `undefined` and the icon returns to the normal variant. Icon artwork is settled in the Storybook/asset pass.
+- **Editor tab (`WebviewPanel`):** `panel.title` gains the suffix — `Dormouse` + ` 🔔` (ringing) + ` [TODO]` (todo), both when both apply. The bell is an emoji stand-in for the in-app bell icon (a tab title is plain text); `[TODO]` is the bracketed word. `panel.iconPath` stays the Dormouse mascot. (`workspaceTitle` in `workspace-chrome.ts`.)
+- **Panel view (`WebviewView`):** a presence **badge** — `view.badge.value = 1` whenever anything owes attention, `0` to clear it (ring-vs-TODO in the tooltip; `workspaceBadge`). `view.title` is *not* used: on this single-view **bottom-panel** container VS Code shows the static container title (`viewsContainers[].title`), which has no runtime API, so the title can't carry status — the badge is the only runtime indicator that surfaces. **Clear with `0`, not `undefined`:** VS Code hides a 0-value badge but does not clear one set to `undefined` on a panel container. `view.description` stays the shell name.
+
+Reflection updates on every owned-PTY `AlertManager.onStateChange` and on `claim` / `release` (a webview gaining or losing a PTY). Source of truth: `attachRouter` `onUnion` / `notifyUnion` in `message-router.ts`; `extension.ts` (panel title), `webview-view-provider.ts` (view badge), `workspace-chrome.ts` (formatting).
### Shell selection
@@ -230,4 +232,3 @@ vscode.commands.executeCommand('setContext', 'dormouse.mode', 'normal');
- Commands not registered: `dormouse.newPane`, `closePane`, `nextPane`, `prevPane`, `enterTerminalMode`, `enterNormalMode`, `listSessions`, `reattach`
- No status bar item showing active session count
- No QuickPick for listing/reattaching PTY sessions
-- Workspace union status not yet reflected onto `panel.iconPath` / `panel.title` (editor tabs) or `view.badge` / `view.description` (bottom-panel view) — the model and chrome contract are in the Workspaces section above
diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts
index 860b174d..5782777c 100644
--- a/vscode-ext/src/extension.ts
+++ b/vscode-ext/src/extension.ts
@@ -8,6 +8,7 @@ import { getWebviewHtml } from './webview-html';
import { log } from './log';
import { mergeAlertStates, refreshSavedSessionStateFromPtys } from './session-state';
import { readPersistedSession } from '../../lib/src/lib/session-types';
+import { workspaceTitle } from './workspace-chrome';
import { resolveSelectedShell, setSelectedShellPath, getSelectedShellPath } from './shell-selection';
import type { ExtensionMessage } from './message-types';
@@ -53,6 +54,9 @@ function setupPanel(
killOnDispose: true,
savedSession: readPersistedSession(initialState),
getSelectedShell,
+ // Reflect this panel's Workspace union onto the editor-tab title
+ // (` 🔔 [TODO]`). Icon stays the Dormouse mascot.
+ onUnion: (union) => { panel.title = workspaceTitle(union); },
// Panels persist via vscode.setState() (per-panel, managed by VS Code).
// Don't write to workspaceState — that's for the WebviewView only.
});
diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts
index 1f14ff80..06d86451 100644
--- a/vscode-ext/src/message-router.ts
+++ b/vscode-ext/src/message-router.ts
@@ -9,6 +9,8 @@ import {
} from '../../lib/src/lib/terminal-protocol';
import { normalizeExternalUri } from '../../lib/src/lib/external-links';
import { VSCODE_WORKBENCH_COMMANDS } from '../../lib/src/lib/vscode-keybindings';
+import { computeWorkspaceUnion, type WorkspaceUnion } from '../../lib/src/lib/workspace-union';
+import type { ActivityState } from '../../lib/src/lib/session-activity-store';
import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state';
import type { PersistedSession } from '../../lib/src/lib/session-types';
import type { WebviewMessage, ExtensionMessage } from './message-types';
@@ -139,6 +141,10 @@ export function attachRouter(
onSaveState?: (state: unknown) => void;
savedSession?: PersistedSession | null;
getSelectedShell?: () => { shell?: string; args?: string[] } | null;
+ // Called with this webview's Workspace union status whenever it changes
+ // (owned-PTY alert state, or a PTY claimed/released). The host reflects it
+ // onto native chrome (tab title / view badge). See docs/specs/vscode.md.
+ onUnion?: (union: WorkspaceUnion) => void;
},
): vscode.Disposable {
const reconnect = options?.reconnect ?? false;
@@ -156,11 +162,24 @@ export function attachRouter(
function claim(id: string): void {
ownedPtyIds.add(id);
globalOwnedPtyIds.add(id);
+ notifyUnion();
}
function release(id: string): void {
ownedPtyIds.delete(id);
globalOwnedPtyIds.delete(id);
+ notifyUnion();
+ }
+
+ // Project this webview's Workspace union over its owned PTYs and hand it to
+ // the host so it can update native chrome. Reuses the shared projection so the
+ // rule (only terminals ring; any surface may TODO; count owing attention)
+ // matches everywhere (docs/specs/alert.md).
+ function notifyUnion(): void {
+ if (!options?.onUnion) return;
+ const states = new Map();
+ for (const id of ownedPtyIds) states.set(id, alertManager.getState(id));
+ options.onUnion(computeWorkspaceUnion(ownedPtyIds, states));
}
function resolveFlushRequest(requestId: string): void {
@@ -259,6 +278,7 @@ export function attachRouter(
notification: state.notification,
attentionDismissedRing: state.attentionDismissedRing,
} satisfies ExtensionMessage);
+ notifyUnion();
});
return () => {
diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts
index 6418ec4f..5f434433 100644
--- a/vscode-ext/src/webview-view-provider.ts
+++ b/vscode-ext/src/webview-view-provider.ts
@@ -6,6 +6,7 @@ import { getSavedSessionState, saveSessionState, mergeAlertStates } from './sess
import type { ExtensionMessage } from './message-types';
import * as ptyManager from './pty-manager';
import { resolveSelectedShell } from './shell-selection';
+import { workspaceBadge } from './workspace-chrome';
import { log } from './log';
export class DormouseViewProvider implements vscode.WebviewViewProvider {
@@ -77,6 +78,14 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider {
void saveSessionState(this.context, mergeAlertStates(state, getAlertStates()));
},
getSelectedShell: () => this.selectedShell,
+ // Reflect this view's Workspace union onto the panel container's badge.
+ // On a single-view panel container VS Code shows the static container
+ // title, so view.title can't carry the status (docs/specs/vscode.md); the
+ // badge is the only runtime indicator that surfaces here. It's a presence
+ // flag (1 when anything owes attention). Description stays the shell name.
+ onUnion: (union) => {
+ if (this.view) this.view.badge = workspaceBadge(union);
+ },
});
view.onDidDispose(() => {
diff --git a/vscode-ext/src/workspace-chrome.ts b/vscode-ext/src/workspace-chrome.ts
new file mode 100644
index 00000000..f1131892
--- /dev/null
+++ b/vscode-ext/src/workspace-chrome.ts
@@ -0,0 +1,39 @@
+import * as vscode from 'vscode';
+import type { WorkspaceUnion } from '../../lib/src/lib/workspace-union';
+
+const BASE_TITLE = 'Dormouse';
+
+/**
+ * Reflect a Workspace's union status onto a webview's native chrome title,
+ * matching the in-app ` [TODO]` pattern: append ` 🔔` when any
+ * terminal Session is ringing and ` [TODO]` when any surface is flagged. Both
+ * can appear, bell first; clear → just the base title.
+ *
+ * A tab title is plain text, so the bell is the emoji stand-in for the in-app
+ * bell icon and TODO is the bracketed word (not an emoji). See
+ * `docs/specs/vscode.md`.
+ */
+export function workspaceTitle(union: WorkspaceUnion): string {
+ let title = BASE_TITLE;
+ if (union.ringing) title += ' 🔔';
+ if (union.todo) title += ' [TODO]';
+ return title;
+}
+
+/**
+ * Presence badge for a panel/sidebar `WebviewView`. On a single-view panel
+ * container VS Code shows the static container title, so `view.title` can't
+ * carry status (see docs/specs/vscode.md) — the badge is the only runtime
+ * indicator that surfaces there. It's a presence flag, not a count: value `1`
+ * whenever anything owes attention (ringing or TODO), `0` to clear it.
+ *
+ * `0` (not `undefined`) is the clear value on purpose: VS Code hides a 0-value
+ * badge but does NOT clear one set to `undefined` on a bottom-panel container.
+ * The ring-vs-TODO detail lives in the tooltip.
+ */
+export function workspaceBadge(union: WorkspaceUnion): vscode.ViewBadge {
+ const parts: string[] = [];
+ if (union.ringing) parts.push('Ringing');
+ if (union.todo) parts.push('TODO');
+ return { value: parts.length > 0 ? 1 : 0, tooltip: parts.join(' · ') };
+}