Skip to content

feat(dashboard): open in workspace tab - #166

Open
andyshinn wants to merge 19 commits into
phuryn:mainfrom
andyshinn:ashinn/open-in-workspace-tab
Open

feat(dashboard): open in workspace tab#166
andyshinn wants to merge 19 commits into
phuryn:mainfrom
andyshinn:ashinn/open-in-workspace-tab

Conversation

@andyshinn

Copy link
Copy Markdown

This adds a new command and some settings around opening the dashboard in a workspace editor tab instead of the left sidebar navigation.

Screen.Recording.2026-07-28.at.8.32.07.PM.mov

New settings additions:

  • claudeUsage.openLocation (sidebar | editor, default sidebar) — where the dashboard opens
  • claudeUsage.collapseSidebarOnOpenInEditor (default false) — collapse the sidebar once the tab is up
  • Claude Usage: Open Dashboard in Editor Tab command, plus a pop-out button in the sidebar's title bar

I also restored a real vector version of the SVG that was removed in 3d53f6d for some reason. If that was unintentional we can place the original SVG icon back to vector and remove my vector one. I needed the vector to create the WOFF font for the editor tab icon.

Closes #164

andyshinn and others added 19 commits July 26, 2026 01:37
Adds claudeUsage.openLocation (sidebar|editor) and
claudeUsage.collapseSidebarOnOpenInEditor, plus a
claudeUsage.openInEditor command and sidebar title-bar button.

Key design decision: the active surface is derived
(panelExists ? "editor" : openLocation) rather than stored, which
guarantees exactly one live dashboard instance by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven TDD tasks: extract webview-html, add pure routing predicates, sidebar
placeholder mode, DashboardPanel singleton, host wiring + manifest, optional
sidebar collapse, then docs and the v1.6.0 bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tton

Both the sidebar and the forthcoming editor panel render identical HTML, so
renderHtml/escapeHtml/makeNonce move to their own vscode-free module. The
status pane's hardcoded 'Retry' -> claudeUsage.open button becomes a
caller-supplied WebviewAction, which the editor panel needs to point its own
retry at claudeUsage.openInEditor.
…tion

Isolates the whole feature's decision table in a vscode-free module so it can
be unit-tested. deriveActiveSurface() encodes the single-instance invariant as
a derivation (panelExists ? editor : setting) rather than stored state.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
setPlaceholder() renders the 'dashboard is in an editor tab' pane; isVisible()
gates sidebar collapse so we never close the Explorer by mistake.
Mirrors DashboardSidebar's API so the host can drive either surface uniformly.
retainContextWhenHidden keeps dashboard state across tab switches; no
serializer, so the tab closes on window reload by design.
reveal()/render() now no-op once the panel is disposed, matching
DashboardSidebar's !this.view guard. dispose() is idempotent. Fixes a gap
where a stale DashboardPanel reference held across an await (e.g. during
server.start()) would throw if the user closed the tab in the meantime.
…mand

Splits openDashboard() into ensureServer() plus two reveal paths, so starting
the Python server is no longer welded to revealing the sidebar. The sidebar
container reveal now lives only in showSidebar() — leaving it on the shared
path would pop open the very panel the user is escaping.

The active surface is derived (panelExists ? editor : setting), so exactly one
dashboard instance is live in every combination of setting and tab state.
Q1: showEditorTab() re-derives the surface after ensureServer() settles
instead of trusting a possibly-stale panel reference, so closing the tab
mid-cold-start no longer strands the sidebar on a blank status.

Q2: openDashboard() routes on the derived activeSurface() instead of the raw
openLocation() setting, so claudeUsage.open (and both Retry actions that call
it) no longer force the sidebar open when an editor tab already owns the
dashboard — the exact bug this feature exists to avoid.

Q3/Q4: broadcastError() clears serverUrl and both broadcast helpers fall back
to the sidebar whenever no panel exists, instead of silently dropping status/
error updates when openLocation is "editor" but no tab has been created yet.
The !spawnArgs failure now also raises a toast, matching the other two early
returns.

M1: doStartup()'s try/catch now covers resolveStablePort()/workspaceState
too, so a port-probe rejection routes through broadcastError() and resolves
to undefined like every other failure, instead of escaping as an unhandled
rejection.
Declares the setting and wires it to the existing shouldCollapseSidebar()
predicate, replacing maybeCollapseSidebar()'s Task-5 inlined condition.
Guarded on the Claude Usage view actually being the visible container,
since workbench.action.closeSidebar closes whatever is showing — without
the guard, popping out with the Explorer open would close the Explorer.

Also gates the collapse on how the tab was opened. showEditorTab() now
takes a fromSidebarReveal flag: explicit invocations (the openInEditor
command, its title-bar button, claudeUsage.open routing to the tab, and
restart()) always collapse when enabled and visible, but the
onSidebarShown() handoff only collapses when openLocation is "editor".
Without that split, turning the setting on with openLocation: "sidebar"
and a tab already open would make revealing the sidebar hand off to the
tab and immediately collapse the sidebar again — leaving the "Show tab"
placeholder permanently unreachable. shouldCollapseSidebar() carries the
new fromSidebarReveal/openLocation gate so it stays unit-testable
alongside the sibling open-target.ts decisions; test/open-target.test.ts
covers the new handoff-vs-explicit cases.

Also fixes rescan(): it used the same optional-chain-no-op pattern that
broadcastStatus/broadcastError were fixed for in Task 5
(this.panel?.refresh() silently doing nothing when openLocation is
"editor" but no tab has been created yet). Branches on this.panel
truthiness instead, so claudeUsage.rescan falls back to refreshing the
sidebar when it's the only surface that actually exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New user-visible feature, so a minor bump. scanner.VERSION, the CHANGELOG
heading and package.json move together — tests/test_version.py enforces it.
The heading stays TBD until the maintainer merges DEV to main.

The collapse-setting wording in the CHANGELOG and README diverges from the
task brief: the brief's text predates two review-driven changes that gate
collapseSidebarOnOpenInEditor by call site (explicit pop-outs always
collapse; the activity-bar handoff from an already-open tab only collapses
when openLocation is "editor"), so the docs here describe that actual
behavior instead.
onSidebarShown() branched on activeSurface(), which reports "editor"
whenever a panel exists regardless of the openLocation setting. With
the default openLocation: "sidebar" and the dashboard popped out to a
tab, revealing the sidebar handed off to DashboardPanel.reveal() and
yanked focus off whatever file the user was on.

Branch on the setting (openLocation()) instead: only hand off to the
tab when openLocation is "editor", matching the spec's state table.
With openLocation: "sidebar" and a panel open, the icon click now
falls through to renderSidebar(), which resolves to the "Show tab"
placeholder instead of stealing focus.

openDashboard() is untouched — an explicit "open the dashboard"
request should still route via activeSurface() to whichever surface
owns it.
The previous commit makes showEditorTab(true) reachable only when
openLocation === "editor", so shouldCollapseSidebar's
`if (fromSidebarReveal && openLocation !== "editor") return false`
branch can never fire. Revert shouldCollapseSidebar to the plain
2-argument form (enabled && sidebarVisible), with a doc comment
describing only the Explorer-safety visibility guard, and revert the
test file's describe block to the 4 enabled x sidebarVisible cases —
dropping the tests that only existed to cover the deleted dimension.

This restores what the spec always said: the collapse fires on any
path that opens the tab, gated only on our view being the visible
container.
… README

- Add the missing Commands-table row for "Claude Usage: Open Dashboard
  in Editor Tab".
- Rewrite the collapseSidebarOnOpenInEditor row (README) and its
  matching CHANGELOG bullet: both self-contradicted by leading with
  "Only fires on explicit pop-outs (...)" and then describing a
  further trigger. State it plainly and completely instead — it
  collapses on any path that opens the editor tab, gated only on the
  Claude Usage view being the visible sidebar container.
- Restore the "Claude Usage:" prefix on command titles in the
  settings-table row so they match package.json and the prose section
  below.
…comments

- sidebar.ts: delete the unused ServerManager import/re-export and its
  comment describing wiring that no longer exists — extension.ts now
  goes through ensureServer() -> renderSidebar(), and nothing imports
  ServerManager from sidebar.ts (verified with grep).
- sidebar.ts: drop refresh()'s redundant `if (!this.view) return` guard
  — render() already guards on the same condition, so the first check
  was unreachable. Matches editor-panel.ts's refresh(), which already
  relies solely on render()'s guard.
- editor-panel.ts: correct DashboardPanel.get()'s doc comment. It does
  not "drive the derived active-surface rule" (extension.ts reads its
  own this.panel field); it's an accessor used only by tests.
There was no test that package.json's contributions match the code.
The title-bar pop-out button depends on the view/title menu entry's
"when": "view == claudeUsage.dashboard" matching
DashboardSidebar.viewId — a typo in either would silently remove the
button with zero test failures.

Covers: claudeUsage.openInEditor's command title/icon, the view/title
menu entry's command and when-clause (compared against the real
DashboardSidebar.viewId import, not a hardcoded string),
claudeUsage.openLocation's enum/default, and
claudeUsage.collapseSidebarOnOpenInEditor's type/default. No vscode
mock needed for the JSON.parse itself, but importing DashboardSidebar
from sidebar.ts requires the same vi.mock("vscode", ...) stub
test/sidebar.test.ts already uses.
onSidebarShown() runs ensureServer() itself now that it no longer delegates
to showEditorTab() under openLocation: "sidebar". A panel can still exist in
that state, so guarding only the sidebar dropped the URL when the panel's own
startup had failed and this attempt succeeded, leaving the tab on a stale
error pane over a running server. Re-derive the target instead.

Also refreshes two README lines this feature made inaccurate: the Open
Dashboard command routes by active surface, not always to the sidebar, and
the Usage steps now mention the editor-tab option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tab icon was a fixed-ink SVG handed to WebviewPanel.iconPath, which VS
Code draws as a plain image and never recolors. The artwork is solid #000000,
so it was invisible on dark themes. Only glyphs contributed through
contributes.icons and referenced as a ThemeIcon get painted with the active
theme's icon.foreground, so the icon now ships as a WOFF glyph.

The source is the original vector logo, recovered from ad05701. It was
replaced in 3d53f6d by a 167 KB SVG that is really two base64 PNGs in an SVG
wrapper (zero path data), which is unusable for a font glyph and is also why
the icon looks pixelated when opened in an editor.

Glyph metrics are copied from VS Code's own codicon.ttf (300 upm, advance
300, ink fitted to a 282 box anchored at x=0) so the icon sits in the tab
strip at the same size and label spacing as the built-in icons. Fitting the
viewBox instead of the ink bbox is what made the first attempt render small
with a visible left margin.

Requires VS Code 1.110+ (microsoft/vscode#90616), up from 1.94.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit raised the floor to 1.110 so the themed tab icon would be
guaranteed, which was the wrong trade: it made the extension uninstallable on
1.94-1.109 to protect a 16px decoration.

Older builds degrade gracefully. Their setter is

  $setIconPath(handle, URI.isUri(value) ? {light: value, dark: value} : value)

so a ThemeIcon is passed through untouched, does not throw, and the tab simply
renders without an icon (microsoft/vscode#282608). Everything else in the
extension works exactly as before.

The manifest test now asserts the floor stays AT OR BELOW 1.94 rather than at
or above 1.110, so the mistake cannot be reintroduced silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 29, 2026 01:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an editor-tab hosting option for the VS Code extension dashboard (in addition to the existing sidebar webview), with settings + commands that keep the “single live dashboard instance” invariant while reusing the same embedded localhost server and shared webview HTML.

Changes:

  • Introduces a singleton DashboardPanel (editor tab) and routing logic to open/focus either sidebar or tab based on derived state + claudeUsage.openLocation.
  • Extracts shared webview HTML rendering into src/webview-html.ts with a parameterized status/placeholder action button (Retry / Show tab / Open tab).
  • Updates extension manifest/docs/tests, adds an icon font for a theme-colored tab icon, and bumps version to 1.6.0 (incl. changelog + scanner.VERSION).

Reviewed changes

Copilot reviewed 17 out of 19 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
vscode-extension/src/extension.ts Refactors orchestration: derived active surface, shared server startup, new open-in-editor command, sidebar placeholder rendering, optional sidebar collapse logic
vscode-extension/src/editor-panel.ts Adds singleton editor-tab webview panel host for the dashboard
vscode-extension/src/sidebar.ts Removes embedded HTML helpers; adds placeholder/action support and visibility probing
vscode-extension/src/webview-html.ts New shared HTML renderer + escaping + nonce generation + action button abstraction
vscode-extension/src/open-target.ts Pure, vscode-free routing predicates and placeholder action constants
vscode-extension/package.json Adds settings, new command + view/title menu entry, icon contribution, version bump
vscode-extension/README.md Documents editor-tab option + new settings/commands; notes optional tab icon behavior on newer VS Code
vscode-extension/scripts/build-icon-font.py Build script to generate the contributed WOFF icon font from a vector SVG
vscode-extension/resources/icon-vector.svg Adds vector source used to generate the icon font
vscode-extension/test/webview-html.test.ts New unit tests for shared HTML renderer + action button escaping
vscode-extension/test/open-target.test.ts New unit tests for surface routing decision table and sidebar-collapse predicate
vscode-extension/test/editor-panel.test.ts New unit tests for editor panel singleton behavior and rendering paths
vscode-extension/test/sidebar.test.ts Updates sidebar tests for placeholder mode + visibility behavior
vscode-extension/test/manifest.test.ts New tests asserting manifest wiring for commands/settings/icon resources
CHANGELOG.md Adds v1.6.0 — TBD entry covering the feature set
scanner.py Bumps VERSION to 1.6.0 to stay in parity with changelog + extension package
docs/superpowers/specs/2026-07-26-vscode-editor-tab-design.md Design spec for the feature and invariants
docs/superpowers/plans/2026-07-26-vscode-editor-tab.md Implementation plan and verification checklist

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +9 to +12
// Static JSON.parse of the manifest — no vscode API needed here, but the
// DashboardSidebar import below requires the stub above regardless.
const manifestPath = path.join(__dirname, "..", "package.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
Comment on lines +149 to +153
/** Idempotent: disposing an already-disposed panel is a safe no-op. */
dispose(): void {
if (this.disposed) return;
this.panel.dispose();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ability to open as a tab in workspace instead of left navigation

2 participants