pmx-canvas 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/pmx-canvas/runtime.json +1 -1
- package/.github/extensions/pmx-canvas/tools.json +601 -22
- package/CHANGELOG.md +49 -0
- package/Readme.md +131 -532
- package/dist/canvas/global.css +2724 -476
- package/dist/canvas/index.js +168 -107
- package/dist/canvas/mcp-app.html +2899 -596
- package/dist/json-render/index.js +20 -20
- package/dist/types/client/canvas/BarHint.d.ts +12 -2
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
- package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
- package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
- package/dist/types/client/canvas/CommandBar.d.ts +3 -4
- package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
- package/dist/types/client/canvas/ContextChip.d.ts +3 -0
- package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
- package/dist/types/client/canvas/EmptyState.d.ts +4 -4
- package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
- package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
- package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -7
- package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
- package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
- package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
- package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
- package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
- package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
- package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
- package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
- package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
- package/dist/types/client/canvas/canvas-area.d.ts +1 -1
- package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
- package/dist/types/client/canvas/graph-model.d.ts +37 -0
- package/dist/types/client/canvas/memory-layout.d.ts +23 -0
- package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
- package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
- package/dist/types/client/icons.d.ts +0 -10
- package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
- package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
- package/dist/types/client/state/boards-store.d.ts +81 -9
- package/dist/types/client/state/canvas-store.d.ts +14 -1
- package/dist/types/client/state/context-chip-store.d.ts +22 -0
- package/dist/types/client/state/context-status-store.d.ts +35 -0
- package/dist/types/client/state/intent-bridge.d.ts +11 -8
- package/dist/types/client/state/knowledge-store.d.ts +22 -0
- package/dist/types/client/state/near-pin-store.d.ts +44 -1
- package/dist/types/client/state/presence-store.d.ts +4 -4
- package/dist/types/client/state/presentation.d.ts +28 -0
- package/dist/types/client/state/relation-store.d.ts +10 -0
- package/dist/types/client/state/session-store.d.ts +41 -2
- package/dist/types/client/state/workspace-settings.d.ts +7 -0
- package/dist/types/client/types.d.ts +13 -1
- package/dist/types/client/utils/keyboard.d.ts +2 -0
- package/dist/types/client/utils/time-ago.d.ts +4 -0
- package/dist/types/mcp/context-uri-template.d.ts +3 -1
- package/dist/types/server/agent-presence.d.ts +10 -3
- package/dist/types/server/ax-context.d.ts +1 -1
- package/dist/types/server/canvas-db.d.ts +30 -7
- package/dist/types/server/canvas-operations.d.ts +24 -2
- package/dist/types/server/canvas-provenance.d.ts +22 -2
- package/dist/types/server/canvas-serialization.d.ts +2 -0
- package/dist/types/server/canvas-state.d.ts +93 -15
- package/dist/types/server/canvas-validation.d.ts +1 -1
- package/dist/types/server/context-brief.d.ts +94 -6
- package/dist/types/server/context-reads.d.ts +33 -7
- package/dist/types/server/edit-summary.d.ts +20 -0
- package/dist/types/server/index.d.ts +71 -10
- package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
- package/dist/types/server/lifecycle-status.d.ts +10 -0
- package/dist/types/server/memory-graph.d.ts +9 -0
- package/dist/types/server/mutation-history.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
- package/dist/types/server/operations/ops/boards.d.ts +2 -1
- package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
- package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +5 -0
- package/dist/types/server/operations/ops/query.d.ts +23 -0
- package/dist/types/server/operations/registry.d.ts +1 -0
- package/dist/types/server/placement.d.ts +7 -0
- package/dist/types/server/scope-fence.d.ts +2 -1
- package/dist/types/server/spatial-analysis.d.ts +6 -3
- package/dist/types/server/tour-control.d.ts +32 -0
- package/dist/types/server/wiki-links.d.ts +14 -0
- package/dist/types/server/workbench-auth.d.ts +2 -0
- package/dist/types/shared/agent-context.d.ts +2 -0
- package/dist/types/shared/agent-presence.d.ts +15 -12
- package/dist/types/shared/auto-arrange.d.ts +1 -0
- package/dist/types/shared/boards.d.ts +4 -4
- package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
- package/dist/types/shared/knowledge-presets.d.ts +6 -0
- package/dist/types/shared/lifecycle-status.d.ts +24 -0
- package/dist/types/shared/memory-graph.d.ts +53 -0
- package/dist/types/shared/relations.d.ts +68 -0
- package/dist/types/shared/semantic-attention.d.ts +2 -0
- package/dist/types/shared/tour.d.ts +38 -0
- package/dist/types/shared/workspace-settings.d.ts +11 -0
- package/docs/RELEASE.md +21 -3
- package/docs/adr-001-bun-only-runtime.md +7 -1
- package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
- package/docs/api-reference.md +109 -0
- package/docs/ax-host-adapter-contract.md +1 -1
- package/docs/ax-state-contract.md +5 -4
- package/docs/cli.md +153 -15
- package/docs/demos/pmx-canvas-demo.mp4 +0 -0
- package/docs/design/AgentContext.dc.html +3 -3
- package/docs/design/BoardPins.dc.html +37 -22
- package/docs/design/Categories.dc.html +208 -0
- package/docs/design/CategoryEditor.dc.html +33 -0
- package/docs/design/Chrome.dc.html +52 -9
- package/docs/design/ContextChip.dc.html +224 -0
- package/docs/design/ContextChip600.dc.html +136 -0
- package/docs/design/EmptyState.dc.html +45 -0
- package/docs/design/Flows.dc.html +23 -1
- package/docs/design/HeaderMarks.dc.html +183 -0
- package/docs/design/Home.dc.html +19 -1
- package/docs/design/HomeGraph.dc.html +86 -202
- package/docs/design/HomeMap.dc.html +23 -1
- package/docs/design/HomeTeam.dc.html +32 -14
- package/docs/design/Lifecycle.dc.html +22 -0
- package/docs/design/Lifecycle600.dc.html +18 -0
- package/docs/design/LifecycleVariants.dc.html +157 -0
- package/docs/design/LinkAuthorship.dc.html +139 -0
- package/docs/design/LinksChanged.dc.html +203 -0
- package/docs/design/LinksOptions.dc.html +137 -0
- package/docs/design/LinksOptions600.dc.html +134 -0
- package/docs/design/NearPin.dc.html +9 -10
- package/docs/design/Pane600.dc.html +4 -3
- package/docs/design/Relations.dc.html +3 -2
- package/docs/design/Restore.dc.html +152 -0
- package/docs/design/SeeChange.dc.html +267 -0
- package/docs/design/Uses.dc.html +12 -6
- package/docs/design/canvas.json +146 -19
- package/docs/design.md +449 -16
- package/docs/environment.md +0 -1
- package/docs/evals/curation-effect.md +128 -0
- package/docs/http-api.md +459 -37
- package/docs/mcp.md +146 -18
- package/docs/node-types.md +58 -3
- package/docs/plans/design-gaps-2026-10-08.md +53 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
- package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
- package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
- package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
- package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
- package/docs/product-context-vision-2026-09.md +17 -1
- package/docs/product-vision-2026-09.md +1082 -43
- package/docs/product-vision-review-2026-09.md +13 -1
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +62 -2
- package/package.json +1 -1
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/plugin.json +1 -1
- package/plugins/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/tools.json +601 -22
- package/skills/pmx-canvas/SKILL.md +216 -42
- package/skills/pmx-canvas/references/full-reference.md +120 -21
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
- package/skills/pmx-canvas/references/importing-context.md +132 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
- package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
- package/skills/pmx-canvas-testing/SKILL.md +20 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +12 -0
- package/src/cli/commands/boards.ts +87 -11
- package/src/cli/commands/edges.ts +28 -0
- package/src/cli/commands/groups.ts +7 -0
- package/src/cli/commands/nodes.ts +1 -0
- package/src/cli/commands/query.ts +34 -2
- package/src/cli/commands/record.ts +199 -48
- package/src/cli/index.ts +6 -1
- package/src/client/App.tsx +177 -70
- package/src/client/canvas/AttentionHistory.tsx +0 -2
- package/src/client/canvas/BarHint.tsx +40 -2
- package/src/client/canvas/BoardCopyDialog.tsx +1 -1
- package/src/client/canvas/BoardFolderDialog.tsx +7 -7
- package/src/client/canvas/BoardLinkDialog.tsx +138 -33
- package/src/client/canvas/BoardPinButton.tsx +33 -0
- package/src/client/canvas/BoardSwitcher.tsx +26 -40
- package/src/client/canvas/CanvasNode.tsx +360 -237
- package/src/client/canvas/CanvasViewport.tsx +45 -7
- package/src/client/canvas/CommandBar.tsx +29 -23
- package/src/client/canvas/CommandPalette.tsx +5 -12
- package/src/client/canvas/ConnectionBanner.tsx +3 -3
- package/src/client/canvas/ContextChip.tsx +296 -0
- package/src/client/canvas/ContextMenu.tsx +63 -5
- package/src/client/canvas/CrossBoardReferences.tsx +60 -0
- package/src/client/canvas/EdgeLayer.tsx +339 -76
- package/src/client/canvas/EmptyState.tsx +33 -18
- package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
- package/src/client/canvas/ExternalWriters.tsx +57 -42
- package/src/client/canvas/HomeGraphView.tsx +942 -0
- package/src/client/canvas/HomeMapView.tsx +641 -0
- package/src/client/canvas/HomeReviewView.tsx +167 -0
- package/src/client/canvas/HomeView.tsx +687 -160
- package/src/client/canvas/IntentLayer.tsx +19 -12
- package/src/client/canvas/KnowledgeDialog.tsx +488 -0
- package/src/client/canvas/LifecycleChrome.tsx +133 -0
- package/src/client/canvas/LifecycleFields.tsx +91 -0
- package/src/client/canvas/LifecyclePanel.tsx +533 -0
- package/src/client/canvas/MemoryInspector.tsx +104 -0
- package/src/client/canvas/Minimap.tsx +3 -3
- package/src/client/canvas/NearPinLayer.tsx +187 -0
- package/src/client/canvas/NodeContextMark.tsx +175 -97
- package/src/client/canvas/Presentation.tsx +23 -57
- package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
- package/src/client/canvas/RelationsPanel.tsx +82 -0
- package/src/client/canvas/SelectionBar.tsx +3 -6
- package/src/client/canvas/SessionPanel.tsx +90 -50
- package/src/client/canvas/SessionReceipt.tsx +311 -39
- package/src/client/canvas/ShortcutOverlay.tsx +1 -1
- package/src/client/canvas/SnapshotPanel.tsx +27 -36
- package/src/client/canvas/ToolRail.tsx +367 -192
- package/src/client/canvas/TopBar.tsx +354 -124
- package/src/client/canvas/canvas-area.ts +10 -3
- package/src/client/canvas/graph-geometry.ts +195 -0
- package/src/client/canvas/graph-model.ts +254 -0
- package/src/client/canvas/memory-layout.ts +106 -0
- package/src/client/canvas/use-chrome-height.ts +20 -0
- package/src/client/canvas/use-focus-trap.ts +31 -5
- package/src/client/canvas/use-memory-graph.ts +58 -0
- package/src/client/canvas/use-node-drag.ts +2 -1
- package/src/client/canvas/use-pan-zoom.ts +64 -7
- package/src/client/icons.tsx +0 -63
- package/src/client/mcp-app/style.css +0 -6
- package/src/client/nodes/AxStepControls.tsx +74 -50
- package/src/client/nodes/BoardNode.tsx +5 -1
- package/src/client/nodes/ContextNode.tsx +18 -11
- package/src/client/nodes/ExtAppFrame.tsx +0 -1
- package/src/client/nodes/FileNode.tsx +46 -42
- package/src/client/nodes/HtmlNode.tsx +32 -0
- package/src/client/nodes/ImageNode.tsx +1 -1
- package/src/client/nodes/InlineFormatBar.tsx +6 -9
- package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
- package/src/client/nodes/MarkdownNode.tsx +377 -109
- package/src/client/nodes/McpAppNode.tsx +50 -20
- package/src/client/nodes/MdFormatBar.tsx +21 -18
- package/src/client/nodes/PromptNode.tsx +1 -1
- package/src/client/nodes/ResponseNode.tsx +1 -1
- package/src/client/nodes/viewer-runtime-state.ts +54 -22
- package/src/client/state/attention-bridge.ts +64 -15
- package/src/client/state/boards-store.ts +193 -39
- package/src/client/state/canvas-store.ts +99 -12
- package/src/client/state/context-chip-store.ts +70 -0
- package/src/client/state/context-status-store.ts +53 -0
- package/src/client/state/intent-bridge.ts +51 -30
- package/src/client/state/knowledge-store.ts +43 -0
- package/src/client/state/near-pin-store.ts +115 -6
- package/src/client/state/presence-store.ts +7 -10
- package/src/client/state/presentation.ts +91 -0
- package/src/client/state/relation-store.ts +81 -0
- package/src/client/state/session-store.ts +144 -18
- package/src/client/state/sse-bridge.ts +206 -29
- package/src/client/state/workspace-settings.ts +11 -0
- package/src/client/theme/global.css +2724 -476
- package/src/client/types.ts +12 -0
- package/src/client/utils/keyboard.ts +10 -0
- package/src/client/utils/time-ago.ts +14 -0
- package/src/json-render/renderer/index.tsx +31 -0
- package/src/mcp/canvas-app.ts +12 -2
- package/src/mcp/context-uri-template.ts +7 -4
- package/src/mcp/server.ts +63 -41
- package/src/mcp/workbench-app.ts +39 -6
- package/src/server/agent-presence.ts +128 -28
- package/src/server/ax-context.ts +27 -13
- package/src/server/board-export.ts +12 -1
- package/src/server/canvas-db.ts +331 -42
- package/src/server/canvas-operations.ts +195 -83
- package/src/server/canvas-provenance.ts +53 -22
- package/src/server/canvas-serialization.ts +2 -0
- package/src/server/canvas-state.ts +950 -122
- package/src/server/canvas-validation.ts +9 -2
- package/src/server/context-brief.ts +345 -38
- package/src/server/context-reads.ts +178 -30
- package/src/server/demo-state.json +355 -24
- package/src/server/edit-summary.ts +74 -0
- package/src/server/html-surface.ts +8 -1
- package/src/server/index.ts +181 -11
- package/src/server/knowledge-lifecycle.ts +304 -0
- package/src/server/lifecycle-status.ts +232 -0
- package/src/server/memory-graph.ts +166 -0
- package/src/server/mutation-history.ts +18 -6
- package/src/server/operations/composites.ts +32 -11
- package/src/server/operations/http.ts +7 -2
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/mcp.ts +26 -1
- package/src/server/operations/ops/ax-presence.ts +1 -1
- package/src/server/operations/ops/ax-read.ts +57 -6
- package/src/server/operations/ops/ax-timeline.ts +28 -2
- package/src/server/operations/ops/backup.ts +5 -3
- package/src/server/operations/ops/batch.ts +35 -4
- package/src/server/operations/ops/boards.ts +188 -47
- package/src/server/operations/ops/canvas-wire.ts +31 -1
- package/src/server/operations/ops/edges.ts +36 -2
- package/src/server/operations/ops/groups.ts +6 -1
- package/src/server/operations/ops/lifecycle.ts +262 -0
- package/src/server/operations/ops/memory-graph.ts +45 -0
- package/src/server/operations/ops/nodes.ts +107 -24
- package/src/server/operations/ops/query.ts +281 -68
- package/src/server/operations/ops/snapshots.ts +73 -1
- package/src/server/operations/ops/validate.ts +5 -1
- package/src/server/operations/ops/viewport.ts +97 -3
- package/src/server/operations/registry.ts +74 -25
- package/src/server/placement.ts +29 -0
- package/src/server/scope-fence.ts +78 -6
- package/src/server/server.ts +115 -16
- package/src/server/spatial-analysis.ts +48 -23
- package/src/server/tour-control.ts +82 -0
- package/src/server/wiki-links.ts +137 -0
- package/src/server/workbench-auth.ts +11 -0
- package/src/shared/agent-context.ts +3 -0
- package/src/shared/agent-presence.ts +15 -15
- package/src/shared/auto-arrange.ts +2 -0
- package/src/shared/boards.ts +4 -4
- package/src/shared/knowledge-lifecycle.ts +22 -0
- package/src/shared/knowledge-presets.ts +80 -0
- package/src/shared/lifecycle-status.ts +26 -0
- package/src/shared/memory-graph.ts +74 -0
- package/src/shared/relations.ts +98 -0
- package/src/shared/semantic-attention.ts +15 -1
- package/src/shared/tour.ts +23 -5
- package/src/shared/workspace-settings.ts +13 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
- package/dist/types/client/canvas/kind-colors.d.ts +0 -3
- package/dist/types/server/agent-context.d.ts +0 -22
- package/src/client/canvas/ContextPinBar.tsx +0 -25
|
@@ -1,100 +1,239 @@
|
|
|
1
|
-
import { signal } from '@preact/signals';
|
|
1
|
+
import { batch, effect, signal } from '@preact/signals';
|
|
2
|
+
import { knowledgeLifecycleEnabled } from './workspace-settings';
|
|
2
3
|
import { requestJson } from './intent-bridge';
|
|
4
|
+
import { focusNode, hasInitialServerLayout, hiddenByCollapsedGroup, nodes, toggleCollapsed } from './canvas-store';
|
|
5
|
+
import type { MemoryTarget } from '../../shared/memory-graph.js';
|
|
6
|
+
import type { RelationDefinition } from '../../shared/relations.js';
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Many boards (plan 012): the workspace's boards and which one is open. The
|
|
6
10
|
* server is the authority; this mirrors GET /api/canvas/boards and the
|
|
7
|
-
* `boards-changed` SSE frame.
|
|
11
|
+
* `boards-changed` SSE frame. Null means no server board is open; browsingMemory
|
|
12
|
+
* separately keeps a human in the library while agents open boards.
|
|
8
13
|
*/
|
|
9
14
|
export interface BoardSummary {
|
|
10
15
|
id: string;
|
|
11
16
|
name: string;
|
|
12
|
-
|
|
17
|
+
folder: string | null;
|
|
13
18
|
createdAt: string;
|
|
14
19
|
lastOpenedAt: string | null;
|
|
15
20
|
nodeCount: number;
|
|
16
21
|
readmeNodeId?: string | null;
|
|
22
|
+
mapPosition?: { x: number; y: number } | null;
|
|
23
|
+
previousBoardId?: string | null;
|
|
24
|
+
relationVocabulary?: RelationDefinition[];
|
|
17
25
|
summary?: string | null;
|
|
18
26
|
pinnedTitles?: Array<{ nodeId: string; title: string }>;
|
|
19
|
-
links?: Array<{
|
|
20
|
-
|
|
27
|
+
links?: Array<{
|
|
28
|
+
id?: string;
|
|
29
|
+
nodeId: string;
|
|
30
|
+
boardId: string;
|
|
31
|
+
cardId?: string;
|
|
32
|
+
cardTitle?: string | null;
|
|
33
|
+
title: string | null;
|
|
34
|
+
missing: boolean;
|
|
35
|
+
kind?: string;
|
|
36
|
+
}>;
|
|
37
|
+
backlinks?: Array<{ id?: string; boardId: string; title: string; nodeId: string; cardId?: string; kind?: string }>;
|
|
38
|
+
/** Home thumbnail (docs/design/Home.dc.html): cards as 0–1 rectangles of the board's extent. */
|
|
39
|
+
preview?: Array<{ x: number; y: number; w: number; h: number; pinned?: true }>;
|
|
40
|
+
/** Roughly what this board's map costs in the brief when pinned (README + pinned card summaries). */
|
|
41
|
+
mapChars?: number;
|
|
42
|
+
/** In the agent's working set (vision move 0a); null when not pinned. */
|
|
43
|
+
pin?: { pinnedBy: { actor: string; source: string; agentId?: string }; pinnedAt: string; reason?: string } | null;
|
|
21
44
|
}
|
|
22
45
|
|
|
23
46
|
interface BoardsPayload {
|
|
47
|
+
catalogEpoch: string;
|
|
48
|
+
catalogRevision: number;
|
|
49
|
+
reloadRevision: number;
|
|
50
|
+
boardGeneration: number;
|
|
24
51
|
activeBoardId: string | null;
|
|
25
52
|
boards: BoardSummary[];
|
|
26
53
|
}
|
|
27
54
|
|
|
28
55
|
export const boardList = signal<BoardSummary[]>([]);
|
|
29
56
|
export const activeBoardId = signal<string | null>(null);
|
|
57
|
+
/** An explicitly opened library view stays visible while agents switch boards. */
|
|
58
|
+
export const browsingMemory = signal(false);
|
|
59
|
+
/** Review belongs to this navigation incarnation, never merely a reused card ID. */
|
|
60
|
+
export const lifecycleTarget = signal<{ boardId: string; nodeId: string; incarnation: number } | null>(null);
|
|
61
|
+
export const lifecycleFilter = signal<'All' | 'To review' | 'Retired'>('All');
|
|
62
|
+
effect(() => {
|
|
63
|
+
activeBoardId.value;
|
|
64
|
+
lifecycleFilter.value = 'All';
|
|
65
|
+
});
|
|
66
|
+
/** Layout/pin/reconnect invalidation for the library's shared graph projection. */
|
|
67
|
+
export const memoryGraphRevision = signal(0);
|
|
30
68
|
/** False until the first list arrives — Home must not flash before the server answers. */
|
|
31
69
|
export const boardsLoaded = signal(false);
|
|
32
70
|
|
|
33
|
-
let onBoardSwitched: (() => void) | null = null;
|
|
34
|
-
let
|
|
71
|
+
let onBoardSwitched: ((initial: boolean) => void) | null = null;
|
|
72
|
+
export let catalogEpoch: string | null = null;
|
|
73
|
+
export let boardGeneration: number | null = null;
|
|
74
|
+
/** Invalidates suspended writes when the canonical board/library is replaced. */
|
|
75
|
+
export let boardIncarnation = 0;
|
|
76
|
+
let catalogRevision = 0;
|
|
77
|
+
let reloadRevision: number | null = null;
|
|
35
78
|
let openRequestSequence = 0;
|
|
36
79
|
let openRequestQueue: Promise<void> = Promise.resolve();
|
|
80
|
+
let navigationSequence = 0;
|
|
81
|
+
/** Invalidates lifecycle UI work across suspension, including an off/on round trip. */
|
|
82
|
+
export let lifecycleUiGeneration = 0;
|
|
83
|
+
effect(() => {
|
|
84
|
+
knowledgeLifecycleEnabled.value;
|
|
85
|
+
lifecycleUiGeneration++;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/** Only the current transport's connected handshake may replace the server epoch. */
|
|
89
|
+
export function connectBoardCatalog(epoch: string): void {
|
|
90
|
+
if (epoch === catalogEpoch) return;
|
|
91
|
+
catalogEpoch = epoch;
|
|
92
|
+
boardGeneration = null;
|
|
93
|
+
boardIncarnation++;
|
|
94
|
+
catalogRevision = 0;
|
|
95
|
+
reloadRevision = null;
|
|
96
|
+
openRequestSequence++;
|
|
97
|
+
navigationSequence++;
|
|
98
|
+
lifecycleTarget.value = null;
|
|
99
|
+
}
|
|
37
100
|
|
|
38
101
|
/** The SSE bridge resyncs the canvas when the open board changes under it. */
|
|
39
|
-
export function setBoardSwitchHandler(handler: (() => void) | null): void {
|
|
102
|
+
export function setBoardSwitchHandler(handler: ((initial: boolean) => void) | null): void {
|
|
40
103
|
onBoardSwitched = handler;
|
|
41
104
|
}
|
|
42
105
|
|
|
43
|
-
export function applyBoards(
|
|
44
|
-
|
|
45
|
-
|
|
106
|
+
export function applyBoards(
|
|
107
|
+
data: {
|
|
108
|
+
activeBoardId?: unknown;
|
|
109
|
+
boards?: unknown;
|
|
110
|
+
catalogEpoch?: unknown;
|
|
111
|
+
catalogRevision?: unknown;
|
|
112
|
+
reloadRevision?: unknown;
|
|
113
|
+
boardGeneration?: unknown;
|
|
114
|
+
} | null,
|
|
115
|
+
): void {
|
|
116
|
+
if (
|
|
117
|
+
!data ||
|
|
118
|
+
!Array.isArray(data.boards) ||
|
|
119
|
+
data.catalogEpoch !== catalogEpoch ||
|
|
120
|
+
typeof data.catalogRevision !== 'number' ||
|
|
121
|
+
typeof data.reloadRevision !== 'number' ||
|
|
122
|
+
data.catalogRevision <= catalogRevision
|
|
123
|
+
)
|
|
124
|
+
return;
|
|
125
|
+
catalogRevision = data.catalogRevision;
|
|
46
126
|
const nextActive = typeof data.activeBoardId === 'string' ? data.activeBoardId : null;
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
127
|
+
const initial = !boardsLoaded.value;
|
|
128
|
+
const establishesGeneration = boardGeneration === null && typeof data.boardGeneration === 'number';
|
|
129
|
+
// A restore stays observable even when a later GET overtakes its notification.
|
|
130
|
+
const switched =
|
|
131
|
+
!initial &&
|
|
132
|
+
(nextActive !== activeBoardId.value ||
|
|
133
|
+
(reloadRevision !== null && data.reloadRevision !== reloadRevision) ||
|
|
134
|
+
(boardGeneration !== null &&
|
|
135
|
+
typeof data.boardGeneration === 'number' &&
|
|
136
|
+
data.boardGeneration !== boardGeneration));
|
|
137
|
+
if (switched) {
|
|
138
|
+
boardIncarnation++;
|
|
139
|
+
lifecycleTarget.value = null;
|
|
140
|
+
}
|
|
141
|
+
if (typeof data.boardGeneration === 'number') boardGeneration = data.boardGeneration;
|
|
142
|
+
reloadRevision = data.reloadRevision;
|
|
143
|
+
batch(() => {
|
|
144
|
+
boardList.value = data.boards as BoardSummary[];
|
|
145
|
+
activeBoardId.value = nextActive;
|
|
146
|
+
boardsLoaded.value = true;
|
|
147
|
+
// Never render old cards with the replacement board's identity/draft keys.
|
|
148
|
+
if (switched || initial) onBoardSwitched?.(initial);
|
|
149
|
+
// A same-board restart may deliver its tagged snapshot before the first
|
|
150
|
+
// catalog. Drain it without detaching the editor or losing its selection.
|
|
151
|
+
else if (establishesGeneration) onBoardSwitched?.(true);
|
|
152
|
+
});
|
|
53
153
|
}
|
|
54
154
|
|
|
55
155
|
export async function loadBoards(): Promise<void> {
|
|
56
|
-
const revision = boardsRevision;
|
|
57
156
|
const payload = await requestJson<BoardsPayload | null>('loadBoards', '/api/canvas/boards', null);
|
|
58
|
-
|
|
157
|
+
applyBoards(payload);
|
|
59
158
|
}
|
|
60
159
|
|
|
61
|
-
async function post(
|
|
62
|
-
action
|
|
63
|
-
url: string,
|
|
64
|
-
method: string,
|
|
65
|
-
body?: unknown,
|
|
66
|
-
revision = boardsRevision,
|
|
67
|
-
): Promise<BoardsPayload | null> {
|
|
68
|
-
const payload = await requestJson<BoardsPayload | null>(action, url, null, {
|
|
160
|
+
async function post(action: string, url: string, method: string, body?: unknown): Promise<BoardsPayload | null> {
|
|
161
|
+
return requestJson<BoardsPayload | null>(action, url, null, {
|
|
69
162
|
method,
|
|
70
163
|
headers: { 'Content-Type': 'application/json' },
|
|
71
164
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
72
165
|
});
|
|
73
|
-
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Reserve navigation before an async save; later navigation cancels the pending jump. */
|
|
169
|
+
export function claimNavigation(): () => boolean {
|
|
170
|
+
const sequence = ++navigationSequence;
|
|
171
|
+
return () => sequence === navigationSequence;
|
|
74
172
|
}
|
|
75
173
|
|
|
76
174
|
/** Open a board, or Home with null. */
|
|
77
175
|
export async function openBoard(id: string | null): Promise<void> {
|
|
78
176
|
const sequence = ++openRequestSequence;
|
|
79
|
-
|
|
177
|
+
navigationSequence++;
|
|
178
|
+
lifecycleTarget.value = null;
|
|
179
|
+
const epoch = catalogEpoch;
|
|
80
180
|
let payload: BoardsPayload | null = null;
|
|
81
181
|
const request = openRequestQueue.then(async () => {
|
|
82
|
-
payload = await post('openBoard', '/api/canvas/boards/open', 'POST', { id }
|
|
182
|
+
if (epoch === catalogEpoch) payload = await post('openBoard', '/api/canvas/boards/open', 'POST', { id });
|
|
83
183
|
});
|
|
84
184
|
openRequestQueue = request.catch(() => undefined);
|
|
85
185
|
await request;
|
|
86
|
-
|
|
87
|
-
|
|
186
|
+
if (sequence === openRequestSequence && epoch === catalogEpoch && payload) {
|
|
187
|
+
batch(() => {
|
|
188
|
+
applyBoards(payload);
|
|
189
|
+
if (id !== null) browsingMemory.value = false;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Used by wiki links, graph nodes and qualified library hits; never focus a stale board's same-ID card. */
|
|
195
|
+
export async function openMemoryTarget(target: MemoryTarget, options: { review?: boolean } = {}): Promise<boolean> {
|
|
196
|
+
if (options.review && !knowledgeLifecycleEnabled.value) return false;
|
|
197
|
+
const lifecycleGeneration = lifecycleUiGeneration;
|
|
198
|
+
const opening = openBoard(target.boardId);
|
|
199
|
+
const sequence = navigationSequence;
|
|
200
|
+
await opening;
|
|
201
|
+
const incarnation = boardIncarnation;
|
|
202
|
+
if (!target.cardId) return sequence === navigationSequence && activeBoardId.value === target.boardId;
|
|
203
|
+
// A slow layout fetch must not turn a card jump into only a board switch.
|
|
204
|
+
for (let attempt = 0; attempt < 400; attempt++) {
|
|
205
|
+
if (
|
|
206
|
+
sequence !== navigationSequence ||
|
|
207
|
+
activeBoardId.value !== target.boardId ||
|
|
208
|
+
boardIncarnation !== incarnation ||
|
|
209
|
+
browsingMemory.value ||
|
|
210
|
+
(options.review && (!knowledgeLifecycleEnabled.value || lifecycleGeneration !== lifecycleUiGeneration))
|
|
211
|
+
)
|
|
212
|
+
return false;
|
|
213
|
+
if (hasInitialServerLayout.value && nodes.value.has(target.cardId)) {
|
|
214
|
+
const group = hiddenByCollapsedGroup.value.get(target.cardId);
|
|
215
|
+
if (group) toggleCollapsed(group);
|
|
216
|
+
focusNode(target.cardId);
|
|
217
|
+
if (options.review) lifecycleTarget.value = { boardId: target.boardId, nodeId: target.cardId, incarnation };
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
await new Promise((resolve) => window.setTimeout(resolve, 25));
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
88
223
|
}
|
|
89
224
|
|
|
90
|
-
|
|
225
|
+
/** Create a board (filed in `folder` when given) and open it. */
|
|
226
|
+
export async function createAndOpenBoard(name: string, folder?: string | null): Promise<void> {
|
|
91
227
|
const sequence = ++openRequestSequence;
|
|
228
|
+
navigationSequence++;
|
|
229
|
+
lifecycleTarget.value = null;
|
|
230
|
+
const epoch = catalogEpoch;
|
|
92
231
|
const created = await requestJson<{ board?: BoardSummary } | null>('createBoard', '/api/canvas/boards', null, {
|
|
93
232
|
method: 'POST',
|
|
94
233
|
headers: { 'Content-Type': 'application/json' },
|
|
95
|
-
body: JSON.stringify({ name }),
|
|
234
|
+
body: JSON.stringify({ name, ...(folder ? { folder } : {}) }),
|
|
96
235
|
});
|
|
97
|
-
if (created?.board && sequence === openRequestSequence) await openBoard(created.board.id);
|
|
236
|
+
if (created?.board && sequence === openRequestSequence && epoch === catalogEpoch) await openBoard(created.board.id);
|
|
98
237
|
}
|
|
99
238
|
|
|
100
239
|
export interface BoardCopyCard {
|
|
@@ -122,7 +261,7 @@ export async function previewBoardCopy(sourceBoardId: string): Promise<BoardCopy
|
|
|
122
261
|
export async function createBoardFrom(opts: {
|
|
123
262
|
sourceBoardId: string;
|
|
124
263
|
name: string;
|
|
125
|
-
|
|
264
|
+
folder?: string;
|
|
126
265
|
nodeIds: string[];
|
|
127
266
|
includeReadme: boolean;
|
|
128
267
|
includeStructure: boolean;
|
|
@@ -141,13 +280,28 @@ export async function createBoardFrom(opts: {
|
|
|
141
280
|
return result?.board ?? null;
|
|
142
281
|
}
|
|
143
282
|
|
|
144
|
-
/** Rename and/or re-file a board; `
|
|
145
|
-
export async function updateBoard(
|
|
283
|
+
/** Rename and/or re-file a board; `folder: null` unfiles it. */
|
|
284
|
+
export async function updateBoard(
|
|
285
|
+
id: string,
|
|
286
|
+
patch: {
|
|
287
|
+
name?: string;
|
|
288
|
+
folder?: string | null;
|
|
289
|
+
mapPosition?: { x: number; y: number } | null;
|
|
290
|
+
relationVocabulary?: RelationDefinition[];
|
|
291
|
+
},
|
|
292
|
+
): Promise<boolean> {
|
|
146
293
|
const payload = await post('updateBoard', `/api/canvas/boards/${encodeURIComponent(id)}`, 'PATCH', {
|
|
147
294
|
...patch,
|
|
148
|
-
...(patch.
|
|
295
|
+
...(patch.folder === null ? { folder: '' } : {}),
|
|
149
296
|
});
|
|
150
297
|
applyBoards(payload);
|
|
298
|
+
return payload !== null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Pin a board into the agent's working set, or unpin it. */
|
|
302
|
+
export async function setBoardPinned(id: string, pinned: boolean): Promise<void> {
|
|
303
|
+
const url = `/api/canvas/boards/${encodeURIComponent(id)}/${pinned ? 'pin' : 'unpin'}`;
|
|
304
|
+
applyBoards(await post(pinned ? 'pinBoard' : 'unpinBoard', url, 'POST', {}));
|
|
151
305
|
}
|
|
152
306
|
|
|
153
307
|
export async function setBoardReadme(id: string, readmeNodeId: string | null): Promise<void> {
|
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
} from '../types';
|
|
11
11
|
import { computeAutoArrange } from '../../shared/auto-arrange';
|
|
12
12
|
import { canvasArea, canvasAreaCenter, canvasFitInsets } from '../canvas/canvas-area';
|
|
13
|
-
import {
|
|
13
|
+
import { activeBoardId, boardsLoaded } from './boards-store';
|
|
14
|
+
import { pushCanvasUpdate, requestBestEffort, requestJson, requestOk, updateViewportFromClient } from './intent-bridge';
|
|
14
15
|
import { isHostedWorkbench } from './workbench-transport';
|
|
16
|
+
import { syncAttentionFromSse } from './attention-bridge';
|
|
15
17
|
|
|
16
18
|
function logCanvasStoreError(action: string, error: unknown): void {
|
|
17
19
|
console.error(`[canvas-store] ${action} failed`, error);
|
|
@@ -101,6 +103,7 @@ export function getNeighborNodeIds(nodeId: string | null, edgeMap: Map<string, C
|
|
|
101
103
|
|
|
102
104
|
const neighborIds = new Set<string>();
|
|
103
105
|
for (const edge of edgeMap.values()) {
|
|
106
|
+
if (edge.toBoardId !== undefined) continue;
|
|
104
107
|
if (edge.from === nodeId) neighborIds.add(edge.to);
|
|
105
108
|
if (edge.to === nodeId) neighborIds.add(edge.from);
|
|
106
109
|
}
|
|
@@ -149,12 +152,15 @@ export function clearSelection(): void {
|
|
|
149
152
|
|
|
150
153
|
/** Clear transient UI state that must never follow a node id onto another board. */
|
|
151
154
|
export function resetCanvasInteractionState(): void {
|
|
155
|
+
cancelViewportAnimation();
|
|
152
156
|
if (expandedCloseTimer !== null) {
|
|
153
157
|
clearTimeout(expandedCloseTimer);
|
|
154
158
|
expandedCloseTimer = null;
|
|
155
159
|
}
|
|
156
160
|
batch(() => {
|
|
157
161
|
canvasInteractionEpoch.value += 1;
|
|
162
|
+
contextPinsRevision++;
|
|
163
|
+
contextPinsPendingWrites = 0;
|
|
158
164
|
activeNodeId.value = null;
|
|
159
165
|
expandedNodeId.value = null;
|
|
160
166
|
pendingExpandedNodeCloseId.value = null;
|
|
@@ -394,6 +400,10 @@ export function getSelectedNodes(): CanvasNodeState[] {
|
|
|
394
400
|
}
|
|
395
401
|
|
|
396
402
|
// ── Context pin actions ──────────────────────────────────────
|
|
403
|
+
// Fence reads against both local writes and newer authoritative pin frames.
|
|
404
|
+
export let contextPinsRevision = 0;
|
|
405
|
+
export let contextPinsPendingWrites = 0;
|
|
406
|
+
|
|
397
407
|
export function toggleContextPin(id: string): void {
|
|
398
408
|
const next = new Set(contextPinnedNodeIds.value);
|
|
399
409
|
if (next.has(id)) {
|
|
@@ -414,6 +424,14 @@ export function addContextPins(ids: string[]): void {
|
|
|
414
424
|
syncContextPinsToServer(next);
|
|
415
425
|
}
|
|
416
426
|
|
|
427
|
+
export function removeContextPins(ids: string[]): void {
|
|
428
|
+
const next = new Set(contextPinnedNodeIds.value);
|
|
429
|
+
for (const id of ids) next.delete(id);
|
|
430
|
+
contextPinnedNodeIds.value = next;
|
|
431
|
+
persistLayout();
|
|
432
|
+
syncContextPinsToServer(new Set(ids), 'remove');
|
|
433
|
+
}
|
|
434
|
+
|
|
417
435
|
export function clearContextPins(): void {
|
|
418
436
|
if (contextPinnedNodeIds.value.size === 0) return;
|
|
419
437
|
contextPinnedNodeIds.value = new Set();
|
|
@@ -422,16 +440,51 @@ export function clearContextPins(): void {
|
|
|
422
440
|
}
|
|
423
441
|
|
|
424
442
|
export function replaceContextPinsFromServer(ids: string[]): void {
|
|
443
|
+
contextPinsRevision++;
|
|
425
444
|
contextPinnedNodeIds.value = new Set(ids);
|
|
426
445
|
}
|
|
427
446
|
|
|
428
|
-
|
|
447
|
+
/** Reconcile dropped frames and refused/normalized writes without replaying old pins. */
|
|
448
|
+
export async function refreshContextPins(): Promise<void> {
|
|
449
|
+
if (contextPinsPendingWrites > 0) return;
|
|
450
|
+
const revision = contextPinsRevision;
|
|
451
|
+
const epoch = canvasInteractionEpoch.value;
|
|
452
|
+
// The agent brief filters retired cards. Only the raw event snapshot is authoritative
|
|
453
|
+
// for membership; consume its pin frame without replaying the rest of the snapshot.
|
|
454
|
+
const data = await requestJson<{
|
|
455
|
+
events?: Array<{ event: string; payload: { nodeIds?: string[]; boardId?: string | null } }>;
|
|
456
|
+
}>('refreshContextPins', '/api/workbench/poll', {});
|
|
457
|
+
const pins = data.events?.find((entry) => entry.event === 'context-pins-changed')?.payload;
|
|
458
|
+
if (
|
|
459
|
+
revision === contextPinsRevision &&
|
|
460
|
+
epoch === canvasInteractionEpoch.value &&
|
|
461
|
+
pins?.boardId === activeBoardId.value &&
|
|
462
|
+
Array.isArray(pins.nodeIds)
|
|
463
|
+
) {
|
|
464
|
+
replaceContextPinsFromServer(pins.nodeIds);
|
|
465
|
+
syncAttentionFromSse({ event: 'context-pins-changed', data: pins });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function syncContextPinsToServer(ids: Set<string>, mode: 'set' | 'remove' = 'set'): void {
|
|
470
|
+
contextPinsRevision++;
|
|
471
|
+
contextPinsPendingWrites++;
|
|
472
|
+
const epoch = canvasInteractionEpoch.value;
|
|
429
473
|
// Through the bridge so the write carries the workbench marker — a bare
|
|
430
474
|
// fetch makes the human's pin look like an anonymous `api` agent.
|
|
431
475
|
void requestBestEffort('syncContextPinsToServer', '/api/canvas/context-pins', {
|
|
432
476
|
method: 'POST',
|
|
433
477
|
headers: { 'Content-Type': 'application/json' },
|
|
434
|
-
body: JSON.stringify({
|
|
478
|
+
body: JSON.stringify({
|
|
479
|
+
nodeIds: Array.from(ids),
|
|
480
|
+
mode,
|
|
481
|
+
...(boardsLoaded.value ? { expectedBoardId: activeBoardId.value } : {}),
|
|
482
|
+
}),
|
|
483
|
+
}).then(() => {
|
|
484
|
+
if (epoch !== canvasInteractionEpoch.value) return;
|
|
485
|
+
contextPinsPendingWrites--;
|
|
486
|
+
contextPinsRevision++;
|
|
487
|
+
void refreshContextPins();
|
|
435
488
|
});
|
|
436
489
|
}
|
|
437
490
|
|
|
@@ -535,7 +588,7 @@ export function removeEdgesForNode(nodeId: string): void {
|
|
|
535
588
|
let changed = false;
|
|
536
589
|
const next = new Map(edges.value);
|
|
537
590
|
for (const [id, edge] of next) {
|
|
538
|
-
if (edge.from === nodeId || edge.to === nodeId) {
|
|
591
|
+
if (edge.from === nodeId || (edge.toBoardId === undefined && edge.to === nodeId)) {
|
|
539
592
|
next.delete(id);
|
|
540
593
|
changed = true;
|
|
541
594
|
}
|
|
@@ -617,11 +670,7 @@ export function replaceViewport(next: ViewportState): void {
|
|
|
617
670
|
viewport.value = next;
|
|
618
671
|
}
|
|
619
672
|
|
|
620
|
-
export function commitViewport(next: ViewportState): void {
|
|
621
|
-
commitViewportWithOptions(next);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
function commitViewportWithOptions(next: ViewportState, options: { recordHistory?: boolean } = {}): void {
|
|
673
|
+
export function commitViewport(next: ViewportState, options: { recordHistory?: boolean } = {}): void {
|
|
625
674
|
viewport.value = next;
|
|
626
675
|
persistLayout(options);
|
|
627
676
|
void updateViewportFromClient(next, options);
|
|
@@ -634,11 +683,22 @@ export function applyServerCanvasLayout(
|
|
|
634
683
|
const nextNodes = new Map<string, CanvasNodeState>();
|
|
635
684
|
let nextMaxZ = 1;
|
|
636
685
|
for (const node of layout.nodes) {
|
|
637
|
-
|
|
686
|
+
const current = nodes.value.get(node.id);
|
|
687
|
+
// A poll captured before an acknowledged edit may arrive afterwards.
|
|
688
|
+
// Accept its geometry, but not older content or attribution. The initial
|
|
689
|
+
// layout belongs to a newly opened board, where IDs/revisions may repeat.
|
|
690
|
+
nextNodes.set(
|
|
691
|
+
node.id,
|
|
692
|
+
options.applyViewport !== true && current && (current.contentRevision ?? 0) > (node.contentRevision ?? 0)
|
|
693
|
+
? { ...node, data: current.data, contentRevision: current.contentRevision, lastEditedBy: current.lastEditedBy }
|
|
694
|
+
: node,
|
|
695
|
+
);
|
|
638
696
|
if (node.zIndex >= nextMaxZ) nextMaxZ = node.zIndex + 1;
|
|
639
697
|
}
|
|
640
698
|
|
|
641
|
-
const edgeSource = layout.edges.filter(
|
|
699
|
+
const edgeSource = layout.edges.filter(
|
|
700
|
+
(edge) => nextNodes.has(edge.from) && (edge.toBoardId !== undefined || nextNodes.has(edge.to)),
|
|
701
|
+
);
|
|
642
702
|
const nextEdges = new Map<string, CanvasEdge>();
|
|
643
703
|
for (const edge of edgeSource) {
|
|
644
704
|
nextEdges.set(edge.id, edge);
|
|
@@ -728,13 +788,39 @@ export function animateViewport(
|
|
|
728
788
|
animationId = requestAnimationFrame(tick);
|
|
729
789
|
} else {
|
|
730
790
|
animationId = null;
|
|
731
|
-
|
|
791
|
+
commitViewport(target, options);
|
|
732
792
|
}
|
|
733
793
|
}
|
|
734
794
|
|
|
735
795
|
animationId = requestAnimationFrame(tick);
|
|
736
796
|
}
|
|
737
797
|
|
|
798
|
+
/**
|
|
799
|
+
* Drive the camera along `frameAt(t)`, t in [0, 1], over `duration` ms — the
|
|
800
|
+
* tour/camera-move path. Shares animateViewport's slot, so a pan gesture or a
|
|
801
|
+
* capture frame (cancelViewportAnimation) interrupts it. Duration 0 jumps.
|
|
802
|
+
*/
|
|
803
|
+
export function animateCameraPath(
|
|
804
|
+
frameAt: (t: number) => ViewportState,
|
|
805
|
+
duration: number,
|
|
806
|
+
onDone?: (end: ViewportState) => void,
|
|
807
|
+
): void {
|
|
808
|
+
cancelViewportAnimation();
|
|
809
|
+
const start = performance.now();
|
|
810
|
+
const tick = (now: number) => {
|
|
811
|
+
const t = duration <= 0 ? 1 : Math.min(1, (now - start) / duration);
|
|
812
|
+
viewport.value = frameAt(t);
|
|
813
|
+
if (t < 1) {
|
|
814
|
+
animationId = requestAnimationFrame(tick);
|
|
815
|
+
} else {
|
|
816
|
+
animationId = null;
|
|
817
|
+
onDone?.(viewport.value);
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
if (duration <= 0) tick(start);
|
|
821
|
+
else animationId = requestAnimationFrame(tick);
|
|
822
|
+
}
|
|
823
|
+
|
|
738
824
|
/** Cancel any in-flight viewport animation (e.g. when user starts dragging). */
|
|
739
825
|
export function cancelViewportAnimation(): void {
|
|
740
826
|
if (animationId !== null) {
|
|
@@ -769,6 +855,7 @@ export function persistLayout(options: { recordHistory?: boolean } = {}): void {
|
|
|
769
855
|
id: e.id,
|
|
770
856
|
from: e.from,
|
|
771
857
|
to: e.to,
|
|
858
|
+
...(e.toBoardId !== undefined ? { toBoardId: e.toBoardId } : {}),
|
|
772
859
|
type: e.type,
|
|
773
860
|
label: e.label,
|
|
774
861
|
style: e.style,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { signal } from '@preact/signals';
|
|
2
|
+
import { activeBoardId, boardList } from './boards-store';
|
|
3
|
+
import { requestJson } from './intent-bridge';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The context chip (docs/design/ContextChip.dc.html): what the agent's brief
|
|
7
|
+
* costs in tokens, and when each pinned board was last read. The brief fetch is a measurement, not a delivery: it says so with
|
|
8
|
+
* `x-pmx-proxied-read`, so it is never booked as an agent read — not even from
|
|
9
|
+
* an `?agent=` tab, whose requests carry no workbench marker. Otherwise the
|
|
10
|
+
* recorded read would announce itself and refetch the chip in a loop.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface BriefSize {
|
|
14
|
+
chars: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BoardRead {
|
|
18
|
+
lastReadAt: string;
|
|
19
|
+
lastReadBy: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const briefSize = signal<BriefSize | null>(null);
|
|
23
|
+
export const pinnedBoardReads = signal<Map<string, BoardRead | null>>(new Map());
|
|
24
|
+
|
|
25
|
+
/** A token count for display: 873, 1.1k. */
|
|
26
|
+
export function formatTokens(tokens: number): string {
|
|
27
|
+
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : `${Math.round(tokens)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** ≈ tokens for a character count (about four characters per token). */
|
|
31
|
+
export function approxTokens(chars: number): string {
|
|
32
|
+
return formatTokens(chars / 4);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
36
|
+
let generation = 0;
|
|
37
|
+
|
|
38
|
+
/** Debounced: pins, reads and board changes arrive in bursts; content edits wait longer. */
|
|
39
|
+
export function refreshContextChip(delayMs = 250): void {
|
|
40
|
+
if (timer) clearTimeout(timer);
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
timer = null;
|
|
43
|
+
void load();
|
|
44
|
+
}, delayMs);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function load(): Promise<void> {
|
|
48
|
+
const request = ++generation;
|
|
49
|
+
const boardId = activeBoardId.value;
|
|
50
|
+
// A late answer for a board you have since left, or an older request, must not paint the chip.
|
|
51
|
+
const current = () => request === generation && boardId === activeBoardId.value;
|
|
52
|
+
const brief = await requestJson<{ entries?: unknown[] } | null>('fetchBriefSize', '/api/canvas/context', null, {
|
|
53
|
+
headers: { 'x-pmx-proxied-read': '1' },
|
|
54
|
+
});
|
|
55
|
+
if (!current()) return;
|
|
56
|
+
if (brief?.entries) briefSize.value = { chars: JSON.stringify(brief).length };
|
|
57
|
+
const pinned = boardList.value.filter((board) => board.pin);
|
|
58
|
+
if (pinned.length === 0) {
|
|
59
|
+
if (current()) pinnedBoardReads.value = new Map();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// One request for every pinned board's latest read.
|
|
63
|
+
const status = await requestJson<{ boards?: Record<string, BoardRead | null> } | null>(
|
|
64
|
+
'fetchBoardReadStatus',
|
|
65
|
+
`/api/canvas/ax/context-status?boards=${pinned.map((board) => encodeURIComponent(board.id)).join(',')}`,
|
|
66
|
+
null,
|
|
67
|
+
);
|
|
68
|
+
if (current())
|
|
69
|
+
pinnedBoardReads.value = new Map(pinned.map((board) => [board.id, status?.boards?.[board.id] ?? null]));
|
|
70
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { signal } from '@preact/signals';
|
|
2
2
|
import { activeBoardId } from './boards-store';
|
|
3
|
+
import { edges } from './canvas-store';
|
|
3
4
|
import { requestJson } from './intent-bridge';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -28,14 +29,18 @@ interface ContextStatusResponse {
|
|
|
28
29
|
ok?: boolean;
|
|
29
30
|
boardId?: string | null;
|
|
30
31
|
nodes?: NodeReadStatus[];
|
|
32
|
+
/** Per node, the newest links revision an agent read carried. */
|
|
33
|
+
links?: Record<string, number>;
|
|
31
34
|
pins?: Record<string, ContextPinMeta>;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
export const nodeReadStatus = signal<Map<string, NodeReadStatus>>(new Map());
|
|
35
38
|
export const contextPinMeta = signal<Record<string, ContextPinMeta>>({});
|
|
39
|
+
export const seenLinks = signal<Record<string, number>>({});
|
|
36
40
|
|
|
37
41
|
export function applyContextStatus(response: ContextStatusResponse): void {
|
|
38
42
|
nodeReadStatus.value = new Map((response.nodes ?? []).map((entry) => [entry.nodeId, entry]));
|
|
43
|
+
seenLinks.value = response.links ?? {};
|
|
39
44
|
contextPinMeta.value = response.pins ?? {};
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -60,6 +65,54 @@ export function pinnedReadState(nodeId: string, currentRevision: number): Pinned
|
|
|
60
65
|
return { kind: 'read', by: read.lastReadBy, at: read.lastReadAt, count: read.readCount };
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The marks a link's pill carries (docs/design/LinkAuthorship.dc.html):
|
|
70
|
+
* - `agent`: the agent that drew or last relabelled it — a ✦ that stays until
|
|
71
|
+
* a person edits, retypes or relabels the link. A read never clears it.
|
|
72
|
+
* - `notSeen`: a person's new or edited link that no read has carried yet —
|
|
73
|
+
* temporary, cleared by the agent's next read of either end. Quiet until an
|
|
74
|
+
* agent has read this board at all. An agent's link is never "not seen".
|
|
75
|
+
* The board's only person draws without a mark (another person's initial is
|
|
76
|
+
* Part 3, shared boards).
|
|
77
|
+
*/
|
|
78
|
+
export interface LinkMarks {
|
|
79
|
+
agent: string | null;
|
|
80
|
+
notSeen: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function linkMarks(edge: {
|
|
84
|
+
from: string;
|
|
85
|
+
to: string;
|
|
86
|
+
toBoardId?: string;
|
|
87
|
+
revision?: number;
|
|
88
|
+
changedBy?: { actor: string; source: string; agentId?: string };
|
|
89
|
+
}): LinkMarks {
|
|
90
|
+
if (edge.changedBy?.actor === 'agent') return { agent: writerName(edge.changedBy), notSeen: false };
|
|
91
|
+
const seen = seenLinks.value;
|
|
92
|
+
const notSeen =
|
|
93
|
+
!!edge.revision &&
|
|
94
|
+
Object.keys(seen).length > 0 &&
|
|
95
|
+
edge.revision > Math.max(seen[edge.from] ?? -1, edge.toBoardId === undefined ? (seen[edge.to] ?? -1) : -1);
|
|
96
|
+
return { agent: null, notSeen };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A card whose links changed after the last read that carried them (shown
|
|
101
|
+
* neutral, never amber). A link an agent drew does not count — that agent
|
|
102
|
+
* made it, and the link itself says "by <agent>". A removed link has no
|
|
103
|
+
* author left to check, so it counts.
|
|
104
|
+
*/
|
|
105
|
+
export function linksChanged(nodeId: string, linksRevision: number): boolean {
|
|
106
|
+
const seen = seenLinks.value[nodeId];
|
|
107
|
+
if (seen === undefined || linksRevision <= seen) return false;
|
|
108
|
+
const unseen = [...edges.value.values()].filter(
|
|
109
|
+
(edge) =>
|
|
110
|
+
(edge.from === nodeId || (edge.toBoardId === undefined && edge.to === nodeId)) && (edge.revision ?? 0) > seen,
|
|
111
|
+
);
|
|
112
|
+
if (unseen.some((edge) => edge.changedBy?.actor !== 'agent')) return true;
|
|
113
|
+
return !unseen.some((edge) => edge.revision === linksRevision);
|
|
114
|
+
}
|
|
115
|
+
|
|
63
116
|
/** Display name for an attributed writer: its agent id, else its transport label. */
|
|
64
117
|
export function writerName(actor: { source: string; agentId?: string }): string {
|
|
65
118
|
return actor.agentId?.trim() || actor.source;
|