diff --git a/src/__tests__/unit/canvas/liveNowPanel.test.tsx b/src/__tests__/unit/canvas/liveNowPanel.test.tsx new file mode 100644 index 0000000000..95c8d81258 --- /dev/null +++ b/src/__tests__/unit/canvas/liveNowPanel.test.tsx @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { MockInstance } from "vitest"; +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react";import { LiveNowPanel } from "@/app/org/[githubLogin]/connections/LiveNowPanel"; +import type { LiveNowRow } from "@/app/org/[githubLogin]/connections/useLiveNowItems"; +import { useCanvasChatStore } from "@/app/org/[githubLogin]/_state/canvasChatStore"; + +/** + * Component tests for the org-canvas "Live Now" panel. + * + * The panel is pure presentation over `LiveNowRow[]` props: these tests + * cover the empty-state precedence, collapse toggle, group-header + * hiding, and the two click paths (deep-link command vs. link fallback) + * without mounting the canvas. + */ + +// --------------------------------------------------------------------------- +// Factories +// --------------------------------------------------------------------------- + +function makeRow(overrides: Partial & Pick): LiveNowRow { + return { + nodeId: "", + canvasRef: "", + fallbackOnly: false, + link: "", + colorHex: "#f59e0b", + iconName: null, + order: 0, + running: null, + ...overrides, + }; +} + +const attentionRow = () => + makeRow({ + key: "node:feature:f1", + nodeId: "feature:f1", + canvasRef: "initiative:i1", + link: "/w/ws-alpha/tasks/t1", + title: "Halted Task", + label: "Halted", + iconName: "alert-triangle", + }); + +const runningRow = () => + makeRow({ + key: "node:feature:f2", + nodeId: "feature:f2", + canvasRef: "initiative:i1", + title: "Building Feature", + label: "Planner working", + colorHex: "#f59e0b", + running: { plannerRunning: true, agentsRunningCount: 0 }, + }); + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +let triggerDeeplink: ReturnType; +let windowOpen: MockInstance; + +beforeEach(() => { + triggerDeeplink = vi.fn(); + // The panel reads `triggerDeeplink` from the shared canvas chat store + // (same command channel the chat's deeplink chips use) — swap in a + // spy for the duration of each test. + useCanvasChatStore.setState({ triggerDeeplink }); + windowOpen = vi.spyOn(window, "open").mockReturnValue(null); +}); + +afterEach(() => { + windowOpen.mockRestore(); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("LiveNowPanel — empty state", () => { + it("renders nothing at all when the row list is empty", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); +}); + +describe("LiveNowPanel — collapse toggle", () => { + it("collapses to the header and re-expands", () => { + render( + , + ); + + const toggle = screen.getByRole("button", { name: /live now/i }); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("Halted Task")).toBeTruthy(); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("Halted Task")).toBeNull(); + expect(screen.queryByText("Building Feature")).toBeNull(); + // Header (with count) stays visible while collapsed. + expect(screen.getByText("Live Now")).toBeTruthy(); + + fireEvent.click(toggle); + expect(screen.getByText("Halted Task")).toBeTruthy(); + expect(screen.getByText("Building Feature")).toBeTruthy(); + }); +}); + +describe("LiveNowPanel — groups", () => { + it("hides a group header when that group has no rows", () => { + render( + , + ); + + expect(screen.getByText("Running")).toBeTruthy(); + expect(screen.queryByText("Needs you")).toBeNull(); + }); + + it("shows both group headers in order when both groups have rows", () => { + render( + , + ); + + const needsYou = screen.getByText("Needs you"); + const running = screen.getByText("Running"); + // "Needs you" precedes "Running" in DOM order. + expect(needsYou.compareDocumentPosition(running) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("shows the +N more overflow hint", () => { + render( + , + ); + expect(screen.getByText("+3 more")).toBeTruthy(); + }); +}); + +describe("LiveNowPanel — row clicks", () => { + it("fallbackOnly row opens its link in a new tab and does NOT dispatch a deep link", () => { + const fallbackRow = makeRow({ + key: "item:t9", + nodeId: "", // task with no parent feature — nothing to focus + fallbackOnly: true, + link: "/w/ws-alpha/tasks/t9", + title: "Orphan Task", + label: "Halted", + iconName: "alert-triangle", + }); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Orphan Task/ })); + + expect(windowOpen).toHaveBeenCalledTimes(1); + expect(windowOpen).toHaveBeenCalledWith( + "/w/ws-alpha/tasks/t9", + "_blank", + "noopener,noreferrer", + ); + expect(triggerDeeplink).not.toHaveBeenCalled(); + }); + + it("non-fallback row dispatches the deep-link command through the store", () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Halted Task/ })); + + expect(triggerDeeplink).toHaveBeenCalledTimes(1); + expect(triggerDeeplink).toHaveBeenCalledWith({ + nodeId: "feature:f1", + canvasRef: "initiative:i1", + label: "Halted Task", + }); + expect(windowOpen).not.toHaveBeenCalled(); + }); + + it("running row dispatches a deep link (focus-first, no fallback link)", () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Building Feature/ })); + + expect(triggerDeeplink).toHaveBeenCalledWith({ + nodeId: "feature:f2", + canvasRef: "initiative:i1", + label: "Building Feature", + }); + expect(windowOpen).not.toHaveBeenCalled(); + }); +}); + +describe("LiveNowPanel — running indicators", () => { + it("shows a secondary running indicator on an attention row with concurrent live activity", () => { + const concurrent = attentionRow(); + concurrent.running = { plannerRunning: false, agentsRunningCount: 2 }; + render( + , + ); + + // Secondary indicator is titled with the formatted running label. + expect(screen.getByTitle("2 agents running")).toBeTruthy(); + // The row's primary label is still the attention signal's. + expect(screen.getByText("Halted")).toBeTruthy(); + }); +}); + +describe("LiveNowPanel — freshness footer", () => { + it("renders a relative 'updated Ns ago' footer once the ticker starts", async () => { + render( + , + ); + + // The ticker effect fills `now` in right after mount. + expect(await screen.findByText(/Updated 5s ago/)).toBeTruthy(); + }); +}); diff --git a/src/__tests__/unit/canvas/nodeDeepLink.test.ts b/src/__tests__/unit/canvas/nodeDeepLink.test.ts index abaf0b34df..f2c0e99f32 100644 --- a/src/__tests__/unit/canvas/nodeDeepLink.test.ts +++ b/src/__tests__/unit/canvas/nodeDeepLink.test.ts @@ -1,242 +1,302 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { computeNodeFocusZoom } from "@/lib/canvas/nodeZoom"; +import type { SystemCanvasHandle, CanvasData } from "system-canvas-react"; +import { + runDeeplinkNavigation, + type RunDeeplinkNavigationArgs, +} from "@/app/org/[githubLogin]/connections/deeplinkNavigation"; /** - * Unit tests for the `?node=` deep-link behaviour in OrgCanvasBackground. + * Unit tests for the org canvas's deep-link navigation. * - * Rather than mounting the full component (which has heavyweight deps on - * system-canvas-react, Pusher, NextAuth, etc.), we extract the pure logic - * helpers and test them in isolation — the same pattern used by - * `whiteboard-auto-fit.test.ts`. + * These tests exercise the REAL exported `runDeeplinkNavigation` — the + * helper extracted from `OrgCanvasBackground`'s `pendingDeeplink` + * effect and shared by chat deeplink chips and the Live Now panel. + * (The suite previously replicated the effect body inline, so a + * regression in the live function would not fail CI; it now imports + * the production symbol via the leaf `deeplinkNavigation` module, which + * has no heavyweight imports.) + * + * The navigation runs in a node environment here: `waitForCanvasSwap` + * falls back to a macrotask when `requestAnimationFrame` is absent, + * mirroring how the browser path settles after `navigateToRoot()`. */ // --------------------------------------------------------------------------- -// Helpers that replicate the pure logic from OrgCanvasBackground +// Harness // --------------------------------------------------------------------------- -interface CanvasHandle { - zoomIntoNode: ( - id: string, - opts?: { targetZoom?: number; durationMs?: number }, - ) => Promise; +interface FakeNode { + id: string; + width?: number; } -interface CanvasData { - nodes: Array<{ id: string; width?: number }>; +/** Minimal canvas-data stand-in — only `nodes` matter to the helper. */ +interface FakeCanvas { + nodes: FakeNode[]; } -/** - * Replicates the `scrollToNode` callback body from OrgCanvasBackground. - */ -function runScrollToNode( - nodeId: string, - handle: CanvasHandle | null, - canvasData: CanvasData | null, - containerWidth: number, -): void { - if (!nodeId || !handle) return; - const node = canvasData?.nodes.find((n) => n.id === nodeId); - const targetZoom = computeNodeFocusZoom(node?.width ?? 260, containerWidth); - void handle - .zoomIntoNode(nodeId, { targetZoom, durationMs: 600 }) - .catch(() => { - // stale link — silent no-op - }); +interface FakeHandle { + zoomIntoNode: ReturnType; + navigateToRoot: ReturnType; + navigateBack: ReturnType; } -/** - * Replicates the root-canvas branch of the deep-link useEffect: - * no `?canvas=` present — call scrollToNode directly after settling. - */ -function runDeepLinkEffect_rootCanvas( - root: CanvasData | null, - pendingCanvasRef: string, - pendingNodeRef: string, - handle: CanvasHandle | null, - setDeepLinkInFlight: (v: boolean) => void, - containerWidth: number, -): void { - if (!root) return; - // Root canvas branch: targetRef is empty - if (pendingCanvasRef === "") { - setDeepLinkInFlight(false); - if (pendingNodeRef) { - runScrollToNode(pendingNodeRef, handle, root, containerWidth); - } - } +function makeHandle(): FakeHandle { + return { + zoomIntoNode: vi.fn().mockResolvedValue(undefined), + navigateToRoot: vi.fn(), + navigateBack: vi.fn(), + }; +} + +function asHandle(fake: FakeHandle): SystemCanvasHandle { + return fake as unknown as SystemCanvasHandle; +} + +interface ArgsOverrides { + currentRef?: string; + target?: { nodeId: string; canvasRef: string; label?: string }; + /** Canvas data returned by `getCanvasData`, keyed by ref ("" = root). */ + canvases?: Record; + containerWidth?: number; + handle?: FakeHandle; } /** - * Replicates the sub-canvas branch of the deep-link useEffect: - * `?canvas=` is present — navigate first, then scrollToNode. + * Builds helper args around a FakeHandle. Returns the raw fake (for + * call-order assertions) plus ready-to-run args; `getCanvasData` casts + * the stand-ins to `CanvasData` at the boundary — the helper only reads + * `nodes[].id` / `nodes[].width`. */ -async function runDeepLinkEffect_subCanvas( - root: CanvasData | null, - pendingCanvasRef: string, - pendingNodeRef: string, - handle: CanvasHandle | null, - subCanvasData: CanvasData | null, - setDeepLinkInFlight: (v: boolean) => void, - containerWidth: number, -): Promise { - if (!root || !handle) return; - try { - await handle.zoomIntoNode(pendingCanvasRef, { durationMs: 0 }); - if (pendingNodeRef) { - runScrollToNode(pendingNodeRef, handle, subCanvasData, containerWidth); - } - } catch (err) { - // stale canvas ref — silent - } finally { - setDeepLinkInFlight(false); - } +function makeArgs({ + currentRef = "", + target = { nodeId: "feature:1", canvasRef: "initiative:B" }, + canvases = {}, + containerWidth = 800, + handle = makeHandle(), +}: ArgsOverrides = {}): { fake: FakeHandle; args: RunDeeplinkNavigationArgs } { + return { + fake: handle, + args: { + handle: asHandle(handle), + currentRef, + target, + getCanvasData: (ref: string) => + (canvases[ref] as unknown as CanvasData | undefined) ?? null, + containerWidth, + }, + }; } // --------------------------------------------------------------------------- -// Tests +// runDeeplinkNavigation — real symbol // --------------------------------------------------------------------------- -describe("scrollToNode — root canvas (no ?canvas= present)", () => { - let zoomIntoNode: ReturnType; - let setDeepLinkInFlight: ReturnType; - let handle: CanvasHandle; - const root: CanvasData = { nodes: [{ id: "initiative:abc", width: 320 }] }; +describe("runDeeplinkNavigation — same-ref no-op", () => { + it("skips scope navigation entirely when already on the target ref", async () => { + const { fake: handle, args } = makeArgs({ + currentRef: "initiative:A", + target: { nodeId: "feature:1", canvasRef: "initiative:A" }, + canvases: { + "initiative:A": { nodes: [{ id: "feature:1", width: 280 }] }, + }, + }); + + await expect(runDeeplinkNavigation(args)).resolves.toBe(true); - beforeEach(() => { - zoomIntoNode = vi.fn().mockResolvedValue(undefined); - setDeepLinkInFlight = vi.fn(); - handle = { zoomIntoNode }; + expect(handle.navigateToRoot).not.toHaveBeenCalled(); + // Only the node focus zoom — no drill-in call. + expect(handle.zoomIntoNode).toHaveBeenCalledTimes(1); + expect(handle.zoomIntoNode).toHaveBeenCalledWith( + "feature:1", + expect.objectContaining({ durationMs: 600 }), + ); }); - it("calls zoomIntoNode with the node ID when ?node= is present and no ?canvas=", () => { - runDeepLinkEffect_rootCanvas(root, "", "initiative:abc", handle, setDeepLinkInFlight, 816); + it("is a no-op when handle is missing or nodeId is empty", async () => { + const { fake: handle, args } = makeArgs({ + target: { nodeId: "", canvasRef: "" }, + }); - expect(zoomIntoNode).toHaveBeenCalledTimes(1); - expect(zoomIntoNode).toHaveBeenCalledWith( - "initiative:abc", + await expect( + runDeeplinkNavigation({ + ...args, + handle: undefined as unknown as SystemCanvasHandle, + }), + ).resolves.toBe(false); + await expect(runDeeplinkNavigation(args)).resolves.toBe(false); + + expect(handle.zoomIntoNode).not.toHaveBeenCalled(); + expect(handle.navigateToRoot).not.toHaveBeenCalled(); + }); +}); + +describe("runDeeplinkNavigation — cross-scope jump from a sibling sub-canvas", () => { + it("calls navigateToRoot() before zoomIntoNode(canvasRef), then focuses the node", async () => { + const { fake: handle, args } = makeArgs({ + currentRef: "initiative:A", + target: { nodeId: "feature:2", canvasRef: "initiative:B" }, + canvases: { + "initiative:B": { nodes: [{ id: "feature:2", width: 250 }] }, + }, + }); + const order: string[] = []; + handle.navigateToRoot.mockImplementation(() => { + order.push("navigateToRoot"); + }); + handle.zoomIntoNode.mockImplementation((id: string) => { + order.push(`zoom:${id}`); + return Promise.resolve(); + }); + + await expect(runDeeplinkNavigation(args)).resolves.toBe(true); + + // Ordering is load-bearing: zoomIntoNode resolves against the + // currently mounted canvas, so the root climb must happen first. + expect(order).toEqual([ + "navigateToRoot", + "zoom:initiative:B", + "zoom:feature:2", + ]); + // Drill-in keeps the chip path's 300ms camera animation. + expect(handle.zoomIntoNode).toHaveBeenNthCalledWith( + 1, + "initiative:B", + { durationMs: 300 }, + ); + expect(handle.zoomIntoNode).toHaveBeenNthCalledWith( + 2, + "feature:2", expect.objectContaining({ durationMs: 600 }), ); }); - it("sets deepLinkInFlight to false before calling scrollToNode", () => { - const callOrder: string[] = []; - setDeepLinkInFlight = vi.fn(() => callOrder.push("setFlight")); - zoomIntoNode = vi.fn(() => { callOrder.push("zoom"); return Promise.resolve(); }); - handle = { zoomIntoNode }; + it("does NOT call navigateToRoot when starting from root", async () => { + const { fake: handle, args } = makeArgs({ + currentRef: "", + target: { nodeId: "feature:2", canvasRef: "initiative:B" }, + canvases: { + "initiative:B": { nodes: [{ id: "feature:2", width: 250 }] }, + }, + }); - runDeepLinkEffect_rootCanvas(root, "", "initiative:abc", handle, setDeepLinkInFlight, 816); + await expect(runDeeplinkNavigation(args)).resolves.toBe(true); - // setDeepLinkInFlight(false) should be called before zoomIntoNode - expect(callOrder[0]).toBe("setFlight"); - expect(callOrder[1]).toBe("zoom"); + expect(handle.navigateToRoot).not.toHaveBeenCalled(); + expect(handle.zoomIntoNode).toHaveBeenCalledTimes(2); }); - it("is a no-op when ?node= is empty string", () => { - runDeepLinkEffect_rootCanvas(root, "", "", handle, setDeepLinkInFlight, 816); + it("computes targetZoom from the LANDED canvas, not the pre-navigation one", async () => { + // `feature:2` exists on BOTH scopes with different widths: the old + // stale-closure bug would read canvas A's width (1000 → zoom 0.4) + // instead of the landed canvas B's (250 → zoom 1.6). + const { fake: handle, args } = makeArgs({ + currentRef: "initiative:A", + target: { nodeId: "feature:2", canvasRef: "initiative:B" }, + canvases: { + "initiative:A": { nodes: [{ id: "feature:2", width: 1000 }] }, + "initiative:B": { nodes: [{ id: "feature:2", width: 250 }] }, + }, + containerWidth: 1000, + }); - expect(zoomIntoNode).not.toHaveBeenCalled(); - }); + await expect(runDeeplinkNavigation(args)).resolves.toBe(true); - it("is a no-op when handle is null", () => { - runScrollToNode("initiative:abc", null, root, 816); - expect(zoomIntoNode).not.toHaveBeenCalled(); + expect(handle.zoomIntoNode).toHaveBeenNthCalledWith( + 2, + "feature:2", + expect.objectContaining({ targetZoom: 1.6, durationMs: 600 }), + ); }); - it("is a no-op when root is null (effect guard)", () => { - runDeepLinkEffect_rootCanvas(null, "", "initiative:abc", handle, setDeepLinkInFlight, 816); - expect(zoomIntoNode).not.toHaveBeenCalled(); + it("climbs to root when the target IS the root canvas (empty canvasRef)", async () => { + const { fake: handle, args } = makeArgs({ + currentRef: "initiative:A", + target: { nodeId: "initiative:abc", canvasRef: "" }, + canvases: { + "": { nodes: [{ id: "initiative:abc", width: 320 }] }, + }, + }); + + await expect(runDeeplinkNavigation(args)).resolves.toBe(true); + + expect(handle.navigateToRoot).toHaveBeenCalledTimes(1); + // No drill-in (target is root) — only the node focus zoom fires. + expect(handle.zoomIntoNode).toHaveBeenCalledTimes(1); + expect(handle.zoomIntoNode).toHaveBeenCalledWith( + "initiative:abc", + expect.objectContaining({ durationMs: 600 }), + ); }); }); -describe("scrollToNode — sub-canvas (?canvas= and ?node= both present)", () => { - let zoomIntoNode: ReturnType; - let setDeepLinkInFlight: ReturnType; - let handle: CanvasHandle; - const root: CanvasData = { nodes: [{ id: "initiative:xyz", width: 280 }] }; - const subCanvas: CanvasData = { nodes: [{ id: "feature:123", width: 240 }] }; - - beforeEach(() => { - zoomIntoNode = vi.fn().mockResolvedValue(undefined); - setDeepLinkInFlight = vi.fn(); - handle = { zoomIntoNode }; - }); +describe("runDeeplinkNavigation — missed focus resolves cleanly", () => { + it("resolves false (no throw) when the node is not on the landed canvas", async () => { + const { fake: handle, args } = makeArgs({ + currentRef: "initiative:A", + target: { nodeId: "feature:999", canvasRef: "initiative:B" }, + canvases: { + // B exists but the node was removed / never pinned there. + "initiative:B": { nodes: [{ id: "feature:2", width: 250 }] }, + }, + }); - it("navigates to sub-canvas first, then calls zoomIntoNode for the target node", async () => { - await runDeepLinkEffect_subCanvas( - root, - "initiative:xyz", - "feature:123", - handle, - subCanvas, - setDeepLinkInFlight, - 816, - ); + await expect(runDeeplinkNavigation(args)).resolves.toBe(false); - expect(zoomIntoNode).toHaveBeenCalledTimes(2); - // First call: drill into sub-canvas (durationMs: 0) - expect(zoomIntoNode).toHaveBeenNthCalledWith(1, "initiative:xyz", { durationMs: 0 }); - // Second call: scroll to node (durationMs: 600) - expect(zoomIntoNode).toHaveBeenNthCalledWith( - 2, - "feature:123", - expect.objectContaining({ durationMs: 600 }), + // The drill-in happened, but no node zoom was attempted. + expect(handle.zoomIntoNode).toHaveBeenCalledTimes(1); + expect(handle.zoomIntoNode).toHaveBeenCalledWith( + "initiative:B", + { durationMs: 300 }, ); }); - it("does not call node zoom when ?node= is empty", async () => { - await runDeepLinkEffect_subCanvas( - root, - "initiative:xyz", - "", - handle, - subCanvas, - setDeepLinkInFlight, - 816, - ); + it("resolves false when the drill-in ref does not resolve to a canvas node", async () => { + const { fake: handle, args } = makeArgs({ + currentRef: "", + target: { nodeId: "feature:1", canvasRef: "initiative:deleted" }, + canvases: { + // Stale scope — nothing on root carries the ref, so the lib's + // zoomIntoNode resolves without navigating and the lookup misses. + "": { nodes: [{ id: "note:1" }] }, + }, + }); - // Only the canvas nav call - expect(zoomIntoNode).toHaveBeenCalledTimes(1); - expect(zoomIntoNode).toHaveBeenCalledWith("initiative:xyz", { durationMs: 0 }); + await expect(runDeeplinkNavigation(args)).resolves.toBe(false); + expect(handle.zoomIntoNode).toHaveBeenCalledTimes(1); }); - it("swallows zoomIntoNode rejection silently (stale node link)", async () => { - zoomIntoNode = vi - .fn() - .mockResolvedValueOnce(undefined) // first call (canvas nav) resolves - .mockRejectedValueOnce(new Error("node not found")); // second call (node) rejects - handle = { zoomIntoNode }; + it("swallows a rejected node zoom (stale link) and resolves false", async () => { + const { fake: handle, args } = makeArgs({ + currentRef: "initiative:A", + target: { nodeId: "feature:1", canvasRef: "initiative:A" }, + canvases: { + "initiative:A": { nodes: [{ id: "feature:1", width: 280 }] }, + }, + }); + handle.zoomIntoNode.mockRejectedValue(new Error("node not found")); - await expect( - runDeepLinkEffect_subCanvas( - root, - "initiative:xyz", - "feature:999", - handle, - subCanvas, - setDeepLinkInFlight, - 816, - ), - ).resolves.toBeUndefined(); + await expect(runDeeplinkNavigation(args)).resolves.toBe(false); }); - it("still calls setDeepLinkInFlight(false) even when canvas nav rejects", async () => { - zoomIntoNode = vi.fn().mockRejectedValue(new Error("deleted initiative")); - handle = { zoomIntoNode }; - - await runDeepLinkEffect_subCanvas( - root, - "initiative:deleted", - "feature:123", - handle, - subCanvas, - setDeepLinkInFlight, - 816, - ); + it("swallows a rejected drill-in and resolves false", async () => { + const { fake: handle, args } = makeArgs({ + currentRef: "", + target: { nodeId: "feature:1", canvasRef: "initiative:gone" }, + canvases: {}, + }); + handle.zoomIntoNode.mockRejectedValue(new Error("canvas gone")); - expect(setDeepLinkInFlight).toHaveBeenCalledWith(false); + await expect(runDeeplinkNavigation(args)).resolves.toBe(false); + expect(handle.navigateToRoot).not.toHaveBeenCalled(); }); }); +// --------------------------------------------------------------------------- +// targetZoom derivation (unchanged pure helper) +// --------------------------------------------------------------------------- + describe("targetZoom derivation from node width and container width", () => { it("derives correct targetZoom from node found in canvas data", () => { // node width=320, container=800 → 0.4*800/320 = 1.0 @@ -255,123 +315,3 @@ describe("targetZoom derivation from node width and container width", () => { expect(computeNodeFocusZoom(10000, 800)).toBe(0.5); }); }); - -// --------------------------------------------------------------------------- -// Chip-triggered cross-scope deeplink navigation -// --------------------------------------------------------------------------- - -/** - * Replicates the `pendingDeeplink` useEffect body from OrgCanvasBackground. - * Cross-scope path: canvasRef !== currentRef → zoomIntoNode(canvasRef) first, - * then scrollToNode(nodeId). - */ -async function runDeeplinkChipEffect( - pendingDeeplink: { nodeId: string; canvasRef: string } | null, - currentRef: string, - handle: CanvasHandle | null, - canvasData: CanvasData | null, - containerWidth: number, - clearDeeplink: () => void, -): Promise { - if (!pendingDeeplink || !handle) return; - const { nodeId, canvasRef } = pendingDeeplink; - - const doNavigate = - canvasRef && canvasRef !== currentRef - ? handle - .zoomIntoNode(canvasRef, { durationMs: 300 }) - .then(() => runScrollToNode(nodeId, handle, canvasData, containerWidth)) - : Promise.resolve().then(() => - runScrollToNode(nodeId, handle, canvasData, containerWidth), - ); - - await doNavigate.finally(() => clearDeeplink()); -} - -describe("CanvasDeeplinkChip — cross-scope navigation (canvasRef !== currentRef)", () => { - let zoomIntoNode: ReturnType; - let clearDeeplink: ReturnType; - let handle: CanvasHandle; - const subCanvas: CanvasData = { nodes: [{ id: "feature:456", width: 280 }] }; - - beforeEach(() => { - zoomIntoNode = vi.fn().mockResolvedValue(undefined); - clearDeeplink = vi.fn(); - handle = { zoomIntoNode }; - }); - - it("calls zoomIntoNode(canvasRef) first, then zoomIntoNode(nodeId) after promise resolves", async () => { - await runDeeplinkChipEffect( - { nodeId: "feature:456", canvasRef: "initiative:xyz" }, - /* currentRef = */ "", - handle, - subCanvas, - 816, - clearDeeplink, - ); - - expect(zoomIntoNode).toHaveBeenCalledTimes(2); - expect(zoomIntoNode).toHaveBeenNthCalledWith(1, "initiative:xyz", { - durationMs: 300, - }); - expect(zoomIntoNode).toHaveBeenNthCalledWith( - 2, - "feature:456", - expect.objectContaining({ durationMs: 600 }), - ); - }); - - it("calls clearDeeplink in finally after successful navigation", async () => { - await runDeeplinkChipEffect( - { nodeId: "feature:456", canvasRef: "initiative:xyz" }, - "", - handle, - subCanvas, - 816, - clearDeeplink, - ); - - expect(clearDeeplink).toHaveBeenCalledTimes(1); - }); - - it("calls clearDeeplink even when canvas nav rejects", async () => { - zoomIntoNode = vi.fn().mockRejectedValue(new Error("canvas gone")); - handle = { zoomIntoNode }; - - await runDeeplinkChipEffect( - { nodeId: "feature:456", canvasRef: "initiative:deleted" }, - "", - handle, - subCanvas, - 816, - clearDeeplink, - ).catch(() => {}); - - expect(clearDeeplink).toHaveBeenCalledTimes(1); - }); - - it("skips canvas nav when canvasRef matches currentRef (same-scope)", async () => { - await runDeeplinkChipEffect( - { nodeId: "feature:456", canvasRef: "initiative:xyz" }, - /* currentRef = */ "initiative:xyz", - handle, - subCanvas, - 816, - clearDeeplink, - ); - - // Only scrollToNode fires — no canvas nav - expect(zoomIntoNode).toHaveBeenCalledTimes(1); - expect(zoomIntoNode).toHaveBeenCalledWith( - "feature:456", - expect.objectContaining({ durationMs: 600 }), - ); - }); - - it("is a no-op when pendingDeeplink is null", async () => { - await runDeeplinkChipEffect(null, "", handle, subCanvas, 816, clearDeeplink); - - expect(zoomIntoNode).not.toHaveBeenCalled(); - expect(clearDeeplink).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/org/[githubLogin]/connections/LiveNowPanel.tsx b/src/app/org/[githubLogin]/connections/LiveNowPanel.tsx new file mode 100644 index 0000000000..c5ba448d95 --- /dev/null +++ b/src/app/org/[githubLogin]/connections/LiveNowPanel.tsx @@ -0,0 +1,437 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import type { AttentionTypeMeta } from "@/services/attention/typeMeta"; +import { getAttentionDOMIcon } from "@/services/attention/typeMeta"; +import { + LIVE_NOW_GROUP_LABELS, + formatRunningLabel, + liveNowGroupOf, + type LiveNowRow, +} from "./useLiveNowItems"; +import { useCanvasChatStore } from "../_state/canvasChatStore"; + +/** + * Live Now panel — a compact, collapsible overlay docked on the org + * canvas that answers "what needs me, and what's running?" at a glance. + * + * Pure presentation over the ranked rows from `useLiveNowItems`: no + * fetching, no polling, no canvas mutation. The parent + * (`OrgCanvasBackground`) mounts it as a sibling of `HiddenLivePill` + * (which owns `top:16, right:16, zIndex:25`); this panel stacks + * directly beneath it at `top:60, right:16, zIndex:24`, inside the + * canvas container so it tracks the same chat-sidebar `rightInset`. + * + * Behavior contract: + * - **Empty list ⇒ renders nothing at all** — not a collapsed stub. + * This precedence is absolute and overrides any expand state, + * matching the brief's "gets out of the way when nothing is active" + * and `HiddenLivePill`'s zero-chrome default. + * - Two labeled groups ("Needs you", "Running"); a group with no rows + * hides its own header. The labels are load-bearing: the attention + * feed is ownership-filtered ("mine") while running activity covers + * every projected feature ("everyone's"). + * - Collapse is a single in-memory `useState` — no `localStorage` + * persistence (intentionally out of scope). + * - Muted footer shows `lastUpdatedAt` as a relative "updated Ns ago" + * string. The attention map is Pusher-driven with a 2s trailing + * debounce plus a 30s interval poll, so a row can trail reality by + * up to ~30s — the panel shows its own freshness rather than + * implying hard realtime. + * - Row click: `fallbackOnly` rows (no guaranteed canvas target) open + * the row's workspace-scoped `link` in a new tab; everything else + * dispatches a `pendingDeeplink` command through the canvas chat + * store, consumed by `runDeeplinkNavigation` in + * `OrgCanvasBackground` — the same code path chat deeplink chips + * use. No `onFocus` prop drilling. + */ + +export interface LiveNowPanelProps { + /** Ranked rows from `useLiveNowItems` (already capped at 12). */ + rows: readonly LiveNowRow[]; + /** Rows cut by the cap — drives the "+N more" hint. */ + overflowCount: number; + /** Wall-clock ms of the last attention refresh (0 until first fetch). */ + lastUpdatedAt: number; +} + +type AttentionIconName = AttentionTypeMeta["iconName"]; +type AttentionIconComponent = React.ComponentType< + React.SVGProps +>; + +// Glass palette shared with HiddenLivePill so the two overlays read as +// siblings on the dark canvas background (#15171c). +const PANEL_BG = "rgba(21, 23, 28, 0.95)"; +const PANEL_BORDER = "1px solid rgba(255, 255, 255, 0.08)"; +const PANEL_SHADOW = "0 8px 24px rgba(0, 0, 0, 0.35)"; +const TEXT_PRIMARY = "rgba(255, 255, 255, 0.9)"; +const TEXT_MUTED = "rgba(255, 255, 255, 0.5)"; +const FONT_FAMILY = + "'Inter', 'SF Pro Text', 'Helvetica Neue', system-ui, sans-serif"; + +/** Tiny spinning circle — running activity indicator. */ +function MiniSpinner({ color, title }: { color: string; title?: string }) { + return ( + + ); +} + +/** Secondary inline running signal on a row whose node is also live. */ +function RunningIndicator({ + running, +}: { + running: { plannerRunning: boolean; agentsRunningCount: number }; +}) { + if (!running.plannerRunning && running.agentsRunningCount === 0) return null; + return ( + + + {running.agentsRunningCount > 0 && {running.agentsRunningCount}} + + ); +} + +export function LiveNowPanel({ + rows, + overflowCount, + lastUpdatedAt, +}: LiveNowPanelProps) { + const [collapsed, setCollapsed] = useState(false); + const triggerDeeplink = useCanvasChatStore((s) => s.triggerDeeplink); + + // One-second ticker for the "updated Ns ago" footer. Starts null so + // server-rendered markup matches the first client render; the effect + // below fills it in immediately after mount. + const [now, setNow] = useState(null); + useEffect(() => { + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + + // Resolve attention glyphs through the shared `getAttentionDOMIcon` + // adapter (async — lazily imports lucide-react). Only the icon names + // actually present in `rows` are requested; there are at most the + // three canonical ones. Until a glyph arrives, the row falls back to + // a status-colored dot — color arrives instantly, the glyph lands a + // frame later. + const iconNames = useMemo(() => { + const names = new Set(); + for (const row of rows) { + if (row.iconName) names.add(row.iconName); + } + return [...names]; + }, [rows]); + + const [icons, setIcons] = useState< + Partial> + >({}); + + useEffect(() => { + if (iconNames.length === 0) return; + let cancelled = false; + void Promise.all( + iconNames.map(async (name) => ({ + name, + icon: await getAttentionDOMIcon(name), + })), + ) + .then((loaded) => { + if (cancelled) return; + setIcons((prev) => { + let changed = false; + const next = { ...prev }; + for (const { name, icon } of loaded) { + if (next[name] !== icon) { + next[name] = icon; + changed = true; + } + } + return changed ? next : prev; + }); + }) + .catch(() => { + // Glyph load failure — rows keep the colored-dot fallback. + }); + return () => { + cancelled = true; + }; + }, [iconNames]); + + // Zero chrome when nothing is active — absolute precedence over the + // user's expand state. (Hooks above run unconditionally; this early + // return only fires when the panel has nothing to show.) + if (rows.length === 0) return null; + + const needsYou = rows.filter((r) => liveNowGroupOf(r) === "needs-you"); + const running = rows.filter((r) => liveNowGroupOf(r) === "running"); + + const secondsAgo = + lastUpdatedAt > 0 && now !== null + ? Math.max(0, Math.floor((now - lastUpdatedAt) / 1000)) + : null; + const footerText = + secondsAgo === null + ? "Updated just now" + : secondsAgo < 60 + ? `Updated ${secondsAgo}s ago` + : `Updated ${Math.floor(secondsAgo / 60)}m ${secondsAgo % 60}s ago`; + + function handleRowClick(row: LiveNowRow) { + // Rows without a guaranteed canvas target (task with no parent + // feature; `ws:`-ref rows where pinning is unverifiable) open the + // workspace-scoped link instead of dispatching a deep link — the + // click is never a dead end. + if (row.fallbackOnly || !row.nodeId) { + if (row.link) { + window.open(row.link, "_blank", "noopener,noreferrer"); + } + return; + } + triggerDeeplink({ + nodeId: row.nodeId, + canvasRef: row.canvasRef, + label: row.title, + }); + } + + const renderRow = (row: LiveNowRow) => { + const Icon = row.iconName ? icons[row.iconName] : undefined; + return ( + + ); + }; + + const renderGroup = (label: string, groupRows: LiveNowRow[]) => { + // A group with no rows hides its own header entirely. + if (groupRows.length === 0) return null; + return ( +
+
+ {label} +
+ {groupRows.map(renderRow)} +
+ ); + }; + + return ( +
+
+ + + {!collapsed && ( + <> +
+ {renderGroup(LIVE_NOW_GROUP_LABELS.needsYou, needsYou)} + {renderGroup(LIVE_NOW_GROUP_LABELS.running, running)} + {overflowCount > 0 && ( +
+ +{overflowCount} more +
+ )} +
+
+ {footerText} +
+ + )} +
+
+ ); +} diff --git a/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx b/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx index 6000f3543a..dea8179ff2 100644 --- a/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx +++ b/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx @@ -54,6 +54,18 @@ import { deriveFeatureSnapshots, decorateNodesWithLiveState, } from "./useFeatureLiveState"; +import { useAttentionMapContext } from "./AttentionMapContext"; +import { useLiveNowItems } from "./useLiveNowItems"; +import { LiveNowPanel } from "./LiveNowPanel"; +import { runDeeplinkNavigation } from "./deeplinkNavigation"; + +/** + * Cross-scope deep-link navigation lives in `./deeplinkNavigation` (a + * leaf module so it stays unit-testable without mounting the canvas). + * Re-exported here so the canvas component remains the single public + * symbol home for everything that consumes `pendingDeeplink`. + */ +export { runDeeplinkNavigation } from "./deeplinkNavigation"; /** * Full-screen interactive system-canvas background for the Connections page. @@ -531,29 +543,54 @@ export function OrgCanvasBackground({ [currentRef, root], ); - // ── Canvas deeplink chip navigation ──────────────────────────────── + // ── Canvas deeplink navigation (chat chips + Live Now panel) ─────── // Consumes `pendingDeeplink` from the chat store. When the user clicks - // a `CanvasDeeplinkChip` in chat, the store slot is set; this effect - // fires, navigates to the correct sub-canvas (if needed), then pans - // and zooms to the target node. `clearDeeplink()` is always called in - // `finally` so the slot is never stuck. + // a `CanvasDeeplinkChip` in chat — or a row in the Live Now panel — + // the store slot is set; this effect hands the request to the shared + // `runDeeplinkNavigation` helper, which performs the cross-scope jump + // (climb to root → drill in → focus zoom) and never throws. + // `clearDeeplink()` is always called in `finally` so the slot is + // never stuck. const pendingDeeplink = useCanvasChatStore((s) => s.pendingDeeplink); const clearDeeplink = useCanvasChatStore((s) => s.clearDeeplink); + /** + * Resolves a canvas ref to its data AT CALL TIME, reading the live + * refs — not a render closure — so the post-navigation scroll sees + * the LANDED canvas. `runDeeplinkNavigation` calls this after the + * drill-in promise resolves, by which point this component's + * `currentRef` state may still hold the pre-navigation scope. + */ + const getDeeplinkCanvasData = useCallback( + (ref: string) => (ref ? subCanvasesRef.current[ref] : rootRef.current), + [rootRef, subCanvasesRef], + ); + + /** + * The navigation is async while `pendingDeeplink` stays set until the + * `finally` below. Mid-flight breadcrumb updates change `currentRef` + * and would re-run this effect against the still-set slot — the guard + * keeps that re-run from re-entering the navigation (a second + * `navigateToRoot` / duplicate breadcrumb push). + */ + const deeplinkInFlightRef = useRef(false); + useEffect(() => { if (!pendingDeeplink || !canvasHandleRef.current) return; + if (deeplinkInFlightRef.current) return; + deeplinkInFlightRef.current = true; const handle = canvasHandleRef.current; - const { nodeId, canvasRef } = pendingDeeplink; - - const doNavigate = - canvasRef && canvasRef !== currentRef - ? handle - .zoomIntoNode(canvasRef, { durationMs: 300 }) - .then(() => scrollToNode(nodeId)) - : Promise.resolve().then(() => scrollToNode(nodeId)); - - void doNavigate.finally(() => clearDeeplink()); - }, [pendingDeeplink, currentRef, scrollToNode, clearDeeplink]); + void runDeeplinkNavigation({ + handle, + currentRef, + target: pendingDeeplink, + getCanvasData: getDeeplinkCanvasData, + containerWidth: canvasContainerRef.current?.clientWidth ?? 0, + }).finally(() => { + deeplinkInFlightRef.current = false; + clearDeeplink(); + }); + }, [pendingDeeplink, currentRef, clearDeeplink, getDeeplinkCanvasData]); // Initial drill-in from `?canvas=`. Runs once after the root // canvas has loaded — `zoomIntoNode` requires the projected node @@ -815,6 +852,47 @@ export function OrgCanvasBackground({ const { liveByFeatureId, binders: featureLiveBinders } = useFeatureLiveState(featureSeeds); + // ── Live Now panel inputs ────────────────────────────────────────── + // The panel is strictly a second client-side read of state this + // component already tracks: the attention feed (from + // `AttentionMapContext`, mounted by `OrgCanvasView`) and the + // `liveByFeatureId` map above. No new fetch, poll, or subscription. + // + // `useLiveNowItems` resolves each attention row's canvas ref from the + // item's FKs, but running-only rows carry no title/ref — those come + // from the optional metadata maps below, scanned off the same loaded + // canvases (root + subCanvases) that seeded `featureSeeds`. One pass, + // first canvas seen wins (same precedence as the seeds scan). + const { items: attentionItems, lastUpdatedAt: attentionUpdatedAt } = + useAttentionMapContext(); + + const liveNowFeatureMeta = useMemo(() => { + const titles = new Map(); + const refs = new Map(); + const scan = (data: CanvasData | null | undefined, ref: string) => { + for (const node of data?.nodes ?? []) { + if (node.category !== "feature" || !node.id.startsWith("feature:")) { + continue; + } + const featureId = node.id.slice("feature:".length); + if (!titles.has(featureId)) { + titles.set(featureId, node.text ?? ""); + refs.set(featureId, ref); + } + } + }; + scan(root, ""); + for (const [ref, data] of Object.entries(subCanvases)) scan(data, ref); + return { titles, refs }; + }, [root, subCanvases]); + + const liveNow = useLiveNowItems({ + items: attentionItems, + liveByFeatureId, + featureTitles: liveNowFeatureMeta.titles, + featureRefs: liveNowFeatureMeta.refs, + }); + const canvasForRender = useMemo( () => decorateNodesWithLiveState( @@ -1344,7 +1422,20 @@ export function OrgCanvasBackground({ entries={hiddenLive ?? []} onRestore={handleRestoreLive} /> - + {/* + * Live Now panel — stacked directly beneath the restore pill + * (which owns `top:16, right:16, zIndex:25`). Renders nothing + * at all when nothing needs attention / is running, so the + * steady-state stays zero chrome. Read-only lens over the + * attention feed + live agent state already tracked above; + * row clicks dispatch `pendingDeeplink` commands through the + * chat store, consumed by the hardened effect up top. + */} + {/* * Deep-link load overlay. When the page loads with a `?canvas=` diff --git a/src/app/org/[githubLogin]/connections/deeplinkNavigation.ts b/src/app/org/[githubLogin]/connections/deeplinkNavigation.ts new file mode 100644 index 0000000000..6a68cfaee7 --- /dev/null +++ b/src/app/org/[githubLogin]/connections/deeplinkNavigation.ts @@ -0,0 +1,159 @@ +/** + * Cross-scope deep-link navigation for the org canvas. + * + * Shared execution path for every "focus this node" command the canvas + * receives: the chat's `CanvasDeeplinkChip` (via the store's + * `pendingDeeplink` slot) and the Live Now panel rows. Extracted from + * `OrgCanvasBackground`'s `pendingDeeplink` effect and re-exported from + * there so consumers have a single public symbol. + * + * ## Why this is a helper, not the effect body + * + * Two real bugs lived in the original effect body, both only reachable + * from a *cross-scope* jump (e.g. clicking a Live Now row while drilled + * into a sibling sub-canvas — a path the chip flow was never exercised + * on, being guarded to fire once from root): + * + * 1. **Silent cross-scope no-op.** `handle.zoomIntoNode(ref)` resolves + * against the *currently mounted* canvas's nodes and silently + * resolves when the id is absent. From inside sub-canvas A, a jump + * to `initiative:B` therefore did nothing. The fix: when the target + * ref differs from the current ref and we are not already on root, + * call `handle.navigateToRoot()` first, wait for React to swap the + * mounted canvas, then drill in. Navigation is skipped entirely when + * already on the target ref. + * + * 2. **Stale ref in the post-navigation scroll.** The old scroll step + * read `currentRef ? subCanvasesRef.current[currentRef] : root` from + * a closure that had not re-rendered when the navigation promise + * resolved, so the node-width lookup hit the pre-navigation canvas + * and `computeNodeFocusZoom` computed the wrong `targetZoom`. The + * fix: the landed canvas is resolved through the `getCanvasData` + * callback (which implementations back with `subCanvasesRef` / + * `rootRef` — refs, not render state) *after* navigation resolves, + * using the target ref as the landed ref rather than any closure + * captured `currentRef`. + * + * ## Contract + * + * - Never throws. A stale scope ref, a missing node, or a rejected + * zoom all resolve (with `false`) so the caller can react (the store + * effect runs `clearDeeplink()` in `finally`; a Live Now click on a + * `fallbackOnly` row falls back to opening the item's link). + * - Returns `true` only when the target node was found on the landed + * canvas and the focus zoom was dispatched. + */ +import type { CanvasData, SystemCanvasHandle } from "system-canvas-react"; +import { computeNodeFocusZoom } from "@/lib/canvas/nodeZoom"; + +/** A pending "focus this node" command — mirrors the chat store's `pendingDeeplink`. */ +export interface DeeplinkTarget { + nodeId: string; + /** Canvas ref the node lives on; `""` means the root canvas. */ + canvasRef: string; + /** Human-readable label (unused for navigation; kept for parity with the store slot). */ + label?: string; +} + +export interface RunDeeplinkNavigationArgs { + /** Imperative handle of the mounted ``. */ + handle: SystemCanvasHandle; + /** + * Canvas ref mounted when navigation starts (`""` = root). This is a + * snapshot for the "are we already there?" check only — never used to + * resolve the post-navigation scroll. + */ + currentRef: string; + target: DeeplinkTarget; + /** + * Resolves the `CanvasData` for a ref AT CALL TIME (`""` = root). + * Called after navigation resolves, so implementations must read the + * live refs (`subCanvasesRef` / `rootRef`) — the landed canvas is + * what feeds the node-width lookup, and the caller's render-time + * closure may still hold the pre-navigation scope. + */ + getCanvasData: (ref: string) => CanvasData | null | undefined; + /** Container width feeding `computeNodeFocusZoom`. */ + containerWidth: number; +} + +/** + * Wait for React to swap the mounted canvas after an imperative + * navigation. `navigateToRoot()` only schedules a state update; the + * root's node list replaces `nodesRef.current` on the next commit. + * Two rAFs mirror the library's own settle pattern after drill-in + * ("the first rAF lets React commit, the second lets the post-commit + * effects — including auto-fit — run"). Falls back to a macrotask when + * rAF is unavailable (tests / non-visual environments). + */ +function waitForCanvasSwap(): Promise { + if (typeof requestAnimationFrame !== "function") { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); +} + +/** + * Execute one deep-link navigation: climb to root if needed, drill into + * the target scope, then center+zoom onto the node. See the module + * header for the bug history and contract. Resolves `true` when the + * node was focused, `false` when it could not be (never rejects). + */ +export async function runDeeplinkNavigation({ + handle, + currentRef, + target, + getCanvasData, + containerWidth, +}: RunDeeplinkNavigationArgs): Promise { + const { nodeId, canvasRef: targetRef } = target; + if (!nodeId || !handle) return false; + + try { + const alreadyOnTarget = currentRef === targetRef; + + // Cross-scope jump: the mounted canvas doesn't contain the target + // scope's node, so climb to root first. (From root, `zoomIntoNode` + // below resolves the drill-in itself.) Also covers the sub-canvas → + // root-target case: `targetRef === ""` and we're not there. + if (!alreadyOnTarget && currentRef !== "") { + handle.navigateToRoot(); + await waitForCanvasSwap(); + } + + // Drill into the target scope. Resolves once the sub-canvas has + // mounted and auto-fit — or without navigating at all when the ref + // doesn't resolve to an on-canvas node (stale scope), in which case + // the node lookup below simply misses. Skipped when the target IS + // the root canvas (nothing to drill into). + if (targetRef && !alreadyOnTarget) { + await handle.zoomIntoNode(targetRef, { durationMs: 300 }); + } + + // Landed ref is the target ref ("" = root) — deliberately NOT + // `currentRef`, which is the pre-navigation snapshot. `getCanvasData` + // reads the live refs at call time, so a canvas that was just + // fetched by the drill-in resolves here. + const landed = getCanvasData(targetRef); + const node = landed?.nodes?.find((n) => n.id === nodeId); + if (!node) { + // Node not on the landed canvas — resolve without navigating. + // Callers fall back (Live Now opens the row's link); the store + // effect just clears the slot. + return false; + } + + const targetZoom = computeNodeFocusZoom(node.width ?? 260, containerWidth); + await handle.zoomIntoNode(nodeId, { targetZoom, durationMs: 600 }); + return true; + } catch { + // Best-effort navigation — a rejected zoom (node removed between + // the lookup and the zoom, camera glitch) resolves as "not focused" + // rather than rejecting into the caller's `finally`. + return false; + } +}