pmx-canvas 0.1.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/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/Readme.md +865 -0
- package/dist/canvas/global.css +3173 -0
- package/dist/canvas/index.js +183 -0
- package/dist/json-render/index.css +2 -0
- package/dist/json-render/index.js +389 -0
- package/dist/types/cli/agent.d.ts +13 -0
- package/dist/types/cli/index.d.ts +2 -0
- package/dist/types/cli/watch.d.ts +5 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
- package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
- package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
- package/dist/types/client/canvas/DockedNode.d.ts +4 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
- package/dist/types/client/canvas/Minimap.d.ts +23 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
- package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
- package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
- package/dist/types/client/canvas/snap-guides.d.ts +23 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
- package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
- package/dist/types/client/ext-app/bridge.d.ts +161 -0
- package/dist/types/client/icons.d.ts +70 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/nodes/ContextNode.d.ts +34 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
- package/dist/types/client/nodes/FileNode.d.ts +5 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -0
- package/dist/types/client/nodes/ImageNode.d.ts +10 -0
- package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
- package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
- package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
- package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
- package/dist/types/client/nodes/PromptNode.d.ts +5 -0
- package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +4 -0
- package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
- package/dist/types/client/nodes/TraceNode.d.ts +4 -0
- package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
- package/dist/types/client/nodes/image-warnings.d.ts +6 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
- package/dist/types/client/nodes/md-format.d.ts +25 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/attention-store.d.ts +25 -0
- package/dist/types/client/state/canvas-store.d.ts +74 -0
- package/dist/types/client/state/intent-bridge.d.ts +158 -0
- package/dist/types/client/state/sse-bridge.d.ts +5 -0
- package/dist/types/client/theme/tokens.d.ts +27 -0
- package/dist/types/client/types.d.ts +40 -0
- package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
- package/dist/types/client/utils/placement.d.ts +1 -0
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/json-render/catalog.d.ts +815 -0
- package/dist/types/json-render/charts/components.d.ts +54 -0
- package/dist/types/json-render/charts/definitions.d.ts +103 -0
- package/dist/types/json-render/charts/extra-components.d.ts +58 -0
- package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
- package/dist/types/json-render/renderer/index.d.ts +16 -0
- package/dist/types/json-render/schema.d.ts +46 -0
- package/dist/types/json-render/server.d.ts +55 -0
- package/dist/types/mcp/server.d.ts +22 -0
- package/dist/types/server/agent-context.d.ts +21 -0
- package/dist/types/server/artifact-paths.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +154 -0
- package/dist/types/server/canvas-provenance.d.ts +13 -0
- package/dist/types/server/canvas-schema.d.ts +49 -0
- package/dist/types/server/canvas-serialization.d.ts +25 -0
- package/dist/types/server/canvas-state.d.ts +174 -0
- package/dist/types/server/canvas-validation.d.ts +33 -0
- package/dist/types/server/chart-template.d.ts +29 -0
- package/dist/types/server/code-graph.d.ts +67 -0
- package/dist/types/server/context-cards.d.ts +24 -0
- package/dist/types/server/diagram-presets.d.ts +28 -0
- package/dist/types/server/ext-app-call-registry.d.ts +16 -0
- package/dist/types/server/ext-app-tool-result.d.ts +1 -0
- package/dist/types/server/file-watcher.d.ts +16 -0
- package/dist/types/server/index.d.ts +243 -0
- package/dist/types/server/mcp-app-candidate.d.ts +25 -0
- package/dist/types/server/mcp-app-host.d.ts +65 -0
- package/dist/types/server/mcp-app-runtime.d.ts +47 -0
- package/dist/types/server/mutation-history.d.ts +105 -0
- package/dist/types/server/placement.d.ts +37 -0
- package/dist/types/server/server.d.ts +103 -0
- package/dist/types/server/spatial-analysis.d.ts +87 -0
- package/dist/types/server/trace-manager.d.ts +48 -0
- package/dist/types/server/web-artifacts.d.ts +50 -0
- package/dist/types/server/webpage-node.d.ts +25 -0
- package/dist/types/shared/auto-arrange.d.ts +29 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
- package/dist/types/shared/placement.d.ts +26 -0
- package/dist/types/shared/semantic-attention.d.ts +97 -0
- package/package.json +109 -0
- package/skills/data-analysis/SKILL.md +324 -0
- package/skills/doc-coauthoring/SKILL.md +375 -0
- package/skills/frontend-design/SKILL.md +45 -0
- package/skills/json-render-codegen/SKILL.md +112 -0
- package/skills/json-render-core/SKILL.md +265 -0
- package/skills/json-render-ink/SKILL.md +273 -0
- package/skills/json-render-mcp/SKILL.md +132 -0
- package/skills/json-render-react/SKILL.md +264 -0
- package/skills/json-render-shadcn/SKILL.md +159 -0
- package/skills/playwright-cli/SKILL.md +67 -0
- package/skills/pmx-canvas/SKILL.md +668 -0
- package/skills/pmx-canvas/evals/evals.json +186 -0
- package/skills/pmx-canvas-testing/SKILL.md +78 -0
- package/skills/published-consumer-e2e/SKILL.md +43 -0
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
- package/skills/web-artifacts-builder/SKILL.md +80 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
- package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
- package/skills/web-design-guidelines/SKILL.md +39 -0
- package/src/cli/agent.ts +2144 -0
- package/src/cli/index.ts +622 -0
- package/src/cli/watch.ts +88 -0
- package/src/client/App.tsx +507 -0
- package/src/client/canvas/AttentionHistory.tsx +81 -0
- package/src/client/canvas/AttentionToast.tsx +19 -0
- package/src/client/canvas/CanvasNode.tsx +363 -0
- package/src/client/canvas/CanvasViewport.tsx +590 -0
- package/src/client/canvas/CommandPalette.tsx +302 -0
- package/src/client/canvas/ContextMenu.tsx +601 -0
- package/src/client/canvas/ContextPinBar.tsx +25 -0
- package/src/client/canvas/ContextPinHud.tsx +22 -0
- package/src/client/canvas/DockedNode.tsx +66 -0
- package/src/client/canvas/EdgeLayer.tsx +280 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
- package/src/client/canvas/FocusFieldLayer.tsx +107 -0
- package/src/client/canvas/Minimap.tsx +301 -0
- package/src/client/canvas/SelectionBar.tsx +69 -0
- package/src/client/canvas/ShortcutOverlay.tsx +69 -0
- package/src/client/canvas/SnapshotPanel.tsx +236 -0
- package/src/client/canvas/snap-guides.ts +170 -0
- package/src/client/canvas/use-node-drag.ts +51 -0
- package/src/client/canvas/use-node-resize.ts +59 -0
- package/src/client/canvas/use-pan-zoom.ts +191 -0
- package/src/client/ext-app/bridge.ts +542 -0
- package/src/client/icons.tsx +424 -0
- package/src/client/index.tsx +7 -0
- package/src/client/nodes/ContextNode.tsx +412 -0
- package/src/client/nodes/ExtAppFrame.tsx +509 -0
- package/src/client/nodes/FileNode.tsx +256 -0
- package/src/client/nodes/GroupNode.tsx +39 -0
- package/src/client/nodes/ImageNode.tsx +160 -0
- package/src/client/nodes/InlineFormatBar.tsx +169 -0
- package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
- package/src/client/nodes/LedgerNode.tsx +37 -0
- package/src/client/nodes/MarkdownNode.tsx +359 -0
- package/src/client/nodes/McpAppNode.tsx +85 -0
- package/src/client/nodes/MdFormatBar.tsx +109 -0
- package/src/client/nodes/PromptNode.tsx +597 -0
- package/src/client/nodes/ResponseNode.tsx +153 -0
- package/src/client/nodes/StatusNode.tsx +84 -0
- package/src/client/nodes/StatusSummary.tsx +38 -0
- package/src/client/nodes/TraceNode.tsx +120 -0
- package/src/client/nodes/WebpageNode.tsx +288 -0
- package/src/client/nodes/image-warnings.ts +95 -0
- package/src/client/nodes/inline-editor-commands.ts +37 -0
- package/src/client/nodes/md-format.ts +206 -0
- package/src/client/state/attention-bridge.ts +328 -0
- package/src/client/state/attention-store.ts +73 -0
- package/src/client/state/canvas-store.ts +631 -0
- package/src/client/state/intent-bridge.ts +315 -0
- package/src/client/state/sse-bridge.ts +965 -0
- package/src/client/theme/global.css +3173 -0
- package/src/client/theme/tokens.ts +72 -0
- package/src/client/types-shims.d.ts +5 -0
- package/src/client/types.ts +81 -0
- package/src/client/utils/ext-app-tool-result.ts +4 -0
- package/src/client/utils/placement.ts +4 -0
- package/src/client/utils/platform.ts +2 -0
- package/src/json-render/catalog.ts +256 -0
- package/src/json-render/charts/components.tsx +198 -0
- package/src/json-render/charts/definitions.ts +81 -0
- package/src/json-render/charts/extra-components.tsx +267 -0
- package/src/json-render/charts/extra-definitions.ts +145 -0
- package/src/json-render/renderer/index.css +174 -0
- package/src/json-render/renderer/index.tsx +86 -0
- package/src/json-render/schema.ts +62 -0
- package/src/json-render/server.ts +597 -0
- package/src/mcp/server.ts +1377 -0
- package/src/server/agent-context.ts +242 -0
- package/src/server/artifact-paths.ts +17 -0
- package/src/server/canvas-operations.ts +1279 -0
- package/src/server/canvas-provenance.ts +243 -0
- package/src/server/canvas-schema.ts +432 -0
- package/src/server/canvas-serialization.ts +95 -0
- package/src/server/canvas-state.ts +1134 -0
- package/src/server/canvas-validation.ts +114 -0
- package/src/server/chart-template.ts +449 -0
- package/src/server/code-graph.ts +370 -0
- package/src/server/context-cards.ts +31 -0
- package/src/server/diagram-presets.ts +71 -0
- package/src/server/ext-app-call-registry.ts +77 -0
- package/src/server/ext-app-tool-result.ts +4 -0
- package/src/server/file-watcher.ts +121 -0
- package/src/server/index.ts +647 -0
- package/src/server/mcp-app-candidate.ts +174 -0
- package/src/server/mcp-app-host.ts +814 -0
- package/src/server/mcp-app-runtime.ts +459 -0
- package/src/server/mutation-history.ts +350 -0
- package/src/server/placement.ts +125 -0
- package/src/server/server.ts +3846 -0
- package/src/server/spatial-analysis.ts +356 -0
- package/src/server/trace-manager.ts +333 -0
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
- package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
- package/src/server/web-artifacts.ts +442 -0
- package/src/server/webpage-node.ts +328 -0
- package/src/shared/auto-arrange.ts +439 -0
- package/src/shared/ext-app-tool-result.ts +76 -0
- package/src/shared/placement.ts +81 -0
- package/src/shared/semantic-attention.ts +598 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { CanvasNodeState } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface ImageNodeWarning {
|
|
4
|
+
title: string;
|
|
5
|
+
detail: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ImageWarningDescriptor {
|
|
9
|
+
title?: unknown;
|
|
10
|
+
detail?: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readTrimmedString(value: unknown): string | null {
|
|
14
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function descriptorFromUnknown(value: unknown): ImageNodeWarning | null {
|
|
18
|
+
if (typeof value === 'string') {
|
|
19
|
+
const trimmed = readTrimmedString(value);
|
|
20
|
+
return trimmed ? { title: 'Evidence warning', detail: trimmed } : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!value || typeof value !== 'object') return null;
|
|
24
|
+
const descriptor = value as ImageWarningDescriptor;
|
|
25
|
+
const title = readTrimmedString(descriptor.title) ?? 'Evidence warning';
|
|
26
|
+
const detail = readTrimmedString(descriptor.detail);
|
|
27
|
+
return detail ? { title, detail } : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function metadataWarnings(node: CanvasNodeState): ImageNodeWarning[] {
|
|
31
|
+
const warnings: ImageNodeWarning[] = [];
|
|
32
|
+
const singleWarning = descriptorFromUnknown(node.data.warning);
|
|
33
|
+
if (singleWarning) warnings.push(singleWarning);
|
|
34
|
+
|
|
35
|
+
const rawWarnings = node.data.warnings;
|
|
36
|
+
if (Array.isArray(rawWarnings)) {
|
|
37
|
+
for (const candidate of rawWarnings) {
|
|
38
|
+
const warning = descriptorFromUnknown(candidate);
|
|
39
|
+
if (warning) warnings.push(warning);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const validationStatus = readTrimmedString(node.data.validationStatus)?.toLowerCase();
|
|
44
|
+
if (validationStatus === 'failed' || validationStatus === 'invalid') {
|
|
45
|
+
warnings.push({
|
|
46
|
+
title: 'Image failed validation',
|
|
47
|
+
detail: readTrimmedString(node.data.validationMessage) ?? 'Review this image before using it as evidence.',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return warnings;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function looksLikeLoginCapture(node: CanvasNodeState): boolean {
|
|
55
|
+
const haystack = [
|
|
56
|
+
node.data.title,
|
|
57
|
+
node.data.caption,
|
|
58
|
+
node.data.alt,
|
|
59
|
+
node.data.src,
|
|
60
|
+
node.data.path,
|
|
61
|
+
]
|
|
62
|
+
.map((value) => readTrimmedString(value)?.toLowerCase() ?? '')
|
|
63
|
+
.join(' ');
|
|
64
|
+
|
|
65
|
+
if (haystack.length === 0) return false;
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
'login',
|
|
69
|
+
'log in',
|
|
70
|
+
'sign in',
|
|
71
|
+
'signin',
|
|
72
|
+
'password',
|
|
73
|
+
'2fa',
|
|
74
|
+
'mfa',
|
|
75
|
+
'authenticate',
|
|
76
|
+
'authentication',
|
|
77
|
+
'sso',
|
|
78
|
+
].some((token) => haystack.includes(token));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getImageNodeWarnings(node: CanvasNodeState): ImageNodeWarning[] {
|
|
82
|
+
const warnings = metadataWarnings(node);
|
|
83
|
+
if (warnings.length > 0) return warnings;
|
|
84
|
+
|
|
85
|
+
if (looksLikeLoginCapture(node)) {
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
title: 'Captured login page',
|
|
89
|
+
detail: 'This image looks like an auth screen. Treat it as environment context, not product evidence.',
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Shared contentEditable command helpers used by both the inline editor
|
|
2
|
+
* and its floating toolbar. Kept in a module that neither component
|
|
3
|
+
* depends on transitively so we don't get a circular import. */
|
|
4
|
+
|
|
5
|
+
/** Prompt for a URL and insert it as a link on the current selection.
|
|
6
|
+
* Rejects `javascript:` and `data:` schemes so a link can't execute script
|
|
7
|
+
* when clicked. */
|
|
8
|
+
export function promptAndInsertLink(): void {
|
|
9
|
+
const url = window.prompt('Link URL:');
|
|
10
|
+
if (!url) return;
|
|
11
|
+
const trimmed = url.trim().toLowerCase();
|
|
12
|
+
if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:')) return;
|
|
13
|
+
document.execCommand('createLink', false, url);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Wrap the current non-empty selection in an inline `<code>` element and
|
|
17
|
+
* place the caret immediately after the new element. No-op on collapsed
|
|
18
|
+
* selections. */
|
|
19
|
+
export function wrapSelectionInCode(): void {
|
|
20
|
+
const sel = window.getSelection();
|
|
21
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
22
|
+
const range = sel.getRangeAt(0);
|
|
23
|
+
if (range.collapsed) return;
|
|
24
|
+
const text = range.toString();
|
|
25
|
+
const code = document.createElement('code');
|
|
26
|
+
code.textContent = text;
|
|
27
|
+
range.deleteContents();
|
|
28
|
+
range.insertNode(code);
|
|
29
|
+
// Build a fresh range after the inserted node — `deleteContents` mutates
|
|
30
|
+
// the original range's boundaries in a way that behaves inconsistently
|
|
31
|
+
// across browsers on selections spanning block boundaries.
|
|
32
|
+
const after = document.createRange();
|
|
33
|
+
after.setStartAfter(code);
|
|
34
|
+
after.setEndAfter(code);
|
|
35
|
+
sel.removeAllRanges();
|
|
36
|
+
sel.addRange(after);
|
|
37
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown formatting helpers for textarea elements.
|
|
3
|
+
* Handles: wrap-style formatting (bold, italic, code, strikethrough),
|
|
4
|
+
* prefix-style formatting (headings, lists, quotes),
|
|
5
|
+
* Tab/Shift+Tab indentation, and floating toolbar positioning.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface FormatAction {
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
icon: string;
|
|
12
|
+
shortcut?: string;
|
|
13
|
+
action: (ta: HTMLTextAreaElement) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Wrap selected text with a marker (e.g. ** for bold). If no selection, insert marker pair and place cursor between. */
|
|
17
|
+
function wrapSelection(ta: HTMLTextAreaElement, marker: string): void {
|
|
18
|
+
const { selectionStart: s, selectionEnd: e, value } = ta;
|
|
19
|
+
const selected = value.slice(s, e);
|
|
20
|
+
|
|
21
|
+
// Check if already wrapped — unwrap
|
|
22
|
+
const before = value.slice(Math.max(0, s - marker.length), s);
|
|
23
|
+
const after = value.slice(e, e + marker.length);
|
|
24
|
+
if (before === marker && after === marker) {
|
|
25
|
+
const newValue = value.slice(0, s - marker.length) + selected + value.slice(e + marker.length);
|
|
26
|
+
setValueAndCursor(ta, newValue, s - marker.length, e - marker.length);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (selected) {
|
|
31
|
+
const newValue = value.slice(0, s) + marker + selected + marker + value.slice(e);
|
|
32
|
+
setValueAndCursor(ta, newValue, s + marker.length, e + marker.length);
|
|
33
|
+
} else {
|
|
34
|
+
const newValue = value.slice(0, s) + marker + marker + value.slice(e);
|
|
35
|
+
setValueAndCursor(ta, newValue, s + marker.length, s + marker.length);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Prefix the current line(s) with a string (e.g. "# " for heading). */
|
|
40
|
+
function prefixLines(ta: HTMLTextAreaElement, prefix: string): void {
|
|
41
|
+
const { selectionStart: s, selectionEnd: e, value } = ta;
|
|
42
|
+
const lineStart = value.lastIndexOf('\n', s - 1) + 1;
|
|
43
|
+
const lineEnd = value.indexOf('\n', e);
|
|
44
|
+
const end = lineEnd === -1 ? value.length : lineEnd;
|
|
45
|
+
const lineText = value.slice(lineStart, end);
|
|
46
|
+
|
|
47
|
+
// Toggle: if already prefixed, remove
|
|
48
|
+
if (lineText.startsWith(prefix)) {
|
|
49
|
+
const newValue = value.slice(0, lineStart) + lineText.slice(prefix.length) + value.slice(end);
|
|
50
|
+
setValueAndCursor(ta, newValue, Math.max(lineStart, s - prefix.length), Math.max(lineStart, e - prefix.length));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const newValue = value.slice(0, lineStart) + prefix + value.slice(lineStart);
|
|
55
|
+
setValueAndCursor(ta, newValue, s + prefix.length, e + prefix.length);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Insert a link template, using selected text as the label. */
|
|
59
|
+
function insertLink(ta: HTMLTextAreaElement): void {
|
|
60
|
+
const { selectionStart: s, selectionEnd: e, value } = ta;
|
|
61
|
+
const selected = value.slice(s, e);
|
|
62
|
+
if (selected) {
|
|
63
|
+
const newValue = value.slice(0, s) + `[${selected}](url)` + value.slice(e);
|
|
64
|
+
// Select "url" for easy replacement
|
|
65
|
+
const urlStart = s + selected.length + 3;
|
|
66
|
+
setValueAndCursor(ta, newValue, urlStart, urlStart + 3);
|
|
67
|
+
} else {
|
|
68
|
+
const newValue = value.slice(0, s) + '[text](url)' + value.slice(e);
|
|
69
|
+
// Select "text" for easy replacement
|
|
70
|
+
setValueAndCursor(ta, newValue, s + 1, s + 5);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Insert a code block. */
|
|
75
|
+
function insertCodeBlock(ta: HTMLTextAreaElement): void {
|
|
76
|
+
const { selectionStart: s, selectionEnd: e, value } = ta;
|
|
77
|
+
const selected = value.slice(s, e);
|
|
78
|
+
const fence = '```';
|
|
79
|
+
if (selected) {
|
|
80
|
+
const newValue = value.slice(0, s) + `${fence}\n${selected}\n${fence}` + value.slice(e);
|
|
81
|
+
setValueAndCursor(ta, newValue, s + fence.length + 1, s + fence.length + 1 + selected.length);
|
|
82
|
+
} else {
|
|
83
|
+
const newValue = value.slice(0, s) + `${fence}\n\n${fence}` + value.slice(e);
|
|
84
|
+
setValueAndCursor(ta, newValue, s + fence.length + 1, s + fence.length + 1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Handle Tab/Shift+Tab for indentation on current line(s). */
|
|
89
|
+
export function handleTab(ta: HTMLTextAreaElement, shiftKey: boolean): void {
|
|
90
|
+
const { selectionStart: s, selectionEnd: e, value } = ta;
|
|
91
|
+
const lineStart = value.lastIndexOf('\n', s - 1) + 1;
|
|
92
|
+
const lineEnd = value.indexOf('\n', e);
|
|
93
|
+
const end = lineEnd === -1 ? value.length : lineEnd;
|
|
94
|
+
|
|
95
|
+
// Multi-line selection: indent/dedent all lines
|
|
96
|
+
const lines = value.slice(lineStart, end).split('\n');
|
|
97
|
+
let newS = s;
|
|
98
|
+
let newE = e;
|
|
99
|
+
|
|
100
|
+
const processed = lines.map((line, i) => {
|
|
101
|
+
if (shiftKey) {
|
|
102
|
+
// Dedent: remove up to 2 leading spaces or 1 tab
|
|
103
|
+
if (line.startsWith(' ')) {
|
|
104
|
+
if (i === 0) newS = Math.max(lineStart, newS - 2);
|
|
105
|
+
newE -= 2;
|
|
106
|
+
return line.slice(2);
|
|
107
|
+
} else if (line.startsWith('\t')) {
|
|
108
|
+
if (i === 0) newS = Math.max(lineStart, newS - 1);
|
|
109
|
+
newE -= 1;
|
|
110
|
+
return line.slice(1);
|
|
111
|
+
}
|
|
112
|
+
return line;
|
|
113
|
+
} else {
|
|
114
|
+
// Indent: add 2 spaces
|
|
115
|
+
if (i === 0) newS += 2;
|
|
116
|
+
newE += 2;
|
|
117
|
+
return ' ' + line;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const newValue = value.slice(0, lineStart) + processed.join('\n') + value.slice(end);
|
|
122
|
+
setValueAndCursor(ta, newValue, newS, newE);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function setValueAndCursor(ta: HTMLTextAreaElement, value: string, selStart: number, selEnd: number): void {
|
|
126
|
+
// Use native setter to trigger Preact's onInput
|
|
127
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
|
128
|
+
if (nativeInputValueSetter) {
|
|
129
|
+
nativeInputValueSetter.call(ta, value);
|
|
130
|
+
} else {
|
|
131
|
+
ta.value = value;
|
|
132
|
+
}
|
|
133
|
+
ta.selectionStart = selStart;
|
|
134
|
+
ta.selectionEnd = selEnd;
|
|
135
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Format actions available in the toolbar. */
|
|
139
|
+
export const FORMAT_ACTIONS: FormatAction[] = [
|
|
140
|
+
{ key: 'bold', label: 'Bold', icon: 'B', shortcut: 'b', action: (ta) => wrapSelection(ta, '**') },
|
|
141
|
+
{ key: 'italic', label: 'Italic', icon: 'I', shortcut: 'i', action: (ta) => wrapSelection(ta, '_') },
|
|
142
|
+
{ key: 'strike', label: 'Strikethrough', icon: 'S', shortcut: 'u', action: (ta) => wrapSelection(ta, '~~') },
|
|
143
|
+
{ key: 'code', label: 'Inline code', icon: '`', shortcut: 'e', action: (ta) => wrapSelection(ta, '`') },
|
|
144
|
+
{ key: 'link', label: 'Link', icon: '🔗', shortcut: 'k', action: insertLink },
|
|
145
|
+
{ key: 'h1', label: 'Heading 1', icon: 'H1', action: (ta) => prefixLines(ta, '# ') },
|
|
146
|
+
{ key: 'h2', label: 'Heading 2', icon: 'H2', action: (ta) => prefixLines(ta, '## ') },
|
|
147
|
+
{ key: 'quote', label: 'Blockquote', icon: '❝', action: (ta) => prefixLines(ta, '> ') },
|
|
148
|
+
{ key: 'ul', label: 'Bullet list', icon: '•', action: (ta) => prefixLines(ta, '- ') },
|
|
149
|
+
{ key: 'ol', label: 'Numbered list', icon: '1.', action: (ta) => prefixLines(ta, '1. ') },
|
|
150
|
+
{ key: 'codeblock', label: 'Code block', icon: '{ }', action: insertCodeBlock },
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
/** Handle Cmd/Ctrl+key formatting shortcuts in a textarea. Returns true if handled. */
|
|
154
|
+
export function handleFormatShortcut(e: KeyboardEvent, ta: HTMLTextAreaElement): boolean {
|
|
155
|
+
if (!(e.metaKey || e.ctrlKey)) return false;
|
|
156
|
+
const action = FORMAT_ACTIONS.find((a) => a.shortcut === e.key);
|
|
157
|
+
if (!action) return false;
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
action.action(ta);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Get the screen rect of the current text selection for positioning a floating toolbar. */
|
|
164
|
+
export function getSelectionRect(ta: HTMLTextAreaElement): { top: number; left: number; width: number } | null {
|
|
165
|
+
if (ta.selectionStart === ta.selectionEnd) return null;
|
|
166
|
+
|
|
167
|
+
// Create a mirror div to measure cursor position
|
|
168
|
+
const mirror = document.createElement('div');
|
|
169
|
+
const style = getComputedStyle(ta);
|
|
170
|
+
const props = [
|
|
171
|
+
'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing',
|
|
172
|
+
'wordSpacing', 'textIndent', 'whiteSpace', 'wordWrap', 'overflowWrap',
|
|
173
|
+
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
|
174
|
+
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
|
|
175
|
+
'boxSizing', 'width',
|
|
176
|
+
] as const;
|
|
177
|
+
for (const p of props) {
|
|
178
|
+
mirror.style[p] = style[p];
|
|
179
|
+
}
|
|
180
|
+
mirror.style.position = 'absolute';
|
|
181
|
+
mirror.style.visibility = 'hidden';
|
|
182
|
+
mirror.style.overflow = 'hidden';
|
|
183
|
+
mirror.style.height = 'auto';
|
|
184
|
+
mirror.style.top = '0';
|
|
185
|
+
mirror.style.left = '0';
|
|
186
|
+
|
|
187
|
+
const text = ta.value;
|
|
188
|
+
const beforeText = document.createTextNode(text.slice(0, ta.selectionStart));
|
|
189
|
+
const span = document.createElement('span');
|
|
190
|
+
span.textContent = text.slice(ta.selectionStart, ta.selectionEnd) || '.';
|
|
191
|
+
mirror.appendChild(beforeText);
|
|
192
|
+
mirror.appendChild(span);
|
|
193
|
+
mirror.appendChild(document.createTextNode(text.slice(ta.selectionEnd)));
|
|
194
|
+
|
|
195
|
+
document.body.appendChild(mirror);
|
|
196
|
+
const spanRect = span.getBoundingClientRect();
|
|
197
|
+
const mirrorRect = mirror.getBoundingClientRect();
|
|
198
|
+
const taRect = ta.getBoundingClientRect();
|
|
199
|
+
document.body.removeChild(mirror);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
top: taRect.top + (spanRect.top - mirrorRect.top) - ta.scrollTop,
|
|
203
|
+
left: taRect.left + (spanRect.left - mirrorRect.left) - ta.scrollLeft,
|
|
204
|
+
width: spanRect.width,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SemanticWatchReducer,
|
|
3
|
+
type ContextPinWatchEvent,
|
|
4
|
+
type ConnectWatchEvent,
|
|
5
|
+
type GroupWatchEvent,
|
|
6
|
+
type MoveEndWatchEvent,
|
|
7
|
+
type RemoveWatchEvent,
|
|
8
|
+
type SemanticWatchEvent,
|
|
9
|
+
type SseMessage,
|
|
10
|
+
} from '../../shared/semantic-attention.js';
|
|
11
|
+
import {
|
|
12
|
+
pushAttentionHistory,
|
|
13
|
+
resetAttentionState,
|
|
14
|
+
setAttentionFocus,
|
|
15
|
+
setAttentionPulse,
|
|
16
|
+
setAttentionToast,
|
|
17
|
+
type AttentionEntry,
|
|
18
|
+
type AttentionTone,
|
|
19
|
+
} from './attention-store';
|
|
20
|
+
|
|
21
|
+
let reducer = new SemanticWatchReducer();
|
|
22
|
+
let toastQueue: AttentionEntry[] = [];
|
|
23
|
+
let toastTimer: number | null = null;
|
|
24
|
+
let pulseTimer: number | null = null;
|
|
25
|
+
let lastSignature = '';
|
|
26
|
+
let lastSignatureAt = 0;
|
|
27
|
+
|
|
28
|
+
function scheduleTimer(callback: () => void, delayMs: number): number {
|
|
29
|
+
return globalThis.setTimeout(callback, delayMs) as unknown as number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cancelTimer(timerId: number | null): void {
|
|
33
|
+
if (timerId === null) return;
|
|
34
|
+
globalThis.clearTimeout(timerId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function runOnNextFrame(callback: () => void): void {
|
|
38
|
+
if (typeof globalThis.requestAnimationFrame === 'function') {
|
|
39
|
+
globalThis.requestAnimationFrame(() => callback());
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
callback();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function quoteLabel(value: string | null | undefined, fallback: string): string {
|
|
46
|
+
return value && value.trim().length > 0 ? value.trim() : fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function summarizeNames(values: string[], limit = 3): string {
|
|
50
|
+
if (values.length === 0) return '';
|
|
51
|
+
if (values.length <= limit) return values.join(', ');
|
|
52
|
+
return `${values.slice(0, limit).join(', ')} +${values.length - limit}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function uniqueNodeIds(nodeIds: Iterable<string>): string[] {
|
|
56
|
+
return Array.from(new Set(Array.from(nodeIds).filter((nodeId) => nodeId.length > 0)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeEntry(
|
|
60
|
+
tone: AttentionTone,
|
|
61
|
+
title: string,
|
|
62
|
+
detail: string,
|
|
63
|
+
nodeIds: string[],
|
|
64
|
+
createdAt = Date.now(),
|
|
65
|
+
): AttentionEntry {
|
|
66
|
+
return {
|
|
67
|
+
id: `${tone}-${createdAt}-${Math.random().toString(36).slice(2, 8)}`,
|
|
68
|
+
tone,
|
|
69
|
+
title,
|
|
70
|
+
detail,
|
|
71
|
+
nodeIds: uniqueNodeIds(nodeIds),
|
|
72
|
+
createdAt,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatReason(reason: string): string {
|
|
77
|
+
if (reason === 'joined cluster') return 'joined a nearby cluster';
|
|
78
|
+
if (reason === 'left cluster') return 'moved away from its cluster';
|
|
79
|
+
if (reason === 'cluster changed') return 'shifted into a different cluster';
|
|
80
|
+
if (reason === 'pinned neighborhood changed') return 'changed the local focus field';
|
|
81
|
+
const enteredMatch = /^entered pinned neighborhood of "(.+)"$/.exec(reason);
|
|
82
|
+
if (enteredMatch) return `moved into focus around ${enteredMatch[1]}`;
|
|
83
|
+
const leftMatch = /^left pinned neighborhood of "(.+)"$/.exec(reason);
|
|
84
|
+
if (leftMatch) return `moved out of focus around ${leftMatch[1]}`;
|
|
85
|
+
return reason;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function entryFromContextPin(event: ContextPinWatchEvent): AttentionEntry | null {
|
|
89
|
+
const addedNames = event.added.map((node) => quoteLabel(node.title, node.id));
|
|
90
|
+
const removedNames = event.removed.map((node) => quoteLabel(node.title, node.id));
|
|
91
|
+
if (addedNames.length === 0 && removedNames.length === 0) return null;
|
|
92
|
+
|
|
93
|
+
let detail = '';
|
|
94
|
+
if (addedNames.length > 0 && removedNames.length === 0) {
|
|
95
|
+
detail = `Now in focus: ${summarizeNames(addedNames)}`;
|
|
96
|
+
} else if (removedNames.length > 0 && addedNames.length === 0) {
|
|
97
|
+
detail = `Removed from focus: ${summarizeNames(removedNames)}`;
|
|
98
|
+
} else {
|
|
99
|
+
const parts: string[] = [];
|
|
100
|
+
if (addedNames.length > 0) parts.push(`${summarizeNames(addedNames)} added`);
|
|
101
|
+
if (removedNames.length > 0) parts.push(`${summarizeNames(removedNames)} removed`);
|
|
102
|
+
detail = parts.join(' · ');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return makeEntry(
|
|
106
|
+
'context',
|
|
107
|
+
'Context updated',
|
|
108
|
+
detail,
|
|
109
|
+
[
|
|
110
|
+
...event.added.map((node) => node.id),
|
|
111
|
+
...event.removed.map((node) => node.id),
|
|
112
|
+
],
|
|
113
|
+
event.timestamp ? Date.parse(event.timestamp) || Date.now() : Date.now(),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function entryFromConnect(event: ConnectWatchEvent): AttentionEntry | null {
|
|
118
|
+
if (event.edges.length === 0) return null;
|
|
119
|
+
if (event.edges.length === 1) {
|
|
120
|
+
const edge = event.edges[0];
|
|
121
|
+
return makeEntry(
|
|
122
|
+
'relationship',
|
|
123
|
+
'Relationship added',
|
|
124
|
+
`${quoteLabel(edge.fromTitle, edge.fromId)} linked to ${quoteLabel(edge.toTitle, edge.toId)}`,
|
|
125
|
+
[edge.fromId, edge.toId],
|
|
126
|
+
event.timestamp ? Date.parse(event.timestamp) || Date.now() : Date.now(),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return makeEntry(
|
|
131
|
+
'relationship',
|
|
132
|
+
'Relationships added',
|
|
133
|
+
`${event.edges.length} connections changed the board structure`,
|
|
134
|
+
event.edges.flatMap((edge) => [edge.fromId, edge.toId]),
|
|
135
|
+
event.timestamp ? Date.parse(event.timestamp) || Date.now() : Date.now(),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function entryFromRemove(event: RemoveWatchEvent): AttentionEntry | null {
|
|
140
|
+
if (event.nodes.length === 0 && event.edges.length === 0) return null;
|
|
141
|
+
const nodeNames = event.nodes.map((node) => quoteLabel(node.title, node.id));
|
|
142
|
+
const parts: string[] = [];
|
|
143
|
+
if (nodeNames.length > 0) parts.push(summarizeNames(nodeNames));
|
|
144
|
+
if (event.edges.length > 0) {
|
|
145
|
+
parts.push(`${event.edges.length} relationship${event.edges.length === 1 ? '' : 's'}`);
|
|
146
|
+
}
|
|
147
|
+
return makeEntry(
|
|
148
|
+
'remove',
|
|
149
|
+
'Items removed',
|
|
150
|
+
parts.join(' · '),
|
|
151
|
+
[
|
|
152
|
+
...event.nodes.map((node) => node.id),
|
|
153
|
+
...event.edges.flatMap((edge) => [edge.fromId, edge.toId]),
|
|
154
|
+
],
|
|
155
|
+
event.timestamp ? Date.parse(event.timestamp) || Date.now() : Date.now(),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function entryFromGroup(event: GroupWatchEvent): AttentionEntry | null {
|
|
160
|
+
if (event.created.length > 0) {
|
|
161
|
+
const titles = event.created.map((group) => quoteLabel(group.title, group.id));
|
|
162
|
+
return makeEntry(
|
|
163
|
+
'group',
|
|
164
|
+
event.created.length === 1 ? 'Group created' : 'Groups created',
|
|
165
|
+
event.created.length === 1
|
|
166
|
+
? `${titles[0]} now frames ${event.created[0].childCount} items`
|
|
167
|
+
: summarizeNames(titles),
|
|
168
|
+
event.created.map((group) => group.id),
|
|
169
|
+
event.timestamp ? Date.parse(event.timestamp) || Date.now() : Date.now(),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (event.updated.length > 0) {
|
|
173
|
+
const titles = event.updated.map((group) => quoteLabel(group.title, group.id));
|
|
174
|
+
return makeEntry(
|
|
175
|
+
'group',
|
|
176
|
+
event.updated.length === 1 ? 'Group updated' : 'Groups updated',
|
|
177
|
+
event.updated.length === 1
|
|
178
|
+
? `${titles[0]} now holds ${event.updated[0].childCount} items`
|
|
179
|
+
: summarizeNames(titles),
|
|
180
|
+
event.updated.map((group) => group.id),
|
|
181
|
+
event.timestamp ? Date.parse(event.timestamp) || Date.now() : Date.now(),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function entryFromMoveEnd(event: MoveEndWatchEvent): AttentionEntry | null {
|
|
188
|
+
if (event.nodes.length === 0) return null;
|
|
189
|
+
const reasons = event.nodes.flatMap((node) => node.reasons);
|
|
190
|
+
const names = event.nodes.map((node) => quoteLabel(node.title, node.id));
|
|
191
|
+
const createdAt = event.timestamp ? Date.parse(event.timestamp) || Date.now() : Date.now();
|
|
192
|
+
|
|
193
|
+
if (reasons.some((reason) => reason.includes('pinned neighborhood'))) {
|
|
194
|
+
if (event.nodes.length === 1) {
|
|
195
|
+
return makeEntry(
|
|
196
|
+
'neighborhood',
|
|
197
|
+
'Neighborhood changed',
|
|
198
|
+
`${names[0]} ${formatReason(event.nodes[0].reasons[0])}`,
|
|
199
|
+
event.nodes.map((node) => node.id),
|
|
200
|
+
createdAt,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return makeEntry(
|
|
204
|
+
'neighborhood',
|
|
205
|
+
'Neighborhood changed',
|
|
206
|
+
summarizeNames(names),
|
|
207
|
+
event.nodes.map((node) => node.id),
|
|
208
|
+
createdAt,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const clusterReasons = reasons.filter((reason) => reason.includes('cluster'));
|
|
213
|
+
if (clusterReasons.length > 0) {
|
|
214
|
+
const formedOnly = clusterReasons.every((reason) => reason === 'joined cluster');
|
|
215
|
+
if (event.nodes.length === 1) {
|
|
216
|
+
return makeEntry(
|
|
217
|
+
'cluster',
|
|
218
|
+
formedOnly ? 'Cluster formed' : 'Cluster changed',
|
|
219
|
+
`${names[0]} ${formatReason(event.nodes[0].reasons[0])}`,
|
|
220
|
+
event.nodes.map((node) => node.id),
|
|
221
|
+
createdAt,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return makeEntry(
|
|
225
|
+
'cluster',
|
|
226
|
+
formedOnly ? 'Cluster formed' : 'Cluster changed',
|
|
227
|
+
summarizeNames(names),
|
|
228
|
+
event.nodes.map((node) => node.id),
|
|
229
|
+
createdAt,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function entryFromEvent(event: SemanticWatchEvent): AttentionEntry | null {
|
|
237
|
+
switch (event.type) {
|
|
238
|
+
case 'context-pin':
|
|
239
|
+
return entryFromContextPin(event);
|
|
240
|
+
case 'connect':
|
|
241
|
+
return entryFromConnect(event);
|
|
242
|
+
case 'remove':
|
|
243
|
+
return entryFromRemove(event);
|
|
244
|
+
case 'group':
|
|
245
|
+
return entryFromGroup(event);
|
|
246
|
+
case 'move-end':
|
|
247
|
+
return entryFromMoveEnd(event);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function applyAttentionSnapshot(): void {
|
|
252
|
+
const snapshot = reducer.getAttentionSnapshot();
|
|
253
|
+
setAttentionFocus(snapshot.primaryFocusNodeIds, snapshot.secondaryFocusNodeIds, snapshot.regions);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function flushToastQueue(): void {
|
|
257
|
+
if (toastTimer !== null) return;
|
|
258
|
+
const next = toastQueue.shift() ?? null;
|
|
259
|
+
if (!next) {
|
|
260
|
+
setAttentionToast(null);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
setAttentionToast(next);
|
|
265
|
+
const durationMs = Math.max(1800, Math.min(2800, 1600 + next.detail.length * 18));
|
|
266
|
+
toastTimer = scheduleTimer(() => {
|
|
267
|
+
toastTimer = null;
|
|
268
|
+
setAttentionToast(null);
|
|
269
|
+
flushToastQueue();
|
|
270
|
+
}, durationMs);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function enqueueToast(entry: AttentionEntry): void {
|
|
274
|
+
toastQueue.push(entry);
|
|
275
|
+
flushToastQueue();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function pulseNodes(nodeIds: string[]): void {
|
|
279
|
+
cancelTimer(pulseTimer);
|
|
280
|
+
pulseTimer = null;
|
|
281
|
+
setAttentionPulse([]);
|
|
282
|
+
if (nodeIds.length === 0) return;
|
|
283
|
+
runOnNextFrame(() => {
|
|
284
|
+
setAttentionPulse(nodeIds);
|
|
285
|
+
pulseTimer = scheduleTimer(() => {
|
|
286
|
+
pulseTimer = null;
|
|
287
|
+
setAttentionPulse([]);
|
|
288
|
+
}, 900);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function shouldSuppressEntry(entry: AttentionEntry): boolean {
|
|
293
|
+
const signature = `${entry.tone}:${entry.title}:${entry.detail}`;
|
|
294
|
+
const now = entry.createdAt;
|
|
295
|
+
if (signature === lastSignature && now - lastSignatureAt < 1200) {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
lastSignature = signature;
|
|
299
|
+
lastSignatureAt = now;
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function resetAttentionBridge(): void {
|
|
304
|
+
cancelTimer(toastTimer);
|
|
305
|
+
cancelTimer(pulseTimer);
|
|
306
|
+
toastTimer = null;
|
|
307
|
+
pulseTimer = null;
|
|
308
|
+
toastQueue = [];
|
|
309
|
+
lastSignature = '';
|
|
310
|
+
lastSignatureAt = 0;
|
|
311
|
+
reducer = new SemanticWatchReducer();
|
|
312
|
+
resetAttentionState();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function syncAttentionFromSse(message: SseMessage): void {
|
|
316
|
+
const entries = reducer.handleMessage(message)
|
|
317
|
+
.map((event) => entryFromEvent(event))
|
|
318
|
+
.filter((entry): entry is AttentionEntry => entry !== null);
|
|
319
|
+
|
|
320
|
+
applyAttentionSnapshot();
|
|
321
|
+
|
|
322
|
+
for (const entry of entries) {
|
|
323
|
+
if (shouldSuppressEntry(entry)) continue;
|
|
324
|
+
pushAttentionHistory(entry);
|
|
325
|
+
enqueueToast(entry);
|
|
326
|
+
pulseNodes(entry.nodeIds);
|
|
327
|
+
}
|
|
328
|
+
}
|