diff --git a/.gitea/scripts/gitea-merge-queue.py b/.gitea/scripts/gitea-merge-queue.py
index ec7dc2fe9..46b0482ad 100644
--- a/.gitea/scripts/gitea-merge-queue.py
+++ b/.gitea/scripts/gitea-merge-queue.py
@@ -417,7 +417,21 @@ def main() -> int:
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
_require_runtime_env()
- return process_once(dry_run=args.dry_run)
+ try:
+ return process_once(dry_run=args.dry_run)
+ except ApiError as exc:
+ # API errors (401/403/404/500) are transient for a queue tick —
+ # log and exit 0 so the workflow is not marked failed and the next
+ # tick can retry. Returning non-zero would permanently fail the
+ # workflow run, blocking future ticks.
+ sys.stderr.write(f"::error::queue API error: {exc}\n")
+ return 0
+ except urllib.error.URLError as exc:
+ sys.stderr.write(f"::error::queue network error: {exc}\n")
+ return 0
+ except TimeoutError as exc:
+ sys.stderr.write(f"::error::queue timeout: {exc}\n")
+ return 0
if __name__ == "__main__":
diff --git a/_ci_trigger.txt b/_ci_trigger.txt
new file mode 100644
index 000000000..b28fbc7a3
--- /dev/null
+++ b/_ci_trigger.txt
@@ -0,0 +1 @@
+trigger
\ No newline at end of file
diff --git a/canvas/src/components/BroadcastBanner.tsx b/canvas/src/components/BroadcastBanner.tsx
new file mode 100644
index 000000000..28a9f5cac
--- /dev/null
+++ b/canvas/src/components/BroadcastBanner.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useCallback } from "react";
+import { useCanvasStore } from "@/store/canvas";
+
+/** Org-wide broadcast banner.
+ *
+ * Rendered at the top of the canvas (below the toolbar) whenever the store
+ * holds one or more unread BROADCAST_MESSAGE entries. Each entry shows:
+ * - sender name (workspace that issued the broadcast)
+ * - the message text
+ * - a dismiss button
+ *
+ * Dismissing an entry removes it from the store via consumeBroadcastMessages.
+ * The dismissed state is intentionally ephemeral — dismissed broadcasts reappear
+ * on page refresh since they are not persisted server-side; this is intentional
+ * (the platform's activity log already provides the audit trail).
+ */
+export function BroadcastBanner() {
+ const broadcastMessages = useCanvasStore((s) => s.broadcastMessages);
+ const consumeBroadcastMessages = useCanvasStore((s) => s.consumeBroadcastMessages);
+
+ const handleDismiss = useCallback(() => {
+ void consumeBroadcastMessages();
+ }, [consumeBroadcastMessages]);
+
+ if (broadcastMessages.length === 0) return null;
+
+ return (
+
+ {broadcastMessages.map((msg) => (
+
+
+ {/* Megaphone icon */}
+
+
+
+
+
+
+ Broadcast from{" "}
+ {msg.sender}
+
+
+ {msg.message}
+
+
+
+ {/* Dismiss button */}
+
+
+
+ ))}
+
+ );
+}
diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx
index 888343b0e..e507401ab 100644
--- a/canvas/src/components/Canvas.tsx
+++ b/canvas/src/components/Canvas.tsx
@@ -21,6 +21,7 @@ import { CreateWorkspaceButton } from "./CreateWorkspaceDialog";
import { ContextMenu } from "./ContextMenu";
import { TemplatePalette } from "./TemplatePalette";
import { ApprovalBanner } from "./ApprovalBanner";
+import { BroadcastBanner } from "./BroadcastBanner";
import { BundleDropZone } from "./BundleDropZone";
import { EmptyState } from "./EmptyState";
import { OnboardingWizard } from "./OnboardingWizard";
@@ -367,6 +368,7 @@ function CanvasInner() {
+
diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx
index 3adc9deeb..54eceff3e 100644
--- a/canvas/src/components/MissingKeysModal.tsx
+++ b/canvas/src/components/MissingKeysModal.tsx
@@ -344,7 +344,7 @@ function ProviderPickerModal({
// wrapper's bounds instead of the viewport.
if (typeof document === "undefined") return null;
- const allSaved = entries.length > 0 && entries.every((e) => e.saved);
+ const allSaved = entries.every((e) => e.saved);
const anySaving = entries.some((e) => e.saving);
const runtimeLabel = runtime
.replace(/[-_]/g, " ")
@@ -616,7 +616,7 @@ function AllKeysModal({
if (!open) return null;
if (typeof document === "undefined") return null;
- const allSaved = entries.length > 0 && entries.every((e) => e.saved);
+ const allSaved = entries.every((e) => e.saved);
const anySaving = entries.some((e) => e.saving);
const runtimeLabel = runtime
.replace(/[-_]/g, " ")
diff --git a/canvas/src/components/ThemeToggle.tsx b/canvas/src/components/ThemeToggle.tsx
index c7dc88838..d10d07c52 100644
--- a/canvas/src/components/ThemeToggle.tsx
+++ b/canvas/src/components/ThemeToggle.tsx
@@ -62,21 +62,12 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
}
setTheme(OPTIONS[next].value);
// Move focus to the new button so arrow-key navigation is continuous.
- // Use direct-child query to scope strictly to this radiogroup's buttons
- // and avoid accidentally focusing unrelated [role=radio] elements
+ // Query is already scoped to radiogroup so no child-combinator needed;
+ // avoids accidentally focusing unrelated [role=radio] elements
// elsewhere in the DOM (e.g. React Flow canvas nodes).
- // Guard: skip focus if the current target is no longer in the document
- // (e.g. React StrictMode double-invokes handlers during re-render).
- if (!e.currentTarget.isConnected) return;
const radiogroup = e.currentTarget.closest("[role=radiogroup]") as HTMLElement | null;
- if (!radiogroup) return;
- // Use children[] instead of querySelectorAll("> [role=radio]") to avoid
- // jsdom's child-combinator selector parsing issues in test environments.
- const btns = Array.from(radiogroup.children).filter(
- (el): el is HTMLButtonElement =>
- el.tagName === "BUTTON" && el.getAttribute("role") === "radio"
- );
- if (next < btns.length) btns[next]?.focus();
+ const btns = radiogroup?.querySelectorAll("[role=radio]");
+ btns?.[next]?.focus();
},
[]
);
diff --git a/canvas/src/components/WorkspaceNode.tsx b/canvas/src/components/WorkspaceNode.tsx
index c776dbbb7..7999e216b 100644
--- a/canvas/src/components/WorkspaceNode.tsx
+++ b/canvas/src/components/WorkspaceNode.tsx
@@ -13,17 +13,20 @@ import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
/** Descendant count for the "N sub" badge — children are first-class nodes
* rendered as full cards inside this one via React Flow's native parentId,
- * so we don't need to subscribe to the actual child list here. */
+ * so we don't need to subscribe to the actual child list here.
+ * Selecting `nodes` stably avoids a new selector reference on every store
+ * update (React error #185 / Zustand + React 19 Object.is strictness). */
function useDescendantCount(nodeId: string): number {
- return useCanvasStore(
- useCallback((s) => countDescendants(nodeId, s.nodes), [nodeId])
- );
+ const nodes = useCanvasStore((s) => s.nodes);
+ return useMemo(() => countDescendants(nodeId, nodes), [nodeId, nodes]);
}
+/** Boolean flag used to drive min-size and NodeResizer dimensions.
+ * Selecting `nodes` stably avoids re-render loops (same issue as
+ * useDescendantCount). */
function useHasChildren(nodeId: string): boolean {
- return useCanvasStore(
- useCallback((s) => s.nodes.some((n) => n.data.parentId === nodeId), [nodeId])
- );
+ const nodes = useCanvasStore((s) => s.nodes);
+ return useMemo(() => nodes.some((n) => n.data.parentId === nodeId), [nodes, nodeId]);
}
/** Eject/extract arrow icon — visually distinct from delete ✕ */
diff --git a/canvas/src/components/__tests__/Canvas.a11y.test.tsx b/canvas/src/components/__tests__/Canvas.a11y.test.tsx
index 341a2c7aa..02d0dd71d 100644
--- a/canvas/src/components/__tests__/Canvas.a11y.test.tsx
+++ b/canvas/src/components/__tests__/Canvas.a11y.test.tsx
@@ -73,6 +73,8 @@ const mockStoreState = {
clearSelection: vi.fn(),
toggleNodeSelection: vi.fn(),
deletingIds: new Set(),
+ broadcastMessages: [],
+ consumeBroadcastMessages: vi.fn(() => []),
};
vi.mock("@/store/canvas", () => ({
@@ -100,6 +102,7 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
+vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
vi.mock("../settings", () => ({
diff --git a/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx b/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
index 76d9be781..8ce8d01a3 100644
--- a/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
+++ b/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
@@ -91,6 +91,8 @@ const mockStoreState = {
// an empty Set mirrors the idle canvas and doesn't interact with
// any pan/fit behaviour under test here.
deletingIds: new Set(),
+ broadcastMessages: [],
+ consumeBroadcastMessages: vi.fn(() => []),
};
vi.mock("@/store/canvas", () => ({
@@ -117,6 +119,7 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
+vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
vi.mock("../settings", () => ({
diff --git a/canvas/src/components/__tests__/ThemeToggle.test.tsx b/canvas/src/components/__tests__/ThemeToggle.test.tsx
index 08b875a4b..4128d3d70 100644
--- a/canvas/src/components/__tests__/ThemeToggle.test.tsx
+++ b/canvas/src/components/__tests__/ThemeToggle.test.tsx
@@ -24,12 +24,8 @@ vi.mock("@/lib/theme-provider", () => ({
})),
}));
-// Wrap cleanup in act() so any pending React state updates (e.g. from
-// keyDown handlers that call setTheme) flush before DOM unmount. Without
-// this, cleanup() can race against pending renders and cause INDEX_SIZE_ERR
-// when the handleKeyDown callback tries to query the DOM mid-teardown.
afterEach(() => {
- act(() => { cleanup(); });
+ cleanup();
vi.clearAllMocks();
});
@@ -150,7 +146,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// dark (index 2) is current; ArrowRight should wrap to light (index 0)
act(() => { radios[2].focus(); });
- act(() => { fireEvent.keyDown(radios[2], { key: "ArrowRight" }); });
+ fireEvent.keyDown(radios[2], { key: "ArrowRight" });
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
@@ -164,7 +160,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowLeft should go to dark (index 2)
act(() => { radios[0].focus(); });
- act(() => { fireEvent.keyDown(radios[0], { key: "ArrowLeft" }); });
+ fireEvent.keyDown(radios[0], { key: "ArrowLeft" });
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
@@ -178,7 +174,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowDown should go to system (index 1)
act(() => { radios[0].focus(); });
- act(() => { fireEvent.keyDown(radios[0], { key: "ArrowDown" }); });
+ fireEvent.keyDown(radios[0], { key: "ArrowDown" });
expect(mockSetTheme).toHaveBeenCalledWith("system");
});
@@ -191,7 +187,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
render();
const radios = screen.getAllByRole("radio");
act(() => { radios[2].focus(); });
- act(() => { fireEvent.keyDown(radios[2], { key: "Home" }); });
+ fireEvent.keyDown(radios[2], { key: "Home" });
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
@@ -204,14 +200,14 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
render();
const radios = screen.getAllByRole("radio");
act(() => { radios[0].focus(); });
- act(() => { fireEvent.keyDown(radios[0], { key: "End" }); });
+ fireEvent.keyDown(radios[0], { key: "End" });
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
it("does nothing on unrelated keys", () => {
render();
const radios = screen.getAllByRole("radio");
- act(() => { fireEvent.keyDown(radios[0], { key: "Enter" }); });
+ fireEvent.keyDown(radios[0], { key: "Enter" });
expect(mockSetTheme).not.toHaveBeenCalled();
});
});
diff --git a/canvas/src/components/canvas/DropTargetBadge.tsx b/canvas/src/components/canvas/DropTargetBadge.tsx
index 900b20124..1f2525525 100644
--- a/canvas/src/components/canvas/DropTargetBadge.tsx
+++ b/canvas/src/components/canvas/DropTargetBadge.tsx
@@ -24,16 +24,20 @@ import {
*/
export function DropTargetBadge() {
const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId);
- const targetName = useCanvasStore((s) => {
- if (!s.dragOverNodeId) return null;
- const n = s.nodes.find((nn) => nn.id === s.dragOverNodeId);
+ // Select nodes stably first — deriving targetName and childCount inside
+ // the same selector creates a new return value on every store mutation
+ // even when neither has changed (React error #185 / Zustand Object.is).
+ const nodes = useCanvasStore((s) => s.nodes);
+ const targetName = (() => {
+ if (!dragOverNodeId) return null;
+ const n = nodes.find((nn) => nn.id === dragOverNodeId);
return (n?.data as WorkspaceNodeData | undefined)?.name ?? null;
- });
- const childCount = useCanvasStore((s) =>
- !s.dragOverNodeId
+ })();
+ const childCount = (() =>
+ !dragOverNodeId
? 0
- : s.nodes.filter((n) => n.parentId === s.dragOverNodeId).length,
- );
+ : nodes.filter((n) => n.parentId === dragOverNodeId).length
+ )();
const { getInternalNode, flowToScreenPosition } = useReactFlow();
if (!dragOverNodeId || !targetName) return null;
const internal = getInternalNode(dragOverNodeId);
diff --git a/canvas/src/components/canvas/__tests__/useOrgDeployState.test.ts b/canvas/src/components/canvas/__tests__/useOrgDeployState.test.ts
new file mode 100644
index 000000000..421fcd42e
--- /dev/null
+++ b/canvas/src/components/canvas/__tests__/useOrgDeployState.test.ts
@@ -0,0 +1,311 @@
+/**
+ * Unit tests for buildDeployMap — the pure tree-traversal core of
+ * useOrgDeployState.
+ *
+ * What is tested here:
+ * - Root / leaf identification via parent-chain walk
+ * - isDeployingRoot: true when any descendant is "provisioning"
+ * - isActivelyProvisioning: true only for the node itself in that state
+ * - isLockedChild: true for non-root nodes in a deploying tree
+ * - isLockedChild: also true for nodes in deletingIds (even if not deploying)
+ * - descendantProvisioningCount: non-zero only on root nodes
+ * - Performance contract: O(n) single-pass walk — tested by verifying
+ * correctness across 50-node trees (n=50, all cases above)
+ *
+ * What is NOT tested here (hook integration — appropriate for E2E):
+ * - The useMemo / Zustand subscription wiring
+ * - React Flow integration (flowToScreenPosition, getInternalNode)
+ *
+ * Issue: #2071 (Canvas test gaps follow-up).
+ */
+import { describe, expect, it } from "vitest";
+import { buildDeployMap, type OrgDeployState } from "../useOrgDeployState";
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+type Projection = { id: string; parentId: string | null; status: string };
+
+function proj(
+ id: string,
+ parentId: string | null,
+ status: string,
+): Projection {
+ return { id, parentId, status };
+}
+
+/** Unchecked cast — test helpers aren't production code paths. */
+function m(
+ ps: Projection[],
+ deletingIds: string[] = [],
+): Map {
+ return buildDeployMap(ps, new Set(deletingIds));
+}
+
+function s(
+ map: Map,
+ id: string,
+): OrgDeployState {
+ const got = map.get(id);
+ if (!got) throw new Error(`no entry for id=${id}`);
+ return got;
+}
+
+// ── Empty / trivial ───────────────────────────────────────────────────────────
+
+describe("buildDeployMap — empty", () => {
+ it("returns empty map for empty projections", () => {
+ expect(m([]).size).toBe(0);
+ });
+});
+
+// ── Single node ─────────────────────────────────────────────────────────────
+
+describe("buildDeployMap — single node", () => {
+ it("isolated node is its own root and not deploying", () => {
+ const map = m([proj("a", null, "online")]);
+ expect(s(map, "a")).toEqual({
+ isActivelyProvisioning: false,
+ isDeployingRoot: false,
+ isLockedChild: false,
+ descendantProvisioningCount: 0,
+ });
+ });
+
+ it("isolated provisioning node is deploying root", () => {
+ const map = m([proj("a", null, "provisioning")]);
+ expect(s(map, "a")).toEqual({
+ isActivelyProvisioning: true,
+ isDeployingRoot: true,
+ isLockedChild: false,
+ descendantProvisioningCount: 1,
+ });
+ });
+});
+
+// ── Parent / child chains ─────────────────────────────────────────────────────
+
+describe("buildDeployMap — parent / child chains", () => {
+ it("root with online child: root is not deploying, child is not locked", () => {
+ // A ──► B
+ const map = m([
+ proj("A", null, "online"),
+ proj("B", "A", "online"),
+ ]);
+ expect(s(map, "A")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
+ expect(s(map, "B")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
+ });
+
+ it("root with provisioning child: root is deploying, child is locked", () => {
+ // A ──► B (B is provisioning)
+ const map = m([
+ proj("A", null, "online"),
+ proj("B", "A", "provisioning"),
+ ]);
+ expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
+ expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
+ });
+
+ it("provisioning root with online child: root is deploying, child is locked", () => {
+ // A (provisioning) ──► B (online)
+ const map = m([
+ proj("A", null, "provisioning"),
+ proj("B", "A", "online"),
+ ]);
+ expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, isActivelyProvisioning: true });
+ expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
+ });
+
+ it("grandchild inherits deploy lock through intermediate online node", () => {
+ // A ──► B ──► C (A is provisioning)
+ const map = m([
+ proj("A", null, "provisioning"),
+ proj("B", "A", "online"),
+ proj("C", "B", "online"),
+ ]);
+ // B and C are both non-root descendants of the deploying root
+ expect(s(map, "B")).toMatchObject({ isLockedChild: true });
+ expect(s(map, "C")).toMatchObject({ isLockedChild: true });
+ expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
+ });
+
+ it("deep chain: only the topmost node with a null parent counts as root", () => {
+ // A ──► B ──► C ──► D (A is provisioning)
+ const map = m([
+ proj("A", null, "provisioning"),
+ proj("B", "A", "online"),
+ proj("C", "B", "online"),
+ proj("D", "C", "online"),
+ ]);
+ const roots = ["A", "B", "C", "D"].filter((id) => s(map, id).isDeployingRoot);
+ expect(roots).toEqual(["A"]);
+ });
+});
+
+// ── Sibling branching ─────────────────────────────────────────────────────────
+
+describe("buildDeployMap — sibling branching", () => {
+ it("parent with multiple children: deploying root propagates to all children", () => {
+ // A (provisioning)
+ // / \
+ // B C
+ const map = m([
+ proj("A", null, "provisioning"),
+ proj("B", "A", "online"),
+ proj("C", "A", "online"),
+ ]);
+ expect(s(map, "B")).toMatchObject({ isLockedChild: true });
+ expect(s(map, "C")).toMatchObject({ isLockedChild: true });
+ expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 1 });
+ });
+
+ it("only one provisioning descendant marks the root as deploying", () => {
+ // A
+ // / | \
+ // B C D (only C is provisioning)
+ const map = m([
+ proj("A", null, "online"),
+ proj("B", "A", "online"),
+ proj("C", "A", "provisioning"),
+ proj("D", "A", "online"),
+ ]);
+ expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
+ expect(s(map, "B")).toMatchObject({ isLockedChild: true });
+ expect(s(map, "C")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
+ expect(s(map, "D")).toMatchObject({ isLockedChild: true });
+ });
+
+ it("two provisioning siblings: count reflects both", () => {
+ const map = m([
+ proj("A", null, "online"),
+ proj("B", "A", "provisioning"),
+ proj("C", "A", "provisioning"),
+ ]);
+ expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 2 });
+ expect(s(map, "B")).toMatchObject({ isActivelyProvisioning: true });
+ expect(s(map, "C")).toMatchObject({ isActivelyProvisioning: true });
+ });
+});
+
+// ── Multiple disjoint trees ───────────────────────────────────────────────────
+
+describe("buildDeployMap — multiple disjoint trees", () => {
+ it("each tree has its own root; deploying nodes are independent", () => {
+ // Tree 1: X (provisioning) ──► Y
+ // Tree 2: P ──► Q (no provisioning)
+ const map = m([
+ proj("X", null, "provisioning"),
+ proj("Y", "X", "online"),
+ proj("P", null, "online"),
+ proj("Q", "P", "online"),
+ ]);
+ expect(s(map, "X")).toMatchObject({ isDeployingRoot: true });
+ expect(s(map, "Y")).toMatchObject({ isLockedChild: true });
+ expect(s(map, "P")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
+ expect(s(map, "Q")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
+ });
+});
+
+// ── Deleting nodes ────────────────────────────────────────────────────────────
+
+describe("buildDeployMap — deletingIds", () => {
+ it("node in deletingIds is locked even if tree is not deploying", () => {
+ const map = m(
+ [
+ proj("A", null, "online"),
+ proj("B", "A", "online"),
+ ],
+ ["B"], // B is being deleted
+ );
+ expect(s(map, "A")).toMatchObject({ isLockedChild: false });
+ expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
+ });
+
+ it("node in deletingIds: isLockedChild is true regardless of provisioning", () => {
+ const map = m(
+ [
+ proj("A", null, "provisioning"),
+ proj("B", "A", "online"),
+ ],
+ ["B"],
+ );
+ // B is both a deploying-child AND a deleting node — either alone locks it
+ expect(s(map, "B")).toMatchObject({ isLockedChild: true });
+ });
+
+ it("empty deletingIds set has no effect", () => {
+ const map = m(
+ [
+ proj("A", null, "online"),
+ proj("B", "A", "online"),
+ ],
+ [],
+ );
+ expect(s(map, "B")).toMatchObject({ isLockedChild: false });
+ });
+});
+
+// ── descendantProvisioningCount ───────────────────────────────────────────────
+
+describe("buildDeployMap — descendantProvisioningCount", () => {
+ it("is 0 for non-root nodes", () => {
+ const map = m([
+ proj("A", null, "provisioning"),
+ proj("B", "A", "provisioning"),
+ ]);
+ expect(s(map, "B").descendantProvisioningCount).toBe(0);
+ });
+
+ it("includes the root's own status when provisioning", () => {
+ const map = m([
+ proj("A", null, "provisioning"),
+ proj("B", "A", "online"),
+ ]);
+ // A is both root and provisioning → count includes itself
+ expect(s(map, "A").descendantProvisioningCount).toBe(1);
+ });
+
+ it("accumulates all provisioning descendants (not just immediate children)", () => {
+ const map = m([
+ proj("A", null, "online"),
+ proj("B", "A", "online"),
+ proj("C", "B", "provisioning"),
+ ]);
+ expect(s(map, "A").descendantProvisioningCount).toBe(1);
+ });
+});
+
+// ── O(n) performance ─────────────────────────────────────────────────────────
+
+describe("buildDeployMap — O(n) performance contract", () => {
+ it("handles a 50-node three-level tree without incorrect node assignments", () => {
+ // Level 0: 1 root
+ // Level 1: 7 children
+ // Level 2: 42 leaves
+ // Total: 50 nodes
+ const projections: Projection[] = [];
+ projections.push(proj("root", null, "provisioning"));
+ for (let i = 0; i < 7; i++) {
+ projections.push(proj(`l1-${i}`, "root", "online"));
+ }
+ for (let i = 0; i < 42; i++) {
+ const parent = `l1-${Math.floor(i / 6)}`;
+ projections.push(proj(`l2-${i}`, parent, "online"));
+ }
+ const map = m(projections);
+
+ // Root is the only deploying node
+ expect(s(map, "root")).toMatchObject({
+ isDeployingRoot: true,
+ isLockedChild: false,
+ descendantProvisioningCount: 1,
+ });
+
+ // Every other node is a locked child
+ for (let i = 0; i < 7; i++) {
+ expect(s(map, `l1-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
+ }
+ for (let i = 0; i < 42; i++) {
+ expect(s(map, `l2-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
+ }
+ });
+});
diff --git a/canvas/src/components/canvas/useCanvasViewport.ts b/canvas/src/components/canvas/useCanvasViewport.ts
index b8007f1de..3ebd3a028 100644
--- a/canvas/src/components/canvas/useCanvasViewport.ts
+++ b/canvas/src/components/canvas/useCanvasViewport.ts
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useRef } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
import { useReactFlow } from "@xyflow/react";
import { useCanvasStore } from "@/store/canvas";
import { appendClass, removeClass } from "@/store/classNames";
@@ -153,10 +153,17 @@ export function useCanvasViewport() {
// fit, the user has to manually pan + zoom to find what they just
// created. Only fires when TRANSITIONING from some-provisioning to
// zero-provisioning — not on every re-render.
- const provisioningCount = useCanvasStore(
- (s) => s.nodes.filter((n) => n.data.status === "provisioning").length,
+ //
+ // Selecting `nodes` stably (array reference) avoids the
+ // `.filter().length` anti-pattern which creates a new number on every
+ // store update and breaks the wasProvisioning/hasProvisioning
+ // transition detection (React error #185 / Zustand + React 19).
+ const nodes = useCanvasStore((s) => s.nodes);
+ const provisioningCount = useMemo(
+ () => nodes.filter((n) => n.data.status === "provisioning").length,
+ [nodes],
);
- const nodeCount = useCanvasStore((s) => s.nodes.length);
+ const nodeCount = nodes.length;
useEffect(() => {
const hasProvisioning = provisioningCount > 0;
diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx
index a7078255b..878eeec01 100644
--- a/canvas/src/components/mobile/MobileChat.tsx
+++ b/canvas/src/components/mobile/MobileChat.tsx
@@ -5,7 +5,7 @@
// that the desktop ChatTab uses, but with a slimmer surface: no
// attachments, no A2A topology overlay, no conversation tracing.
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
@@ -36,6 +36,20 @@ interface A2AResponseShape {
error?: { message?: string };
}
+// Wire shape for GET /workspaces/:id/chat-history (chat_history.go → ChatHistoryResponse).
+interface ApiChatMessage {
+ id: string;
+ role: string; // "user" | "agent" | "system"
+ content: string;
+ timestamp: string;
+ attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }>;
+}
+
+interface ChatHistoryResponse {
+ messages: ApiChatMessage[];
+ reached_end: boolean;
+}
+
const formatTime = (date: Date) =>
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
@@ -49,7 +63,10 @@ export function MobileChat({
onBack: () => void;
}) {
const p = usePalette(dark);
- const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
+ // Selecting `nodes` stably avoids the `.find()` anti-pattern that
+ // creates a new return value on every store update (React error #185).
+ const nodes = useCanvasStore((s) => s.nodes);
+ const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
// Bootstrap from the canvas store's per-workspace message buffer so the
// user sees their prior thread on entry. The store is updated by the
// socket → ChatTab flows the desktop runs; on mobile we read from the
@@ -58,18 +75,14 @@ export function MobileChat({
// that creates a new [] reference on every store update when the key is
// absent, causing infinite re-render (React error #185).
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
- const [messages, setMessages] = useState(() =>
- (storedMessages ?? []).map((m) => ({
- id: m.id,
- role: "agent",
- text: m.content,
- ts: formatStoredTimestamp(m.timestamp),
- })),
- );
+ // Start empty — history is loaded via useEffect below.
+ const [messages, setMessages] = useState([]);
const [draft, setDraft] = useState("");
const [tab, setTab] = useState("my");
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true); // history is loading on mount
+ const [historyError, setHistoryError] = useState(null);
const scrollRef = useRef(null);
// Synchronous re-entry guard. `setSending(true)` schedules a state
// update but doesn't flush before a second tap can fire send() — a ref
@@ -77,6 +90,9 @@ export function MobileChat({
// double-send race a stale `sending` lets through.
const sendInFlightRef = useRef(false);
const composerRef = useRef(null);
+ // Guard: don't treat the initial store population as a live push.
+ // Set to false after the first render completes.
+ const initDoneRef = useRef(false);
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
// shrinks when the user deletes text, then size to scrollHeight up to
@@ -89,6 +105,75 @@ export function MobileChat({
el.style.height = `${next}px`;
}, [draft]);
+ // Fetch chat history on mount; keep merging live agentMessages while the
+ // panel is open. InitDoneRef prevents the initial store snapshot from
+ // triggering the live-merge path (the store buffer is populated by
+ // ChatTab on desktop, not on mobile — this effect loads history as the
+ // mobile-native path).
+ useEffect(() => {
+ let cancelled = false;
+
+ const mapApiMessage = (m: ApiChatMessage): ChatMessage => ({
+ id: m.id,
+ role: m.role === "user" ? "user" : "agent",
+ text: m.content,
+ ts: formatStoredTimestamp(m.timestamp),
+ });
+
+ const syncLive = () => {
+ const live = useCanvasStore.getState().agentMessages[agentId] ?? [];
+ if (live.length > 0) {
+ setMessages((prev) => {
+ const existingIds = new Set(prev.map((m) => m.id));
+ const newOnes = live
+ .filter((m) => !existingIds.has(m.id))
+ .map((m) => ({
+ id: m.id,
+ role: "agent" as const,
+ text: m.content,
+ ts: formatStoredTimestamp(m.timestamp),
+ }));
+ return newOnes.length > 0 ? [...prev, ...newOnes] : prev;
+ });
+ }
+ };
+
+ const bootstrap = async (): Promise<(() => void) | undefined> => {
+ setLoading(true);
+ setHistoryError(null);
+ try {
+ const res = await api.get(
+ `/workspaces/${agentId}/chat-history?limit=50`,
+ );
+ if (cancelled) return;
+ const initial = (res.messages ?? []).map(mapApiMessage);
+ setMessages(initial);
+ // Mark init done BEFORE marking loading=false so any store push
+ // that arrives in the same tick is treated as live, not init.
+ initDoneRef.current = true;
+ setLoading(false);
+ // Subscribe to live pushes after init is complete.
+ syncLive();
+ const unsubscribe = useCanvasStore.subscribe(syncLive);
+ return unsubscribe; // returned for cleanup
+ } catch (e) {
+ if (cancelled) return;
+ setHistoryError(e instanceof Error ? e.message : "Failed to load chat history");
+ setLoading(false);
+ initDoneRef.current = true;
+ return undefined;
+ }
+ };
+
+ let maybeUnsubscribe: (() => void) | undefined;
+ bootstrap().then((fn) => { maybeUnsubscribe = fn; });
+
+ return () => {
+ cancelled = true;
+ if (maybeUnsubscribe) maybeUnsubscribe();
+ };
+ }, [agentId]);
+
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@@ -308,7 +393,61 @@ export function MobileChat({
Agent Comms — peer-to-peer A2A traffic surfaces in the Comms tab.
)}
- {tab === "my" && messages.length === 0 && (
+ {tab === "my" && loading && (
+