diff --git a/resources/beads-icon-editor-dark.svg b/resources/beads-icon-editor-dark.svg
new file mode 100644
index 0000000..271c0c5
--- /dev/null
+++ b/resources/beads-icon-editor-dark.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/resources/beads-icon-editor-light.svg b/resources/beads-icon-editor-light.svg
new file mode 100644
index 0000000..3581f9a
--- /dev/null
+++ b/resources/beads-icon-editor-light.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/src/backend/BeadsCommandRunner.ts b/src/backend/BeadsCommandRunner.ts
index ff0d015..007b3c7 100644
--- a/src/backend/BeadsCommandRunner.ts
+++ b/src/backend/BeadsCommandRunner.ts
@@ -54,6 +54,51 @@ export function createShowCommandArgs(id: string): string[] {
return ["show", id, "--json", "--include-dependents"];
}
+export function createUpdateCommandArgs(
+ args: UpdateIssueArgs,
+ currentLabels: string[] = []
+): string[] {
+ const cmdArgs = ["update", args.id, "--json"];
+
+ if (args.title !== undefined) cmdArgs.push("--title", args.title);
+ if (args.description !== undefined) cmdArgs.push("--description", args.description);
+ if (args.design !== undefined) cmdArgs.push("--design", args.design);
+ if (args.acceptance_criteria !== undefined) cmdArgs.push("--acceptance", args.acceptance_criteria);
+ if (args.notes !== undefined) cmdArgs.push("--notes", args.notes);
+ if (args.status !== undefined) cmdArgs.push("--status", args.status);
+ if (args.priority !== undefined) cmdArgs.push("--priority", String(args.priority));
+ if (args.assignee !== undefined) cmdArgs.push("--assignee", args.assignee);
+ if (args.external_ref !== undefined) cmdArgs.push("--external-ref", args.external_ref);
+ if (
+ args.estimated_minutes !== undefined &&
+ args.estimate !== undefined &&
+ args.estimated_minutes !== args.estimate
+ ) {
+ throw new Error("Conflicting estimate values: estimated_minutes and estimate differ");
+ }
+ const estimate = args.estimated_minutes ?? args.estimate;
+ if (estimate !== undefined) cmdArgs.push("--estimate", String(estimate));
+ if (
+ args.type !== undefined &&
+ args.issue_type !== undefined &&
+ args.type !== args.issue_type
+ ) {
+ throw new Error("Conflicting issue type values");
+ }
+ const issueType = args.issue_type ?? args.type;
+ if (issueType !== undefined) cmdArgs.push("--type", issueType);
+
+ for (const label of args.add_labels ?? []) cmdArgs.push("--add-label", label);
+ for (const label of args.remove_labels ?? []) cmdArgs.push("--remove-label", label);
+ if (args.set_labels?.length === 0) {
+ for (const label of currentLabels) cmdArgs.push("--remove-label", label);
+ } else {
+ for (const label of args.set_labels ?? []) cmdArgs.push("--set-labels", label);
+ }
+
+ return cmdArgs;
+}
+
export class BeadsCommandRunner implements BeadsBackend {
private readonly bdPath: string;
private readonly cwd: string;
@@ -152,41 +197,21 @@ export class BeadsCommandRunner implements BeadsBackend {
}
async update(args: UpdateIssueArgs): Promise {
- const cmdArgs = ["update", args.id, "--json"];
-
- if (args.title !== undefined) cmdArgs.push("--title", args.title);
- if (args.description !== undefined) cmdArgs.push("--description", args.description);
- if (args.design !== undefined) cmdArgs.push("--design", args.design);
- if (args.acceptance_criteria !== undefined) {
- cmdArgs.push("--acceptance", args.acceptance_criteria);
- }
- if (args.notes !== undefined) cmdArgs.push("--notes", args.notes);
- if (args.status !== undefined) cmdArgs.push("--status", args.status);
- if (args.priority !== undefined) cmdArgs.push("--priority", String(args.priority));
- if (args.assignee !== undefined) cmdArgs.push("--assignee", args.assignee);
- if (args.external_ref !== undefined) cmdArgs.push("--external-ref", args.external_ref);
- if (
- args.estimated_minutes !== undefined &&
- args.estimate !== undefined &&
- args.estimated_minutes !== args.estimate
- ) {
- throw new Error("Conflicting estimate values: estimated_minutes and estimate differ");
- }
- const estimate = args.estimated_minutes ?? args.estimate;
- if (estimate !== undefined) cmdArgs.push("--estimate", String(estimate));
- if (
- args.type !== undefined &&
- args.issue_type !== undefined &&
- args.type !== args.issue_type
- ) {
- throw new Error("Conflicting issue type values");
+ let currentIssue: BeadsIssue | null = null;
+ if (args.set_labels?.length === 0) {
+ const result = await this.runJson(createShowCommandArgs(args.id));
+ currentIssue = Array.isArray(result)
+ ? (result[0] as BeadsIssue | undefined) ?? null
+ : (result as BeadsIssue) ?? null;
+ if (!currentIssue) {
+ throw new Error(`Issue not found: ${args.id}`);
+ }
}
- const issueType = args.issue_type ?? args.type;
- if (issueType !== undefined) cmdArgs.push("--type", issueType);
- for (const label of args.add_labels ?? []) cmdArgs.push("--add-label", label);
- for (const label of args.remove_labels ?? []) cmdArgs.push("--remove-label", label);
- for (const label of args.set_labels ?? []) cmdArgs.push("--set-labels", label);
+ const cmdArgs = createUpdateCommandArgs(args, currentIssue?.labels);
+ if (cmdArgs.length === 3 && currentIssue) {
+ return currentIssue;
+ }
const result = await this.runJson(cmdArgs);
return this.pickSingleIssue(result, "update");
diff --git a/src/backend/__tests__/BeadsCommandRunner.test.ts b/src/backend/__tests__/BeadsCommandRunner.test.ts
index 70d880e..63a7e44 100644
--- a/src/backend/__tests__/BeadsCommandRunner.test.ts
+++ b/src/backend/__tests__/BeadsCommandRunner.test.ts
@@ -1,5 +1,11 @@
-import { createListCommandArgs, createShowCommandArgs } from "../BeadsCommandRunner";
+import {
+ BeadsCommandRunner,
+ createListCommandArgs,
+ createShowCommandArgs,
+ createUpdateCommandArgs,
+} from "../BeadsCommandRunner";
import { MIN_SUPPORTED_BD_VERSION } from "../BeadsBackend";
+import { Logger } from "../../utils/logger";
describe("createListCommandArgs", () => {
it("requests all issues without the CLI default limit", () => {
@@ -23,3 +29,75 @@ describe("createShowCommandArgs", () => {
expect(major * 10000 + minor * 100 + patch).toBeGreaterThanOrEqual(10005);
});
});
+
+describe("createUpdateCommandArgs", () => {
+ it("serializes empty strings and zero values used to clear optional fields", () => {
+ expect(createUpdateCommandArgs({
+ id: "bd-1",
+ external_ref: "",
+ estimated_minutes: 0,
+ })).toEqual([
+ "update",
+ "bd-1",
+ "--json",
+ "--external-ref",
+ "",
+ "--estimate",
+ "0",
+ ]);
+ });
+
+ it("removes current labels when the replacement set is empty", () => {
+ expect(createUpdateCommandArgs(
+ { id: "bd-1", set_labels: [] },
+ ["bug", "ui"]
+ )).toEqual([
+ "update",
+ "bd-1",
+ "--json",
+ "--remove-label",
+ "bug",
+ "--remove-label",
+ "ui",
+ ]);
+ });
+
+ it("reads labels without the show cache before removing the final labels", async () => {
+ const log = { child: () => log } as unknown as Logger;
+ const runner = new BeadsCommandRunner({
+ bdPath: "bd",
+ cwd: "/tmp",
+ beadsDir: "/tmp/.beads",
+ log,
+ });
+ const issue = {
+ id: "bd-1",
+ title: "Issue",
+ status: "open",
+ priority: 2,
+ issue_type: "task",
+ labels: ["bug", "ui"],
+ created_at: "2026-08-29T00:00:00Z",
+ updated_at: "2026-08-29T00:00:00Z",
+ };
+ const runJson = jest.spyOn(
+ runner as unknown as { runJson: (args: string[]) => Promise },
+ "runJson"
+ )
+ .mockResolvedValueOnce([issue])
+ .mockResolvedValueOnce([{ ...issue, labels: [] }]);
+
+ await runner.update({ id: "bd-1", set_labels: [] });
+
+ expect(runJson).toHaveBeenNthCalledWith(1, createShowCommandArgs("bd-1"));
+ expect(runJson).toHaveBeenNthCalledWith(2, [
+ "update",
+ "bd-1",
+ "--json",
+ "--remove-label",
+ "bug",
+ "--remove-label",
+ "ui",
+ ]);
+ });
+});
diff --git a/src/providers/BaseViewProvider.ts b/src/providers/BaseViewProvider.ts
index a940118..791b9c9 100644
--- a/src/providers/BaseViewProvider.ts
+++ b/src/providers/BaseViewProvider.ts
@@ -160,7 +160,10 @@ export abstract class BaseViewProvider implements vscode.WebviewViewProvider {
// Options and HTML are not preserved across a reload, so always re-apply.
panel.webview.options = this.getWebviewOptions();
panel.webview.html = this.getHtmlForWebview(panel.webview);
- panel.iconPath = vscode.Uri.joinPath(this.extensionUri, "resources", "beads-icon.svg");
+ panel.iconPath = {
+ light: vscode.Uri.joinPath(this.extensionUri, "resources", "beads-icon-editor-light.svg"),
+ dark: vscode.Uri.joinPath(this.extensionUri, "resources", "beads-icon-editor-dark.svg"),
+ };
this.editorPanel = panel;
const host: WebviewHost = {
@@ -316,16 +319,26 @@ export abstract class BaseViewProvider implements vscode.WebviewViewProvider {
},
}, target);
- // Load view-specific data only while some surface is visible.
- if (this.isVisible) {
- await this.loadData("initial");
+ this.seedView(target);
+
+ // Load view-specific data only while the initialized surface is visible.
+ if (target?.visible ?? this.isVisible) {
+ await this.loadData("initial", target);
}
}
+ /** Seeds provider-specific state that is not part of a backend load. */
+ protected seedView(_target?: WebviewHost): void {
+ // Default: nothing to seed
+ }
+
/**
* Loads view-specific data. Override in subclasses.
*/
- protected abstract loadData(reason?: "initial" | "projectChange" | "manualRefresh" | "background"): Promise;
+ protected abstract loadData(
+ reason?: "initial" | "projectChange" | "manualRefresh" | "background",
+ target?: WebviewHost
+ ): Promise;
/**
* Handles messages from the webview. Override in subclasses for custom handling.
@@ -488,15 +501,15 @@ export abstract class BaseViewProvider implements vscode.WebviewViewProvider {
/**
* Sets the loading state in the webview
*/
- protected setLoading(loading: boolean): void {
- this.postMessage({ type: "setLoading", loading });
+ protected setLoading(loading: boolean, target?: WebviewHost): void {
+ this.postMessage({ type: "setLoading", loading }, target);
}
/**
* Sets an error message in the webview
*/
- protected setError(error: string | null): void {
- this.postMessage({ type: "setError", error });
+ protected setError(error: string | null, target?: WebviewHost): void {
+ this.postMessage({ type: "setError", error }, target);
}
/**
@@ -535,11 +548,14 @@ export abstract class BaseViewProvider implements vscode.WebviewViewProvider {
* Triggers a refresh intended for active project switches.
*/
public refreshForProjectChange(): void {
+ // Retained hidden webviews also need the new project identity so their
+ // persisted state cannot remain bound to the previous project.
+ this.postProjectState();
+
if (!this.isVisible) {
return;
}
- this.postProjectState();
this.loadData("projectChange");
}
diff --git a/src/providers/BeadDetailsViewProvider.ts b/src/providers/BeadDetailsViewProvider.ts
index f9e1e1e..2016a3e 100644
--- a/src/providers/BeadDetailsViewProvider.ts
+++ b/src/providers/BeadDetailsViewProvider.ts
@@ -9,9 +9,9 @@
*/
import * as vscode from "vscode";
-import { BaseViewProvider, NavigationOrigin } from "./BaseViewProvider";
+import { BaseViewProvider, NavigationOrigin, WebviewHost } from "./BaseViewProvider";
import { BeadsProjectManager } from "../backend/BeadsProjectManager";
-import { WebviewToExtensionMessage, issueToWebviewBead } from "../backend/types";
+import { Bead, WebviewToExtensionMessage, issueToWebviewBead } from "../backend/types";
import { Logger } from "../utils/logger";
import { buildUpdateArgs } from "./bead-updates";
@@ -19,9 +19,17 @@ export class BeadDetailsViewProvider extends BaseViewProvider {
protected readonly viewType = "beadsDetails";
protected readonly panelViewType = "beads.detailsEditor";
protected readonly panelTitle = "Beads Details";
+ private static readonly SNAPSHOT_TTL_MS = 1000;
private currentBeadId: string | null = null;
private currentProjectId: string | null = null;
private loadSequence = 0; // Tracks request order to prevent stale responses
+ private readonly targetLoadTokens = new WeakMap();
+ private snapshot: {
+ projectId: string;
+ beadId: string;
+ bead: Bead;
+ loadedAt: number;
+ } | null = null;
constructor(
extensionUri: vscode.Uri,
@@ -40,6 +48,7 @@ export class BeadDetailsViewProvider extends BaseViewProvider {
// Update context for conditional menu items
vscode.commands.executeCommand("setContext", "beads.hasSelectedBead", true);
+ this.postMessage({ type: "setSelectedBeadId", beadId });
// Auto-expand the details view on the surface the request came from,
// creating the editor tab if that is where the request originated
@@ -53,13 +62,27 @@ export class BeadDetailsViewProvider extends BaseViewProvider {
* Restores the bead an editor tab was showing before a window reload.
*/
protected restoreEditorState(state: unknown): void {
- const beadId = (state as { beadId?: unknown } | null | undefined)?.beadId;
- if (typeof beadId !== "string" || beadId.length === 0) {
+ const restored = state as {
+ version?: unknown;
+ projectId?: unknown;
+ beadId?: unknown;
+ } | null | undefined;
+ const activeProjectId = this.projectManager.getActiveProject()?.id ?? null;
+ if (
+ restored?.version !== 1 ||
+ typeof restored.projectId !== "string" ||
+ restored.projectId !== activeProjectId ||
+ typeof restored.beadId !== "string" ||
+ restored.beadId.length === 0
+ ) {
+ this.currentBeadId = null;
+ this.currentProjectId = activeProjectId;
+ vscode.commands.executeCommand("setContext", "beads.hasSelectedBead", false);
return;
}
- this.currentBeadId = beadId;
- this.currentProjectId = this.projectManager.getActiveProject()?.id || null;
+ this.currentBeadId = restored.beadId;
+ this.currentProjectId = restored.projectId;
vscode.commands.executeCommand("setContext", "beads.hasSelectedBead", true);
}
@@ -85,49 +108,99 @@ export class BeadDetailsViewProvider extends BaseViewProvider {
* Clear the current bead (e.g., when switching projects)
*/
public clearBead(): void {
+ this.loadSequence++;
+ this.snapshot = null;
this.currentBeadId = null;
vscode.commands.executeCommand("setContext", "beads.hasSelectedBead", false);
this.setEditorPanelTitle(this.panelTitle);
+ this.postMessage({ type: "setSelectedBeadId", beadId: null });
this.postMessage({ type: "setBead", bead: null });
this.setLoading(false);
}
- protected async loadData(_reason: "initial" | "projectChange" | "manualRefresh" | "background" = "background"): Promise {
+ public refreshForProjectChange(): void {
+ const activeProjectId = this.projectManager.getActiveProject()?.id ?? null;
+ if (this.currentBeadId && activeProjectId !== this.currentProjectId) {
+ this.currentProjectId = activeProjectId;
+ this.clearBead();
+ } else {
+ this.currentProjectId = activeProjectId;
+ }
+ super.refreshForProjectChange();
+ }
+
+ protected seedView(target?: WebviewHost): void {
+ this.postMessage({ type: "setSelectedBeadId", beadId: this.currentBeadId }, target);
+ }
+
+ protected async loadData(
+ reason: "initial" | "projectChange" | "manualRefresh" | "background" = "background",
+ target?: WebviewHost
+ ): Promise {
+ const activeProjectId = this.projectManager.getActiveProject()?.id ?? null;
+ const beadId = this.currentBeadId;
+ if (
+ reason === "initial" &&
+ target &&
+ activeProjectId &&
+ beadId &&
+ this.snapshot?.projectId === activeProjectId &&
+ this.snapshot.beadId === beadId
+ ) {
+ this.postMessage({ type: "setBead", bead: this.snapshot.bead }, target);
+ this.setError(null, target);
+ this.setLoading(false, target);
+ if (Date.now() - this.snapshot.loadedAt > BeadDetailsViewProvider.SNAPSHOT_TTL_MS) {
+ await this.loadData("background");
+ }
+ return;
+ }
+
// Increment sequence to track this request - prevents stale responses from
// overwriting newer data when multiple refreshes occur in rapid succession
- const thisRequest = ++this.loadSequence;
+ const targetLoadToken = target ? Symbol() : null;
+ if (target && targetLoadToken) {
+ this.targetLoadTokens.set(target, targetLoadToken);
+ }
+ const thisRequest = target ? this.loadSequence : ++this.loadSequence;
+ const isCurrentRequest = () =>
+ thisRequest === this.loadSequence &&
+ (!target || this.targetLoadTokens.get(target) === targetLoadToken);
const client = this.projectManager.getClient();
- const activeProjectId = this.projectManager.getActiveProject()?.id;
// Clear selection if project changed. Goes through clearBead so the menu
// context and the editor tab title are reset too, not just the id.
- if (this.currentProjectId && activeProjectId !== this.currentProjectId) {
- this.currentProjectId = activeProjectId || null;
+ if (this.currentBeadId && activeProjectId !== this.currentProjectId) {
+ this.currentProjectId = activeProjectId;
this.clearBead();
}
if (!client || !this.currentBeadId) {
- this.postMessage({ type: "setBead", bead: null });
- this.setLoading(false);
+ this.postMessage({ type: "setBead", bead: null }, target);
+ this.setLoading(false, target);
return;
}
- this.setLoading(true);
- this.setError(null);
+ this.setLoading(true, target);
+ this.setError(null, target);
try {
// Fetch issue and comments in parallel
const [issue, comments] = await Promise.all([
- client.show(this.currentBeadId),
- client.listComments(this.currentBeadId).catch((err) => {
+ client.show(beadId!),
+ client.listComments(beadId!).catch((err) => {
this.log.trace(`Failed to fetch comments: ${err}`);
return [];
}),
]);
// Check if a newer request has started - if so, discard this stale response
- if (thisRequest !== this.loadSequence) {
+ if (
+ !isCurrentRequest() ||
+ (this.projectManager.getActiveProject()?.id ?? null) !== activeProjectId ||
+ this.currentBeadId !== beadId
+ ) {
this.log.debug(`Discarding stale response (request ${thisRequest}, current ${this.loadSequence})`);
return;
}
@@ -142,27 +215,33 @@ export class BeadDetailsViewProvider extends BaseViewProvider {
};
const bead = issueToWebviewBead(issueWithComments);
if (bead) {
- this.postMessage({ type: "setBead", bead });
+ this.snapshot = {
+ projectId: activeProjectId!,
+ beadId: beadId!,
+ bead,
+ loadedAt: Date.now(),
+ };
+ this.postMessage({ type: "setBead", bead }, target);
} else {
- this.setError("Invalid bead status");
- this.postMessage({ type: "setBead", bead: null });
+ this.setError("Invalid bead status", target);
+ this.postMessage({ type: "setBead", bead: null }, target);
}
} else {
- this.setError("Bead not found");
- this.postMessage({ type: "setBead", bead: null });
+ this.setError("Bead not found", target);
+ this.postMessage({ type: "setBead", bead: null }, target);
}
} catch (err) {
// Only handle error if this is still the current request
- if (thisRequest !== this.loadSequence) {
+ if (!isCurrentRequest() || this.currentBeadId !== beadId) {
return;
}
- this.setError(String(err));
- this.postMessage({ type: "setBead", bead: null });
+ this.setError(String(err), target);
+ this.postMessage({ type: "setBead", bead: null }, target);
this.handleBackendError("Failed to load bead details", err);
} finally {
// Only update loading state if this is still the current request
- if (thisRequest === this.loadSequence) {
- this.setLoading(false);
+ if (isCurrentRequest()) {
+ this.setLoading(false, target);
}
}
}
diff --git a/src/providers/BeadsPanelViewProvider.ts b/src/providers/BeadsPanelViewProvider.ts
index 93036c5..c3acdbb 100644
--- a/src/providers/BeadsPanelViewProvider.ts
+++ b/src/providers/BeadsPanelViewProvider.ts
@@ -10,7 +10,7 @@
*/
import * as vscode from "vscode";
-import { BaseViewProvider } from "./BaseViewProvider";
+import { BaseViewProvider, WebviewHost } from "./BaseViewProvider";
import { BeadsProjectManager } from "../backend/BeadsProjectManager";
import { WebviewToExtensionMessage, Bead, issueToWebviewBead } from "../backend/types";
import { Logger } from "../utils/logger";
@@ -21,8 +21,11 @@ export class BeadsPanelViewProvider extends BaseViewProvider {
protected readonly panelViewType = "beads.issuesEditor";
protected readonly panelTitle = "Beads Issues";
private static readonly MIN_LOADING_MS = 500;
+ private static readonly SNAPSHOT_TTL_MS = 1000;
private selectedBeadId: string | null = null;
private loadSequence = 0;
+ private readonly targetLoadTokens = new WeakMap();
+ private snapshot: { projectId: string | null; beads: Bead[]; loadedAt: number } | null = null;
constructor(
extensionUri: vscode.Uri,
@@ -40,51 +43,80 @@ export class BeadsPanelViewProvider extends BaseViewProvider {
this.postMessage({ type: "setSelectedBeadId", beadId });
}
- protected async loadData(reason: "initial" | "projectChange" | "manualRefresh" | "background" = "background"): Promise {
- const thisRequest = ++this.loadSequence;
+ protected seedView(target?: WebviewHost): void {
+ this.postMessage({ type: "setSelectedBeadId", beadId: this.selectedBeadId }, target);
+ }
+
+ protected async loadData(
+ reason: "initial" | "projectChange" | "manualRefresh" | "background" = "background",
+ target?: WebviewHost
+ ): Promise {
+ const projectId = this.projectManager.getActiveProject()?.id ?? null;
+ if (reason === "initial" && target && this.snapshot?.projectId === projectId) {
+ this.postMessage({ type: "setBeads", beads: this.snapshot.beads }, target);
+ this.setError(null, target);
+ this.setLoading(false, target);
+ if (Date.now() - this.snapshot.loadedAt > BeadsPanelViewProvider.SNAPSHOT_TTL_MS) {
+ await this.loadData("background");
+ }
+ return;
+ }
+
+ const targetLoadToken = target ? Symbol() : null;
+ if (target && targetLoadToken) {
+ this.targetLoadTokens.set(target, targetLoadToken);
+ }
+ const thisRequest = target ? this.loadSequence : ++this.loadSequence;
+ const isCurrentRequest = () =>
+ thisRequest === this.loadSequence &&
+ (!target || this.targetLoadTokens.get(target) === targetLoadToken);
const client = this.projectManager.getClient();
if (!client) {
+ this.snapshot = { projectId, beads: [], loadedAt: Date.now() };
// No project/backend: clear loading so the webview shows the empty state
// instead of spinning forever (#76)
- this.postMessage({ type: "setBeads", beads: [] });
- this.setLoading(false);
+ this.postMessage({ type: "setBeads", beads: [] }, target);
+ this.setLoading(false, target);
return;
}
const showLoading = reason === "initial" || reason === "projectChange" || reason === "manualRefresh";
const loadingStartedAt = showLoading ? Date.now() : 0;
if (showLoading) {
- this.postMessage({ type: "setBeads", beads: [] });
- this.setLoading(true);
+ this.postMessage({ type: "setBeads", beads: [] }, target);
+ this.setLoading(true, target);
}
- this.setError(null);
+ this.setError(null, target);
try {
const issues = await client.list();
if (showLoading) {
await this.waitForMinimumLoading(loadingStartedAt);
}
- if (thisRequest !== this.loadSequence) {
+ if (!isCurrentRequest() ||
+ (this.projectManager.getActiveProject()?.id ?? null) !== projectId) {
return;
}
const beads = issues.map(issueToWebviewBead).filter((b): b is Bead => b !== null);
- this.postMessage({ type: "setBeads", beads });
- this.setLoading(false);
+ this.snapshot = { projectId, beads, loadedAt: Date.now() };
+ this.postMessage({ type: "setBeads", beads }, target);
+ this.setLoading(false, target);
} catch (err) {
if (showLoading) {
await this.waitForMinimumLoading(loadingStartedAt);
}
- if (thisRequest !== this.loadSequence) {
+ if (!isCurrentRequest() ||
+ (this.projectManager.getActiveProject()?.id ?? null) !== projectId) {
return;
}
- this.setError(String(err));
+ this.setError(String(err), target);
if (showLoading) {
- this.postMessage({ type: "setBeads", beads: [] });
+ this.postMessage({ type: "setBeads", beads: [] }, target);
}
this.handleBackendError("Failed to load beads", err);
} finally {
- if (thisRequest === this.loadSequence) {
- this.setLoading(false);
+ if (isCurrentRequest()) {
+ this.setLoading(false, target);
}
}
}
diff --git a/src/providers/DashboardViewProvider.ts b/src/providers/DashboardViewProvider.ts
index 69eb2fe..a2330bc 100644
--- a/src/providers/DashboardViewProvider.ts
+++ b/src/providers/DashboardViewProvider.ts
@@ -9,7 +9,7 @@
*/
import * as vscode from "vscode";
-import { BaseViewProvider } from "./BaseViewProvider";
+import { BaseViewProvider, WebviewHost } from "./BaseViewProvider";
import { BeadsProjectManager } from "../backend/BeadsProjectManager";
import { Bead, BeadsSummary, issueToWebviewBead, BeadPriority, BUILT_IN_STATUSES } from "../backend/types";
import { Logger } from "../utils/logger";
@@ -19,7 +19,15 @@ export class DashboardViewProvider extends BaseViewProvider {
protected readonly panelViewType = "beads.dashboardEditor";
protected readonly panelTitle = "Beads Dashboard";
private static readonly MIN_LOADING_MS = 500;
+ private static readonly SNAPSHOT_TTL_MS = 1000;
private loadSequence = 0;
+ private readonly targetLoadTokens = new WeakMap();
+ private snapshot: {
+ projectId: string | null;
+ summary: BeadsSummary;
+ beads: Bead[];
+ loadedAt: number;
+ } | null = null;
constructor(
extensionUri: vscode.Uri,
@@ -29,43 +37,68 @@ export class DashboardViewProvider extends BaseViewProvider {
super(extensionUri, projectManager, logger.child("Dashboard"));
}
- protected async loadData(reason: "initial" | "projectChange" | "manualRefresh" | "background" = "background"): Promise {
- const thisRequest = ++this.loadSequence;
+ protected async loadData(
+ reason: "initial" | "projectChange" | "manualRefresh" | "background" = "background",
+ target?: WebviewHost
+ ): Promise {
+ const projectId = this.projectManager.getActiveProject()?.id ?? null;
+ if (reason === "initial" && target && this.snapshot?.projectId === projectId) {
+ this.postMessage({ type: "setSummary", summary: this.snapshot.summary }, target);
+ this.postMessage({ type: "setBeads", beads: this.snapshot.beads }, target);
+ this.setError(null, target);
+ this.setLoading(false, target);
+ if (Date.now() - this.snapshot.loadedAt > DashboardViewProvider.SNAPSHOT_TTL_MS) {
+ await this.loadData("background");
+ }
+ return;
+ }
+
+ const targetLoadToken = target ? Symbol() : null;
+ if (target && targetLoadToken) {
+ this.targetLoadTokens.set(target, targetLoadToken);
+ }
+ const thisRequest = target ? this.loadSequence : ++this.loadSequence;
+ const isCurrentRequest = () =>
+ thisRequest === this.loadSequence &&
+ (!target || this.targetLoadTokens.get(target) === targetLoadToken);
const client = this.projectManager.getClient();
if (!client) {
+ const summary: BeadsSummary = {
+ total: 0,
+ byStatus: Object.fromEntries(BUILT_IN_STATUSES.map((s) => [s, 0])),
+ byPriority: { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 },
+ readyCount: 0,
+ blockedCount: 0,
+ inProgressCount: 0,
+ };
+ this.snapshot = { projectId, summary, beads: [], loadedAt: Date.now() };
this.postMessage({
type: "setSummary",
- summary: {
- total: 0,
- byStatus: Object.fromEntries(BUILT_IN_STATUSES.map((s) => [s, 0])),
- byPriority: { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 },
- readyCount: 0,
- blockedCount: 0,
- inProgressCount: 0,
- },
- });
+ summary,
+ }, target);
// No project/backend: clear loading so the webview shows the empty state
// instead of spinning forever (#76)
- this.postMessage({ type: "setBeads", beads: [] });
- this.setLoading(false);
+ this.postMessage({ type: "setBeads", beads: [] }, target);
+ this.setLoading(false, target);
return;
}
const showLoading = reason === "initial" || reason === "projectChange" || reason === "manualRefresh";
const loadingStartedAt = showLoading ? Date.now() : 0;
if (showLoading) {
- this.postMessage({ type: "setSummary", summary: null });
- this.postMessage({ type: "setBeads", beads: [] });
- this.setLoading(true);
+ this.postMessage({ type: "setSummary", summary: null }, target);
+ this.postMessage({ type: "setBeads", beads: [] }, target);
+ this.setLoading(true, target);
}
- this.setError(null);
+ this.setError(null, target);
try {
const issues = await client.list();
if (showLoading) {
await this.waitForMinimumLoading(loadingStartedAt);
}
- if (thisRequest !== this.loadSequence) {
+ if (!isCurrentRequest() ||
+ (this.projectManager.getActiveProject()?.id ?? null) !== projectId) {
return;
}
@@ -91,25 +124,27 @@ export class DashboardViewProvider extends BaseViewProvider {
inProgressCount: byStatus.in_progress,
};
- this.postMessage({ type: "setSummary", summary });
-
const openBeads = beads.filter((b) => b.status === "open").slice(0, 5);
const blockedBeads = beads.filter((b) => b.status === "blocked").slice(0, 5);
const inProgressBeads = beads.filter((b) => b.status === "in_progress").slice(0, 5);
- this.postMessage({ type: "setBeads", beads: [...openBeads, ...blockedBeads, ...inProgressBeads] });
- this.setLoading(false);
+ const dashboardBeads = [...openBeads, ...blockedBeads, ...inProgressBeads];
+ this.snapshot = { projectId, summary, beads: dashboardBeads, loadedAt: Date.now() };
+ this.postMessage({ type: "setSummary", summary }, target);
+ this.postMessage({ type: "setBeads", beads: dashboardBeads }, target);
+ this.setLoading(false, target);
} catch (err) {
if (showLoading) {
await this.waitForMinimumLoading(loadingStartedAt);
}
- if (thisRequest !== this.loadSequence) {
+ if (!isCurrentRequest() ||
+ (this.projectManager.getActiveProject()?.id ?? null) !== projectId) {
return;
}
- this.setError(String(err));
+ this.setError(String(err), target);
this.handleBackendError("Failed to load dashboard", err);
} finally {
- if (thisRequest === this.loadSequence) {
- this.setLoading(false);
+ if (isCurrentRequest()) {
+ this.setLoading(false, target);
}
}
}
diff --git a/src/providers/__tests__/bead-updates.test.ts b/src/providers/__tests__/bead-updates.test.ts
index 3f0e4e6..bc28dcb 100644
--- a/src/providers/__tests__/bead-updates.test.ts
+++ b/src/providers/__tests__/bead-updates.test.ts
@@ -76,6 +76,23 @@ describe("buildUpdateArgs", () => {
expect(buildUpdateArgs("bd-1", { priority: 1.5 })).toHaveProperty("error");
});
+ it("accepts the supported clear-value representations", () => {
+ const { args } = expectArgs(
+ buildUpdateArgs("bd-1", {
+ externalRef: "",
+ estimatedMinutes: 0,
+ labels: [],
+ })
+ );
+
+ expect(args).toEqual({
+ id: "bd-1",
+ external_ref: "",
+ estimated_minutes: 0,
+ set_labels: [],
+ });
+ });
+
it("rejects a message with no usable target or payload", () => {
expect(buildUpdateArgs("", { status: "open" })).toHaveProperty("error");
expect(buildUpdateArgs(42, { status: "open" })).toHaveProperty("error");
diff --git a/src/providers/__tests__/editor-panels.test.ts b/src/providers/__tests__/editor-panels.test.ts
index bfe7060..82204c8 100644
--- a/src/providers/__tests__/editor-panels.test.ts
+++ b/src/providers/__tests__/editor-panels.test.ts
@@ -12,9 +12,11 @@ import {
FakeWebview,
} from "../../__mocks__/vscode";
import { BeadDetailsViewProvider } from "../BeadDetailsViewProvider";
+import { BeadsPanelViewProvider } from "../BeadsPanelViewProvider";
import { DashboardViewProvider } from "../DashboardViewProvider";
import { BeadsProjectManager } from "../../backend/BeadsProjectManager";
import { ExtensionToWebviewMessage } from "../../backend/types";
+import { BeadsBackend, BeadsIssue } from "../../backend/BeadsBackend";
import { Logger } from "../../utils/logger";
interface Harness {
@@ -38,13 +40,14 @@ function makeLogger(): Logger {
*/
function harness(
Provider: new (uri: vscode.Uri, pm: BeadsProjectManager, log: Logger) => T,
- activeProjectId: string | null = "project-a"
+ activeProjectId: string | null = "project-a",
+ client: Partial | null = null
): Harness {
const posted: ExtensionToWebviewMessage[] = [];
let projectId = activeProjectId;
const projectManager = {
- getClient: () => null,
+ getClient: () => client,
getActiveProject: () => (projectId ? { id: projectId, name: projectId } : null),
getProjects: () => [],
} as unknown as BeadsProjectManager;
@@ -94,6 +97,19 @@ describe("editor tab lifecycle", () => {
expect(createdPanels[0].revealCalls).toEqual([{ column: undefined, preserveFocus: false }]);
});
+ it("uses theme-specific editor tab icons", () => {
+ const { provider } = harness(DashboardViewProvider);
+
+ provider.showInEditor();
+
+ const iconPath = createdPanels[0].iconPath as {
+ light: { fsPath: string };
+ dark: { fsPath: string };
+ };
+ expect(iconPath.light.fsPath).toMatch(/beads-icon-editor-light\.svg$/);
+ expect(iconPath.dark.fsPath).toMatch(/beads-icon-editor-dark\.svg$/);
+ });
+
it("stops posting to a panel once it is disposed", async () => {
const { provider } = harness(DashboardViewProvider);
provider.showInEditor();
@@ -208,36 +224,204 @@ describe("serializer restoration", () => {
provider.adoptEditorPanel(
vscode.window.createWebviewPanel("beads.detailsEditor", "Beads Details", 1, {}) as unknown as vscode.WebviewPanel,
- { beadId: "bd-42" }
+ { version: 1, projectId: "project-a", beadId: "bd-42" }
);
expect(provider.getCurrentBeadId()).toBe("bd-42");
});
- it("ignores malformed persisted state", () => {
+ it("ignores persisted state for another project", () => {
const { provider } = harness(BeadDetailsViewProvider);
provider.adoptEditorPanel(
vscode.window.createWebviewPanel("beads.detailsEditor", "Beads Details", 1, {}) as unknown as vscode.WebviewPanel,
- { beadId: 42 }
+ { version: 1, projectId: "project-b", beadId: "bd-42" }
);
expect(provider.getCurrentBeadId()).toBeNull();
});
+
+ it("ignores legacy and malformed persisted state", () => {
+ const { provider } = harness(BeadDetailsViewProvider);
+
+ provider.adoptEditorPanel(
+ vscode.window.createWebviewPanel("beads.detailsEditor", "Beads Details", 1, {}) as unknown as vscode.WebviewPanel,
+ { beadId: "bd-42" }
+ );
+
+ expect(provider.getCurrentBeadId()).toBeNull();
+ });
+
+ it("seeds a hidden restored tab with its bead id before loading", async () => {
+ const { provider } = harness(BeadDetailsViewProvider);
+ const panel = vscode.window.createWebviewPanel(
+ "beads.detailsEditor",
+ "Beads Details",
+ 1,
+ {}
+ ) as unknown as vscode.WebviewPanel;
+ provider.adoptEditorPanel(panel, {
+ version: 1,
+ projectId: "project-a",
+ beadId: "bd-42",
+ });
+ const fakePanel = createdPanels[0];
+ fakePanel.setVisible(false);
+ const seen: ExtensionToWebviewMessage[] = [];
+ fakePanel.webview.postMessage = (message) => seen.push(message as ExtensionToWebviewMessage);
+
+ await fakePanel.webview.emit({ type: "ready" });
+
+ expect(seen).toContainEqual({ type: "setSelectedBeadId", beadId: "bd-42" });
+ });
});
describe("project switching", () => {
- it("clears the Details selection and its menu context", async () => {
+ it("clears a hidden Details selection and its menu context immediately", async () => {
const spy = jest.spyOn(vscode.commands, "executeCommand");
const { provider, setActiveProjectId } = harness(BeadDetailsViewProvider);
- await provider.showBead("bd-1");
+ await provider.showBead("bd-1", { surface: "editor" });
expect(provider.getCurrentBeadId()).toBe("bd-1");
+ createdPanels[0].setVisible(false);
+ const seen: ExtensionToWebviewMessage[] = [];
+ createdPanels[0].webview.postMessage = (message) => seen.push(message as ExtensionToWebviewMessage);
setActiveProjectId("project-b");
- await (provider as unknown as { loadData: () => Promise }).loadData();
+ provider.refreshForProjectChange();
expect(provider.getCurrentBeadId()).toBeNull();
+ expect(createdPanels[0].title).toBe("Beads Details");
expect(spy).toHaveBeenCalledWith("setContext", "beads.hasSelectedBead", false);
+ expect(seen).toContainEqual({ type: "setSelectedBeadId", beadId: null });
+ });
+});
+
+describe("host seeding", () => {
+ it("seeds a newly opened Issues host with the current selection", async () => {
+ const { provider } = harness(BeadsPanelViewProvider);
+ provider.setSelectedBead("bd-1");
+ provider.showInEditor();
+
+ const seen: ExtensionToWebviewMessage[] = [];
+ createdPanels[0].webview.postMessage = (message) => seen.push(message as ExtensionToWebviewMessage);
+ await createdPanels[0].webview.emit({ type: "ready" });
+
+ expect(seen).toContainEqual({ type: "setSelectedBeadId", beadId: "bd-1" });
+ });
+
+ it("replays cached Issues data only to the newly opened host", async () => {
+ const issue: BeadsIssue = {
+ id: "bd-1",
+ title: "Cached issue",
+ status: "open",
+ priority: 2,
+ issue_type: "task",
+ created_at: "2026-08-29T00:00:00Z",
+ updated_at: "2026-08-29T00:00:00Z",
+ };
+ const list = jest.fn().mockResolvedValue([issue]);
+ const { provider, posted, attachSidebar } = harness(
+ BeadsPanelViewProvider,
+ "project-a",
+ { list }
+ );
+ attachSidebar();
+ await (provider as unknown as {
+ loadData: (reason: "background") => Promise;
+ }).loadData("background");
+ posted.length = 0;
+
+ provider.showInEditor();
+ const editorMessages: ExtensionToWebviewMessage[] = [];
+ createdPanels[0].webview.postMessage = (message) =>
+ editorMessages.push(message as ExtensionToWebviewMessage);
+ await createdPanels[0].webview.emit({ type: "ready" });
+
+ expect(list).toHaveBeenCalledTimes(1);
+ expect(editorMessages).toContainEqual(expect.objectContaining({ type: "setBeads" }));
+ expect(posted).toEqual([]);
+ });
+
+ it("does not let an older targeted load replace a newer refresh", async () => {
+ let resolveInitial!: (issues: BeadsIssue[]) => void;
+ const initial = new Promise((resolve) => {
+ resolveInitial = resolve;
+ });
+ const oldIssue: BeadsIssue = {
+ id: "bd-old",
+ title: "Old issue",
+ status: "open",
+ priority: 2,
+ issue_type: "task",
+ created_at: "2026-08-29T00:00:00Z",
+ updated_at: "2026-08-29T00:00:00Z",
+ };
+ const newIssue = { ...oldIssue, id: "bd-new", title: "New issue" };
+ const list = jest.fn()
+ .mockReturnValueOnce(initial)
+ .mockResolvedValueOnce([newIssue]);
+ const { provider } = harness(BeadsPanelViewProvider, "project-a", { list });
+ provider.showInEditor();
+ const seen: ExtensionToWebviewMessage[] = [];
+ createdPanels[0].webview.postMessage = (message) => seen.push(message as ExtensionToWebviewMessage);
+
+ const ready = createdPanels[0].webview.emit({ type: "ready" });
+ await (provider as unknown as {
+ loadData: (reason: "background") => Promise;
+ }).loadData("background");
+ resolveInitial([oldIssue]);
+ await ready;
+
+ const beadMessages = seen.filter((message) => message.type === "setBeads");
+ expect(beadMessages).toContainEqual({
+ type: "setBeads",
+ beads: [expect.objectContaining({ id: "bd-new" })],
+ });
+ expect(beadMessages).not.toContainEqual({
+ type: "setBeads",
+ beads: [expect.objectContaining({ id: "bd-old" })],
+ });
+ });
+
+ it("does not let an older load replace a newer load for the same host", async () => {
+ let resolveInitial!: (issues: BeadsIssue[]) => void;
+ const initial = new Promise((resolve) => {
+ resolveInitial = resolve;
+ });
+ const oldIssue: BeadsIssue = {
+ id: "bd-old",
+ title: "Old issue",
+ status: "open",
+ priority: 2,
+ issue_type: "task",
+ created_at: "2026-08-29T00:00:00Z",
+ updated_at: "2026-08-29T00:00:00Z",
+ };
+ const newIssue = { ...oldIssue, id: "bd-new", title: "New issue" };
+ const list = jest.fn()
+ .mockReturnValueOnce(initial)
+ .mockResolvedValueOnce([newIssue]);
+ const { provider } = harness(BeadsPanelViewProvider, "project-a", { list });
+ provider.showInEditor();
+ const seen: ExtensionToWebviewMessage[] = [];
+ const panel = createdPanels[0];
+ panel.webview.postMessage = (message) => seen.push(message as ExtensionToWebviewMessage);
+
+ const firstReady = panel.webview.emit({ type: "ready" });
+ const secondReady = panel.webview.emit({ type: "ready" });
+ await secondReady;
+ resolveInitial([oldIssue]);
+ await firstReady;
+
+ const beadMessages = seen.filter((message) => message.type === "setBeads");
+ expect(beadMessages).toContainEqual({
+ type: "setBeads",
+ beads: [expect.objectContaining({ id: "bd-new" })],
+ });
+ expect(beadMessages).not.toContainEqual({
+ type: "setBeads",
+ beads: [expect.objectContaining({ id: "bd-old" })],
+ });
});
});
diff --git a/src/webview/App.tsx b/src/webview/App.tsx
index 0ea41ff..ccb1f9e 100644
--- a/src/webview/App.tsx
+++ b/src/webview/App.tsx
@@ -33,6 +33,7 @@ interface AppState {
loading: boolean;
error: string | null;
settings: WebviewSettings;
+ projectInitialized: boolean;
}
const initialState: AppState = {
@@ -46,6 +47,7 @@ const initialState: AppState = {
loading: true,
error: null,
settings: { renderMarkdown: true, userId: "", tooltipHoverDelay: 1000 },
+ projectInitialized: false,
};
export function App(): React.ReactElement {
@@ -60,7 +62,7 @@ export function App(): React.ReactElement {
setState((prev) => ({ ...prev, viewType: message.viewType }));
break;
case "setProject":
- setState((prev) => ({ ...prev, project: message.project }));
+ setState((prev) => ({ ...prev, project: message.project, projectInitialized: true }));
break;
case "setProjects":
setState((prev) => ({ ...prev, projects: message.projects }));
@@ -100,15 +102,22 @@ export function App(): React.ReactElement {
useEffect(() => {
// Skip the pre-initialization render: writing placeholders here would
// clear a selection a restored tab still needs.
- if (!state.viewType) {
+ if (!state.viewType || !state.projectInitialized) {
return;
}
patchState({
+ version: 1,
viewType: state.viewType,
- beadId: state.selectedBead?.id ?? state.selectedBeadId ?? null,
+ projectId: state.project?.id ?? null,
+ beadId: state.selectedBeadId,
});
- }, [state.viewType, state.selectedBead?.id, state.selectedBeadId]);
+ }, [
+ state.viewType,
+ state.projectInitialized,
+ state.project?.id,
+ state.selectedBeadId,
+ ]);
useEffect(() => {
// Listen for messages from the extension
diff --git a/src/webview/views/DetailsView.tsx b/src/webview/views/DetailsView.tsx
index 4e1571c..2cfc523 100644
--- a/src/webview/views/DetailsView.tsx
+++ b/src/webview/views/DetailsView.tsx
@@ -569,7 +569,7 @@ export function DetailsView({
handleFieldChange("externalRef", e.target.value || null)}
+ onChange={(e) => handleFieldChange("externalRef", e.target.value)}
className="text-input"
placeholder="URL or reference ID"
/>
@@ -586,8 +586,8 @@ export function DetailsView({
{editMode ? (
handleFieldChange("estimatedMinutes", e.target.value ? parseInt(e.target.value, 10) : null)}
+ value={displayBead.estimatedMinutes ?? ""}
+ onChange={(e) => handleFieldChange("estimatedMinutes", e.target.value ? parseInt(e.target.value, 10) : 0)}
className="text-input estimate-input"
placeholder="Minutes"
min="0"