pmx-canvas 0.4.8 → 0.5.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/extension.mjs +154 -5
- package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/CHANGELOG.md +139 -0
- package/Readme.md +72 -23
- package/dist/canvas/global.css +3069 -796
- package/dist/canvas/index.js +104 -104
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/pump.d.ts +9 -0
- package/dist/types/client/App.d.ts +1 -1
- package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
- package/dist/types/client/canvas/BarHint.d.ts +22 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
- package/dist/types/client/canvas/CommandBar.d.ts +10 -0
- package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
- package/dist/types/client/canvas/EmptyState.d.ts +11 -0
- package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
- package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/Minimap.d.ts +12 -1
- package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
- package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
- package/dist/types/client/canvas/ToolRail.d.ts +21 -0
- package/dist/types/client/canvas/TopBar.d.ts +7 -0
- package/dist/types/client/canvas/canvas-area.d.ts +25 -0
- package/dist/types/client/canvas/create-in-view.d.ts +18 -0
- package/dist/types/client/canvas/import-files.d.ts +8 -0
- package/dist/types/client/canvas/kind-colors.d.ts +3 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
- package/dist/types/client/canvas/use-now.d.ts +6 -0
- package/dist/types/client/icons.d.ts +11 -4
- package/dist/types/client/nodes/GroupNode.d.ts +6 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
- package/dist/types/client/state/canvas-store.d.ts +49 -5
- package/dist/types/client/state/human-store.d.ts +26 -0
- package/dist/types/client/state/iframe-mode.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +37 -5
- package/dist/types/client/state/node-factory.d.ts +0 -1
- package/dist/types/client/state/presence-store.d.ts +78 -0
- package/dist/types/client/state/session-store.d.ts +176 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/server/agent-presence.d.ts +192 -0
- package/dist/types/server/ax-gate-ttl.d.ts +6 -0
- package/dist/types/server/ax-interaction.d.ts +4 -10
- package/dist/types/server/ax-state-manager.d.ts +21 -2
- package/dist/types/server/ax-state.d.ts +49 -3
- package/dist/types/server/ax-wait.d.ts +13 -0
- package/dist/types/server/canvas-db.d.ts +9 -1
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +27 -13
- package/dist/types/server/canvas-validation.d.ts +0 -14
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/human-presence.d.ts +40 -0
- package/dist/types/server/index.d.ts +29 -3
- package/dist/types/server/intent-registry.d.ts +7 -0
- package/dist/types/server/mutation-history.d.ts +15 -2
- package/dist/types/server/operations/invoker.d.ts +13 -2
- package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
- package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
- package/dist/types/server/operations/registry.d.ts +11 -0
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/scope-fence.d.ts +62 -0
- package/dist/types/server/server.d.ts +0 -12
- package/dist/types/shared/agent-presence.d.ts +130 -0
- package/dist/types/shared/approval-gates.d.ts +19 -0
- package/dist/types/shared/auto-arrange.d.ts +0 -1
- package/dist/types/shared/ax-kinds.d.ts +11 -0
- package/dist/types/shared/human-presence.d.ts +31 -0
- package/dist/types/shared/iframe-probe.d.ts +8 -0
- package/dist/types/shared/scope-fence.d.ts +21 -0
- package/docs/RELEASE.md +6 -0
- package/docs/ax-host-adapter-contract.md +30 -14
- package/docs/cli.md +37 -1
- package/docs/environment.md +3 -0
- package/docs/http-api.md +222 -5
- package/docs/mcp.md +6 -5
- package/docs/node-types.md +3 -1
- package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/multi-agent-live.png +0 -0
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +1 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +103 -16
- package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
- package/skills/pmx-canvas/references/full-reference.md +55 -16
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
- package/skills/pmx-canvas-testing/SKILL.md +17 -0
- package/src/cli/agent.ts +1 -0
- package/src/cli/commands/copilot.ts +15 -8
- package/src/cli/commands/nodes.ts +1 -19
- package/src/cli/commands/pump.ts +252 -0
- package/src/cli/index.ts +1 -5
- package/src/cli/shared.ts +1 -1
- package/src/client/App.tsx +209 -585
- package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
- package/src/client/canvas/AttentionHistory.tsx +0 -10
- package/src/client/canvas/BarHint.tsx +49 -0
- package/src/client/canvas/CanvasNode.tsx +488 -136
- package/src/client/canvas/CanvasViewport.tsx +190 -94
- package/src/client/canvas/CommandBar.tsx +140 -0
- package/src/client/canvas/CommandPalette.tsx +133 -58
- package/src/client/canvas/ConnectionBanner.tsx +65 -0
- package/src/client/canvas/ContextMenu.tsx +88 -27
- package/src/client/canvas/ContextPinBar.tsx +2 -2
- package/src/client/canvas/EdgeLayer.tsx +121 -38
- package/src/client/canvas/EmptyState.tsx +93 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
- package/src/client/canvas/ExternalWriters.tsx +308 -0
- package/src/client/canvas/FocusFieldLayer.tsx +12 -1
- package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
- package/src/client/canvas/IntentLayer.tsx +1 -1
- package/src/client/canvas/Minimap.tsx +117 -190
- package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
- package/src/client/canvas/SelectionBar.tsx +175 -23
- package/src/client/canvas/SessionPanel.tsx +481 -0
- package/src/client/canvas/SessionReceipt.tsx +141 -0
- package/src/client/canvas/ShortcutOverlay.tsx +21 -6
- package/src/client/canvas/SnapshotPanel.tsx +166 -80
- package/src/client/canvas/TextPrompt.tsx +128 -0
- package/src/client/canvas/ToolRail.tsx +447 -0
- package/src/client/canvas/TopBar.tsx +308 -0
- package/src/client/canvas/auto-fit.ts +1 -5
- package/src/client/canvas/canvas-area.ts +41 -0
- package/src/client/canvas/create-in-view.ts +48 -0
- package/src/client/canvas/import-files.ts +64 -0
- package/src/client/canvas/kind-colors.ts +22 -0
- package/src/client/canvas/snap-guides.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +54 -0
- package/src/client/canvas/use-now.ts +17 -0
- package/src/client/canvas/use-pan-zoom.ts +13 -2
- package/src/client/icons.tsx +57 -18
- package/src/client/nodes/GroupNode.tsx +9 -28
- package/src/client/nodes/InlineFormatBar.tsx +4 -3
- package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
- package/src/client/nodes/MarkdownNode.tsx +59 -24
- package/src/client/nodes/MdFormatBar.tsx +2 -4
- package/src/client/nodes/inline-editor-commands.ts +17 -6
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/state/attention-bridge.ts +3 -0
- package/src/client/state/canvas-store.ts +273 -84
- package/src/client/state/human-store.ts +175 -0
- package/src/client/state/iframe-mode.ts +33 -9
- package/src/client/state/intent-bridge.ts +75 -13
- package/src/client/state/node-factory.ts +2 -8
- package/src/client/state/presence-store.ts +168 -0
- package/src/client/state/session-store.ts +419 -0
- package/src/client/state/sse-bridge.ts +93 -30
- package/src/client/theme/global.css +3069 -796
- package/src/client/types.ts +3 -1
- package/src/client/utils/platform.ts +6 -1
- package/src/mcp/canvas-access.ts +2 -2
- package/src/server/agent-presence.ts +755 -0
- package/src/server/ax-context.ts +45 -0
- package/src/server/ax-gate-ttl.ts +61 -0
- package/src/server/ax-interaction.ts +29 -1
- package/src/server/ax-state-manager.ts +78 -6
- package/src/server/ax-state.ts +82 -15
- package/src/server/ax-wait.ts +33 -0
- package/src/server/canvas-db.ts +103 -33
- package/src/server/canvas-operations.ts +17 -8
- package/src/server/canvas-state.ts +131 -58
- package/src/server/canvas-validation.ts +3 -53
- package/src/server/demo-state.json +2 -70
- package/src/server/html-surface.ts +11 -2
- package/src/server/human-presence.ts +160 -0
- package/src/server/index.ts +107 -4
- package/src/server/intent-registry.ts +13 -0
- package/src/server/mutation-history.ts +47 -2
- package/src/server/operations/composites.ts +6 -3
- package/src/server/operations/http.ts +10 -1
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/invoker.ts +33 -6
- package/src/server/operations/ops/ax-presence.ts +62 -0
- package/src/server/operations/ops/ax-read.ts +8 -0
- package/src/server/operations/ops/ax-state.ts +106 -1
- package/src/server/operations/ops/ax-timeline.ts +54 -5
- package/src/server/operations/ops/ax-work.ts +46 -0
- package/src/server/operations/ops/batch.ts +3 -1
- package/src/server/operations/ops/edges.ts +88 -2
- package/src/server/operations/ops/groups.ts +4 -3
- package/src/server/operations/ops/human-presence.ts +35 -0
- package/src/server/operations/ops/json-render.ts +11 -1
- package/src/server/operations/ops/nodes.ts +65 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +16 -0
- package/src/server/operations/registry.ts +196 -1
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +207 -0
- package/src/server/server.ts +177 -79
- package/src/server/trace-manager.ts +0 -2
- package/src/server/web-artifacts.ts +0 -1
- package/src/shared/agent-presence.ts +162 -0
- package/src/shared/approval-gates.ts +35 -0
- package/src/shared/auto-arrange.ts +121 -13
- package/src/shared/ax-kinds.ts +23 -0
- package/src/shared/human-presence.ts +30 -0
- package/src/shared/iframe-probe.ts +8 -0
- package/src/shared/scope-fence.ts +31 -0
- package/dist/types/client/canvas/DockedNode.d.ts +0 -4
- package/src/client/canvas/DockedNode.tsx +0 -165
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { effect } from '@preact/signals';
|
|
1
2
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
3
|
+
import { askText } from './TextPrompt';
|
|
2
4
|
import { ContextNode } from '../nodes/ContextNode';
|
|
3
5
|
import { DiffNode } from '../nodes/DiffNode';
|
|
4
6
|
import { FileNode } from '../nodes/FileNode';
|
|
@@ -29,11 +31,20 @@ import {
|
|
|
29
31
|
viewport,
|
|
30
32
|
createAnnotationFromClient,
|
|
31
33
|
removeAnnotationFromClient,
|
|
34
|
+
isPanModeActive,
|
|
35
|
+
dragDropTarget,
|
|
36
|
+
hiddenByCollapsedGroup,
|
|
37
|
+
canvasTool,
|
|
32
38
|
} from '../state/canvas-store';
|
|
33
39
|
import { createEdgeFromClient, createNodeFromClient } from '../state/intent-bridge';
|
|
34
|
-
import type { CanvasAnnotation, CanvasNodeState } from '../types';
|
|
40
|
+
import type { AnnotationTool, CanvasAnnotation, CanvasNodeState } from '../types';
|
|
35
41
|
import { FocusFieldLayer } from './FocusFieldLayer';
|
|
42
|
+
import { importFiles } from './import-files';
|
|
36
43
|
import { IntentLayer } from './IntentLayer';
|
|
44
|
+
import { AgentPresenceLayer } from './AgentPresenceLayer';
|
|
45
|
+
import { HumanPresenceLayer } from './HumanPresenceLayer';
|
|
46
|
+
import { reportHumanCursor } from '../state/human-store';
|
|
47
|
+
import { ScopeFenceLayer } from './ScopeFenceLayer';
|
|
37
48
|
import { CanvasNode } from './CanvasNode';
|
|
38
49
|
import { EdgeLayer } from './EdgeLayer';
|
|
39
50
|
import { AnnotationLayer } from './AnnotationLayer';
|
|
@@ -151,17 +162,14 @@ const ANNOTATION_WIDTH = 4;
|
|
|
151
162
|
const TEXT_ANNOTATION_WIDTH = 24;
|
|
152
163
|
const ERASER_HIT_RADIUS = 14;
|
|
153
164
|
|
|
154
|
-
type AnnotationTool = 'pen' | 'eraser' | 'text' | null;
|
|
155
|
-
|
|
156
165
|
interface CanvasViewportProps {
|
|
157
166
|
onNodeContextMenu?: (e: MouseEvent, nodeId: string) => void;
|
|
167
|
+
onEdgeContextMenu?: (e: MouseEvent, edgeId: string) => void;
|
|
158
168
|
onCanvasContextMenu?: (e: MouseEvent, canvasX: number, canvasY: number) => void;
|
|
159
169
|
annotationMode?: boolean;
|
|
160
170
|
annotationTool?: AnnotationTool;
|
|
161
171
|
}
|
|
162
172
|
|
|
163
|
-
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
|
|
164
|
-
const MD_EXTS = new Set(['md', 'mdx', 'markdown']);
|
|
165
173
|
const WEBPAGE_NODE_SIZE = { width: 520, height: 420 };
|
|
166
174
|
|
|
167
175
|
function normalizeUrlCandidate(raw: string): string | null {
|
|
@@ -214,21 +222,16 @@ function isEditableElement(element: Element | null): boolean {
|
|
|
214
222
|
return Boolean(element.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]'));
|
|
215
223
|
}
|
|
216
224
|
|
|
217
|
-
function nodeTypeFromFilename(name: string): 'image' | 'markdown' | 'file' {
|
|
218
|
-
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
219
|
-
if (IMAGE_EXTS.has(ext)) return 'image';
|
|
220
|
-
if (MD_EXTS.has(ext)) return 'markdown';
|
|
221
|
-
return 'file';
|
|
222
|
-
}
|
|
223
|
-
|
|
224
225
|
export function getRenderableWorldNodes(
|
|
225
226
|
allNodes: Iterable<CanvasNodeState>,
|
|
226
227
|
focusedNodeId: string | null,
|
|
228
|
+
hiddenIds: ReadonlySet<string> = new Set(),
|
|
227
229
|
): CanvasNodeState[] {
|
|
228
230
|
const worldNodes: CanvasNodeState[] = [];
|
|
229
231
|
let insertIdx = 0; // groups fill from the front
|
|
230
232
|
for (const n of allNodes) {
|
|
231
|
-
|
|
233
|
+
// Children of a collapsed group are hidden behind its chip.
|
|
234
|
+
if (hiddenIds.has(n.id)) continue;
|
|
232
235
|
// Focus mode renders the node inside the overlay. Skip the original world
|
|
233
236
|
// instance so embedded apps do not mount twice.
|
|
234
237
|
if (focusedNodeId && n.id === focusedNodeId) continue;
|
|
@@ -243,12 +246,14 @@ export function getRenderableWorldNodes(
|
|
|
243
246
|
|
|
244
247
|
export function CanvasViewport({
|
|
245
248
|
onNodeContextMenu,
|
|
249
|
+
onEdgeContextMenu,
|
|
246
250
|
onCanvasContextMenu,
|
|
247
251
|
annotationMode = false,
|
|
248
252
|
annotationTool = null,
|
|
249
253
|
}: CanvasViewportProps) {
|
|
250
254
|
const v = viewport.value;
|
|
251
255
|
const isLassoing = useRef(false);
|
|
256
|
+
const lastPointerScreen = useRef<{ x: number; y: number } | null>(null);
|
|
252
257
|
const isAnnotating = useRef(false);
|
|
253
258
|
const annotationPoints = useRef<Array<{ x: number; y: number }>>([]);
|
|
254
259
|
const [lasso, setLasso] = useState<LassoRect | null>(null);
|
|
@@ -261,6 +266,30 @@ export function CanvasViewport({
|
|
|
261
266
|
// without stale-closure issues from useCallback dependency capture.
|
|
262
267
|
const lassoRef = useRef<LassoRect | null>(null);
|
|
263
268
|
|
|
269
|
+
// Raster hygiene: promote the world layer only WHILE the viewport moves and
|
|
270
|
+
// demote it once it settles. A permanent will-change pinned a cached raster
|
|
271
|
+
// the compositor merely stretched — at high zoom the node headline (12px
|
|
272
|
+
// text magnified by the matrix) went permanently blurry. Dropping the hint
|
|
273
|
+
// after the last change forces a crisp re-raster at the final scale.
|
|
274
|
+
const worldRef = useRef<HTMLDivElement>(null);
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
277
|
+
const dispose = effect(() => {
|
|
278
|
+
void viewport.value;
|
|
279
|
+
const world = worldRef.current;
|
|
280
|
+
if (!world) return;
|
|
281
|
+
world.style.willChange = 'transform';
|
|
282
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
283
|
+
settleTimer = setTimeout(() => {
|
|
284
|
+
world.style.willChange = 'auto';
|
|
285
|
+
}, 180);
|
|
286
|
+
});
|
|
287
|
+
return () => {
|
|
288
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
289
|
+
dispose();
|
|
290
|
+
};
|
|
291
|
+
}, []);
|
|
292
|
+
|
|
264
293
|
const containerRef = usePanZoom({
|
|
265
294
|
viewport,
|
|
266
295
|
disabled: annotationMode,
|
|
@@ -318,7 +347,7 @@ export function CanvasViewport({
|
|
|
318
347
|
|
|
319
348
|
if (annotationTool === 'eraser') {
|
|
320
349
|
const target = e.target instanceof Element ? e.target : null;
|
|
321
|
-
if (target?.closest('.
|
|
350
|
+
if (target?.closest('.tool-rail, .top-bar, .snapshot-panel, .context-menu, .command-palette')) return;
|
|
322
351
|
e.preventDefault();
|
|
323
352
|
e.stopPropagation();
|
|
324
353
|
const rect = container.getBoundingClientRect();
|
|
@@ -334,7 +363,7 @@ export function CanvasViewport({
|
|
|
334
363
|
|
|
335
364
|
if (annotationTool === 'text') {
|
|
336
365
|
const target = e.target instanceof Element ? e.target : null;
|
|
337
|
-
if (target?.closest('.
|
|
366
|
+
if (target?.closest('.tool-rail, .top-bar, .snapshot-panel, .context-menu, .command-palette')) return;
|
|
338
367
|
e.preventDefault();
|
|
339
368
|
e.stopPropagation();
|
|
340
369
|
activeNodeId.value = null;
|
|
@@ -351,7 +380,7 @@ export function CanvasViewport({
|
|
|
351
380
|
|
|
352
381
|
if (annotationTool === 'pen') {
|
|
353
382
|
const target = e.target instanceof Element ? e.target : null;
|
|
354
|
-
if (target?.closest('.
|
|
383
|
+
if (target?.closest('.tool-rail, .top-bar, .snapshot-panel, .context-menu, .command-palette')) return;
|
|
355
384
|
e.preventDefault();
|
|
356
385
|
e.stopPropagation();
|
|
357
386
|
activeNodeId.value = null;
|
|
@@ -379,14 +408,17 @@ export function CanvasViewport({
|
|
|
379
408
|
|
|
380
409
|
if (e.target !== container) return;
|
|
381
410
|
|
|
382
|
-
|
|
383
|
-
|
|
411
|
+
// Pan tool / held Space: usePanZoom owns this drag.
|
|
412
|
+
if (isPanModeActive() || e.button === 1) {
|
|
413
|
+
if (!e.shiftKey && !lassoRef.current) {
|
|
384
414
|
activeNodeId.value = null;
|
|
385
415
|
clearSelection();
|
|
386
416
|
}
|
|
387
417
|
return;
|
|
388
418
|
}
|
|
389
419
|
|
|
420
|
+
// Select tool: plain background drag draws the lasso. A sub-threshold
|
|
421
|
+
// drag (a click) falls through in handlePointerUp and clears selection.
|
|
390
422
|
e.preventDefault();
|
|
391
423
|
e.stopPropagation();
|
|
392
424
|
isLassoing.current = true;
|
|
@@ -401,8 +433,39 @@ export function CanvasViewport({
|
|
|
401
433
|
[annotationTool, containerRef],
|
|
402
434
|
);
|
|
403
435
|
|
|
436
|
+
// Trackpad pans and zooms move the world under a motionless pointer — no
|
|
437
|
+
// pointermove fires — so other tabs would watch this cursor drift to a stale
|
|
438
|
+
// world point while this tab navigates. Re-derive the world position from
|
|
439
|
+
// the last screen point on every viewport change (throttled by the store).
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
return effect(() => {
|
|
442
|
+
const vp = viewport.value;
|
|
443
|
+
const screen = lastPointerScreen.current;
|
|
444
|
+
const container = containerRef.current;
|
|
445
|
+
if (!screen || !container) return;
|
|
446
|
+
const rect = container.getBoundingClientRect();
|
|
447
|
+
reportHumanCursor({
|
|
448
|
+
x: (screen.x - rect.left - vp.x) / vp.scale,
|
|
449
|
+
y: (screen.y - rect.top - vp.y) / vp.scale,
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
}, []);
|
|
453
|
+
|
|
404
454
|
const handlePointerMove = useCallback(
|
|
405
455
|
(e: PointerEvent) => {
|
|
456
|
+
// Human presence (phase 8): other tabs see this pointer in world space.
|
|
457
|
+
{
|
|
458
|
+
const container = containerRef.current;
|
|
459
|
+
if (container) {
|
|
460
|
+
lastPointerScreen.current = { x: e.clientX, y: e.clientY };
|
|
461
|
+
const rect = container.getBoundingClientRect();
|
|
462
|
+
const vp = viewport.value;
|
|
463
|
+
reportHumanCursor({
|
|
464
|
+
x: (e.clientX - rect.left - vp.x) / vp.scale,
|
|
465
|
+
y: (e.clientY - rect.top - vp.y) / vp.scale,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
406
469
|
if (isAnnotating.current) {
|
|
407
470
|
const container = containerRef.current;
|
|
408
471
|
if (!container) return;
|
|
@@ -460,6 +523,12 @@ export function CanvasViewport({
|
|
|
460
523
|
const minY = Math.min(current.startY, current.currentY);
|
|
461
524
|
const maxY = Math.max(current.startY, current.currentY);
|
|
462
525
|
|
|
526
|
+
// A sub-threshold lasso is a background CLICK: clear the selection —
|
|
527
|
+
// pre-rail chrome this was the plain-pointerdown path.
|
|
528
|
+
if (maxX - minX <= 5 && maxY - minY <= 5) {
|
|
529
|
+
activeNodeId.value = null;
|
|
530
|
+
clearSelection();
|
|
531
|
+
}
|
|
463
532
|
// Only commit if the lasso was dragged at least a few pixels
|
|
464
533
|
if (maxX - minX > 5 || maxY - minY > 5) {
|
|
465
534
|
// Convert screen lasso rect to world-space
|
|
@@ -472,7 +541,6 @@ export function CanvasViewport({
|
|
|
472
541
|
// Find intersecting nodes (AABB intersection)
|
|
473
542
|
const hits: string[] = [];
|
|
474
543
|
for (const node of nodes.value.values()) {
|
|
475
|
-
if (node.dockPosition !== null) continue;
|
|
476
544
|
const nx = node.position.x;
|
|
477
545
|
const ny = node.position.y;
|
|
478
546
|
if (nx + node.size.width > worldMinX && nx < worldMaxX && ny + node.size.height > worldMinY && ny < worldMaxY) {
|
|
@@ -516,53 +584,78 @@ export function CanvasViewport({
|
|
|
516
584
|
if (annotationTool !== 'text' && textDraft) setTextDraft(null);
|
|
517
585
|
}, [annotationTool, setTextDraft, textDraft]);
|
|
518
586
|
|
|
519
|
-
// ── Drag-to-connect
|
|
587
|
+
// ── Drag-to-connect (item 15): the target lights up under the cursor,
|
|
588
|
+
// Esc cancels, L asks for a label on release. ──
|
|
520
589
|
useEffect(() => {
|
|
590
|
+
const nodeAt = (wx: number, wy: number, exceptId: string): string | null => {
|
|
591
|
+
let hit: string | null = null;
|
|
592
|
+
for (const node of nodes.value.values()) {
|
|
593
|
+
if (node.id === exceptId || node.type === 'group') continue;
|
|
594
|
+
if (
|
|
595
|
+
wx >= node.position.x &&
|
|
596
|
+
wx <= node.position.x + node.size.width &&
|
|
597
|
+
wy >= node.position.y &&
|
|
598
|
+
wy <= node.position.y + node.size.height
|
|
599
|
+
) {
|
|
600
|
+
hit = node.id;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return hit;
|
|
604
|
+
};
|
|
605
|
+
|
|
521
606
|
function handleMove(e: PointerEvent) {
|
|
522
607
|
if (!draggingEdge.value) return;
|
|
523
608
|
const container = containerRef.current;
|
|
524
609
|
if (!container) return;
|
|
525
610
|
const rect = container.getBoundingClientRect();
|
|
526
611
|
const v = viewport.value;
|
|
612
|
+
const cursorX = (e.clientX - rect.left - v.x) / v.scale;
|
|
613
|
+
const cursorY = (e.clientY - rect.top - v.y) / v.scale;
|
|
527
614
|
draggingEdge.value = {
|
|
528
615
|
...draggingEdge.value,
|
|
529
|
-
cursorX
|
|
530
|
-
cursorY
|
|
616
|
+
cursorX,
|
|
617
|
+
cursorY,
|
|
618
|
+
targetId: nodeAt(cursorX, cursorY, draggingEdge.value.fromId),
|
|
531
619
|
};
|
|
532
620
|
}
|
|
533
621
|
|
|
534
|
-
function handleUp(
|
|
622
|
+
function handleUp() {
|
|
535
623
|
const drag = draggingEdge.value;
|
|
536
624
|
if (!drag) return;
|
|
537
625
|
draggingEdge.value = null;
|
|
626
|
+
const targetId = drag.targetId;
|
|
627
|
+
if (!targetId) return;
|
|
628
|
+
if (drag.withLabel) {
|
|
629
|
+
// In-canvas prompt — `window.prompt` is a silent no-op in embedded
|
|
630
|
+
// panes (the L-labelled connect just dropped the label there).
|
|
631
|
+
// Cancelling still creates the edge, unlabelled, like prompt-cancel did.
|
|
632
|
+
void askText('Edge label', 'label text').then((label) => {
|
|
633
|
+
createEdgeFromClient(drag.fromId, targetId, 'relation', label ?? undefined);
|
|
634
|
+
});
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
createEdgeFromClient(drag.fromId, targetId, 'relation', undefined);
|
|
638
|
+
}
|
|
538
639
|
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
if (!
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
if (node.id === drag.fromId || node.dockPosition !== null) continue;
|
|
549
|
-
if (
|
|
550
|
-
wx >= node.position.x &&
|
|
551
|
-
wx <= node.position.x + node.size.width &&
|
|
552
|
-
wy >= node.position.y &&
|
|
553
|
-
wy <= node.position.y + node.size.height
|
|
554
|
-
) {
|
|
555
|
-
createEdgeFromClient(drag.fromId, node.id, 'relation');
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
640
|
+
function handleKey(e: KeyboardEvent) {
|
|
641
|
+
const drag = draggingEdge.value;
|
|
642
|
+
if (!drag) return;
|
|
643
|
+
if (e.key === 'Escape') {
|
|
644
|
+
e.preventDefault();
|
|
645
|
+
draggingEdge.value = null;
|
|
646
|
+
} else if (e.key === 'l' || e.key === 'L') {
|
|
647
|
+
e.preventDefault();
|
|
648
|
+
draggingEdge.value = { ...drag, withLabel: !drag.withLabel };
|
|
558
649
|
}
|
|
559
650
|
}
|
|
560
651
|
|
|
561
652
|
document.addEventListener('pointermove', handleMove);
|
|
562
653
|
document.addEventListener('pointerup', handleUp);
|
|
654
|
+
document.addEventListener('keydown', handleKey);
|
|
563
655
|
return () => {
|
|
564
656
|
document.removeEventListener('pointermove', handleMove);
|
|
565
657
|
document.removeEventListener('pointerup', handleUp);
|
|
658
|
+
document.removeEventListener('keydown', handleKey);
|
|
566
659
|
};
|
|
567
660
|
}, [containerRef]);
|
|
568
661
|
|
|
@@ -654,51 +747,7 @@ export function CanvasViewport({
|
|
|
654
747
|
await createWebpageNodes(urls, baseWx, baseWy);
|
|
655
748
|
return;
|
|
656
749
|
}
|
|
657
|
-
|
|
658
|
-
const nodeW = 400;
|
|
659
|
-
const nodeH = 300;
|
|
660
|
-
const spacing = 20;
|
|
661
|
-
const cols = Math.ceil(Math.sqrt(files.length));
|
|
662
|
-
|
|
663
|
-
for (let i = 0; i < files.length; i++) {
|
|
664
|
-
const file = files[i];
|
|
665
|
-
const col = i % cols;
|
|
666
|
-
const row = Math.floor(i / cols);
|
|
667
|
-
const wx = baseWx - (cols * (nodeW + spacing)) / 2 + col * (nodeW + spacing);
|
|
668
|
-
const wy = baseWy - nodeH / 2 + row * (nodeH + spacing);
|
|
669
|
-
|
|
670
|
-
const type = nodeTypeFromFilename(file.name);
|
|
671
|
-
const fileName = file.name;
|
|
672
|
-
|
|
673
|
-
if (type === 'image') {
|
|
674
|
-
const reader = new FileReader();
|
|
675
|
-
const dataUri: string = await new Promise((resolve) => {
|
|
676
|
-
reader.onload = () => resolve(reader.result as string);
|
|
677
|
-
reader.readAsDataURL(file);
|
|
678
|
-
});
|
|
679
|
-
await createNodeFromClient({
|
|
680
|
-
type: 'image',
|
|
681
|
-
title: fileName,
|
|
682
|
-
content: dataUri,
|
|
683
|
-
x: wx,
|
|
684
|
-
y: wy,
|
|
685
|
-
width: nodeW,
|
|
686
|
-
height: nodeH,
|
|
687
|
-
});
|
|
688
|
-
} else {
|
|
689
|
-
const text = await file.text();
|
|
690
|
-
const isWide = type === 'markdown' || type === 'file';
|
|
691
|
-
await createNodeFromClient({
|
|
692
|
-
type,
|
|
693
|
-
title: fileName,
|
|
694
|
-
content: text,
|
|
695
|
-
x: wx,
|
|
696
|
-
y: wy,
|
|
697
|
-
width: isWide ? 720 : nodeW,
|
|
698
|
-
height: isWide ? 500 : nodeH,
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
|
-
}
|
|
750
|
+
await importFiles(files, baseWx, baseWy);
|
|
702
751
|
},
|
|
703
752
|
[containerRef, createWebpageNodes],
|
|
704
753
|
);
|
|
@@ -731,12 +780,18 @@ export function CanvasViewport({
|
|
|
731
780
|
};
|
|
732
781
|
}, [containerRef, createWebpageNodes]);
|
|
733
782
|
|
|
734
|
-
// Only render world-space nodes (dockPosition === null); docked nodes are in the HUD layer.
|
|
735
783
|
// Do NOT sort by zIndex here — CSS z-index handles visual stacking. Sorting would
|
|
736
784
|
// reorder DOM children when bringToFront() changes zIndex, causing browsers to
|
|
737
785
|
// detach/reattach iframe elements (which forces them to reload/reconnect).
|
|
738
786
|
// Group nodes render first (behind) so they serve as visual containers.
|
|
739
|
-
const worldNodes = getRenderableWorldNodes(
|
|
787
|
+
const worldNodes = getRenderableWorldNodes(
|
|
788
|
+
nodes.value.values(),
|
|
789
|
+
expandedNodeId.value,
|
|
790
|
+
new Set(hiddenByCollapsedGroup.value.keys()),
|
|
791
|
+
);
|
|
792
|
+
const dropTarget = dragDropTarget.value;
|
|
793
|
+
const dropTargetGroup = dropTarget ? nodes.value.get(dropTarget.groupId) : undefined;
|
|
794
|
+
const draggedNode = dropTarget ? nodes.value.get(dropTarget.nodeId) : undefined;
|
|
740
795
|
|
|
741
796
|
// Compute lasso overlay rect in screen space
|
|
742
797
|
let lassoStyle: Record<string, string> | null = null;
|
|
@@ -757,11 +812,15 @@ export function CanvasViewport({
|
|
|
757
812
|
|
|
758
813
|
return (
|
|
759
814
|
<div
|
|
760
|
-
class=
|
|
815
|
+
class={`canvas-viewport${isPanModeActive() ? ' pan-mode' : ''}`}
|
|
761
816
|
ref={containerRef}
|
|
762
817
|
tabIndex={0}
|
|
763
818
|
onPointerDown={handlePointerDown}
|
|
764
819
|
onPointerMove={handlePointerMove}
|
|
820
|
+
onPointerLeave={() => {
|
|
821
|
+
lastPointerScreen.current = null;
|
|
822
|
+
reportHumanCursor(null);
|
|
823
|
+
}}
|
|
765
824
|
onPointerUp={handlePointerUp}
|
|
766
825
|
onContextMenu={handleContextMenu}
|
|
767
826
|
onDblClick={handleDblClick}
|
|
@@ -779,27 +838,64 @@ export function CanvasViewport({
|
|
|
779
838
|
? 'cell'
|
|
780
839
|
: annotationTool === 'text'
|
|
781
840
|
? 'text'
|
|
782
|
-
: annotationMode || draggingEdge.value || isLassoing.current
|
|
841
|
+
: annotationMode || draggingEdge.value || isLassoing.current || canvasTool.value === 'connect'
|
|
783
842
|
? 'crosshair'
|
|
784
|
-
:
|
|
843
|
+
: isPanModeActive()
|
|
844
|
+
? 'grab'
|
|
845
|
+
: 'default',
|
|
785
846
|
}}
|
|
786
847
|
>
|
|
787
848
|
{/* D4: CSS matrix(a,b,c,d,tx,ty) — scale uniformly (a=d=scale, b=c=0)
|
|
788
849
|
then translate (tx=v.x, ty=v.y). transformOrigin: '0 0' ensures
|
|
789
850
|
the scale pivot is the top-left corner of the world layer. */}
|
|
790
851
|
<div
|
|
852
|
+
ref={worldRef}
|
|
853
|
+
class="canvas-world"
|
|
791
854
|
style={{
|
|
792
855
|
transform: `matrix(${v.scale}, 0, 0, ${v.scale}, ${v.x}, ${v.y})`,
|
|
793
856
|
transformOrigin: '0 0',
|
|
794
|
-
willChange: 'transform',
|
|
795
857
|
position: 'absolute',
|
|
796
858
|
top: 0,
|
|
797
859
|
left: 0,
|
|
798
860
|
}}
|
|
799
861
|
>
|
|
800
862
|
<FocusFieldLayer />
|
|
863
|
+
<ScopeFenceLayer />
|
|
801
864
|
<IntentLayer />
|
|
802
|
-
<
|
|
865
|
+
<AgentPresenceLayer />
|
|
866
|
+
<HumanPresenceLayer />
|
|
867
|
+
<EdgeLayer nodes={nodes} edges={edges} onEdgeContextMenu={onEdgeContextMenu} />
|
|
868
|
+
{draggingEdge.value && (
|
|
869
|
+
<div
|
|
870
|
+
class="edge-hint-pill"
|
|
871
|
+
data-testid="edge-hint"
|
|
872
|
+
style={{ left: `${draggingEdge.value.cursorX + 14}px`, top: `${draggingEdge.value.cursorY + 14}px` }}
|
|
873
|
+
>
|
|
874
|
+
<span class="edge-hint-main">
|
|
875
|
+
{draggingEdge.value.targetId ? 'release to connect' : 'drag onto a node'}
|
|
876
|
+
</span>
|
|
877
|
+
<span class="edge-hint-keys">
|
|
878
|
+
esc cancels · L labels{draggingEdge.value.withLabel ? ' · label on' : ''}
|
|
879
|
+
</span>
|
|
880
|
+
</div>
|
|
881
|
+
)}
|
|
882
|
+
{dropTarget && dropTargetGroup && draggedNode && (
|
|
883
|
+
<div
|
|
884
|
+
class="drop-pill"
|
|
885
|
+
data-testid="drop-pill"
|
|
886
|
+
style={{
|
|
887
|
+
left: `${draggedNode.position.x}px`,
|
|
888
|
+
top: `${draggedNode.position.y + draggedNode.size.height + 10}px`,
|
|
889
|
+
}}
|
|
890
|
+
>
|
|
891
|
+
<span class="drop-pill-text">
|
|
892
|
+
{dropTarget.mode === 'add'
|
|
893
|
+
? `release to add to ${String(dropTargetGroup.data.title ?? 'group')}`
|
|
894
|
+
: `release to remove from ${String(dropTargetGroup.data.title ?? 'group')}`}
|
|
895
|
+
</span>
|
|
896
|
+
<span class="drop-pill-hint">{dropTarget.mode === 'add' ? 'esc keeps it out' : 'esc keeps it in'}</span>
|
|
897
|
+
</div>
|
|
898
|
+
)}
|
|
803
899
|
<AnnotationLayer annotations={Array.from(annotations.value.values())} />
|
|
804
900
|
{draftAnnotation && draftAnnotation.points.length >= 2 && <AnnotationLayer annotations={[draftAnnotation]} />}
|
|
805
901
|
{worldNodes.map((node) => (
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useState } from 'preact/hooks';
|
|
2
|
+
import { IconSteer } from '../icons';
|
|
3
|
+
import { contextPinnedNodeIds, nodes, toggleContextPin } from '../state/canvas-store';
|
|
4
|
+
import { steerableAgents } from '../state/presence-store';
|
|
5
|
+
import { sendSteering } from '../state/session-store';
|
|
6
|
+
import { modChord } from '../utils/platform';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Command bar (rail-chrome-v2 phase 5): the human's steering surface while a
|
|
10
|
+
* session is attached — the design's floating composer, centered at the
|
|
11
|
+
* bottom of the canvas region. Above it, the pinned context as gold ✦ chips
|
|
12
|
+
* (× unpins — the same pin the node's own control toggles); the composer row
|
|
13
|
+
* posts an AX steering message to the session. The context-budget meter lives
|
|
14
|
+
* in the top bar (`ContextBudget`), where the mockup puts it. Replaces the
|
|
15
|
+
* quiet board's pin bar only while attached.
|
|
16
|
+
*/
|
|
17
|
+
export function CommandBar() {
|
|
18
|
+
const pinnedIds = [...contextPinnedNodeIds.value];
|
|
19
|
+
const nodeMap = nodes.value;
|
|
20
|
+
const [draft, setDraft] = useState('');
|
|
21
|
+
const [sending, setSending] = useState(false);
|
|
22
|
+
// Addressed steering: the picker lists the CONNECTED agents (live presences,
|
|
23
|
+
// sessions first) and shows only when there is a real choice. A picked agent
|
|
24
|
+
// that detaches or expires falls back to "All agents" — presence is the
|
|
25
|
+
// source of truth for who can still claim a delivery.
|
|
26
|
+
const agents = steerableAgents.value;
|
|
27
|
+
const [target, setTarget] = useState<string | null>(null);
|
|
28
|
+
// The picker only earns its place when the roster offers a real choice or
|
|
29
|
+
// real information: two or more rows. A LONE steerable agent needs no
|
|
30
|
+
// dropdown — steers auto-address it (the placeholder names it), which also
|
|
31
|
+
// keeps stale broadcasts from leaking to agents that join later.
|
|
32
|
+
const showPicker = agents.length >= 2;
|
|
33
|
+
const soloTarget = !showPicker && agents.length === 1 && agents[0]!.steerable ? agents[0]! : null;
|
|
34
|
+
// A picked agent that loses its inbox (or leaves) falls back to All —
|
|
35
|
+
// steers must never keep targeting a roster row that cannot receive.
|
|
36
|
+
const effective = showPicker
|
|
37
|
+
? target
|
|
38
|
+
? (agents.find((agent) => agent.value === target && agent.steerable) ?? null)
|
|
39
|
+
: null
|
|
40
|
+
: soloTarget;
|
|
41
|
+
const effectiveTarget = effective?.value ?? null;
|
|
42
|
+
|
|
43
|
+
const submit = async () => {
|
|
44
|
+
const message = draft.trim();
|
|
45
|
+
if (!message || sending) return;
|
|
46
|
+
setSending(true);
|
|
47
|
+
const ok = await sendSteering(message, effectiveTarget);
|
|
48
|
+
setSending(false);
|
|
49
|
+
if (ok) setDraft('');
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div class="command-bar" data-testid="command-bar">
|
|
54
|
+
{pinnedIds.length > 0 && (
|
|
55
|
+
<div class="command-bar-chips" aria-label="Pinned context">
|
|
56
|
+
{pinnedIds.map((id) => {
|
|
57
|
+
const node = nodeMap.get(id);
|
|
58
|
+
const title =
|
|
59
|
+
typeof node?.data.title === 'string' && node.data.title.trim() ? node.data.title : (node?.type ?? id);
|
|
60
|
+
return (
|
|
61
|
+
<span key={id} class="command-bar-chip" data-node-id={id}>
|
|
62
|
+
<span class="command-bar-chip-glyph" aria-hidden="true">
|
|
63
|
+
✦
|
|
64
|
+
</span>
|
|
65
|
+
<span class="command-bar-chip-label" title={title}>
|
|
66
|
+
{title}
|
|
67
|
+
</span>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
class="command-bar-chip-unpin"
|
|
71
|
+
aria-label={`Unpin ${title}`}
|
|
72
|
+
title="Remove from context"
|
|
73
|
+
onClick={() => toggleContextPin(id)}
|
|
74
|
+
>
|
|
75
|
+
×
|
|
76
|
+
</button>
|
|
77
|
+
</span>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
<span class="command-bar-chips-note">in agent context</span>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
<div class="command-bar-composer">
|
|
84
|
+
<IconSteer size={15} class="command-bar-icon" />
|
|
85
|
+
{showPicker && (
|
|
86
|
+
<select
|
|
87
|
+
class="command-bar-target"
|
|
88
|
+
aria-label="Steer which agent"
|
|
89
|
+
title="Connected agents — steering reaches all of them unless you address one"
|
|
90
|
+
value={effectiveTarget ?? ''}
|
|
91
|
+
onChange={(e) => setTarget((e.target as HTMLSelectElement).value || null)}
|
|
92
|
+
>
|
|
93
|
+
<option value="">All agents</option>
|
|
94
|
+
{agents.map((agent) => {
|
|
95
|
+
// Pump health at the point of choice: unclaimed steers already
|
|
96
|
+
// sitting in this agent's queue (a growing number means its loop
|
|
97
|
+
// is not polling), and proof-of-polling when its consumer key
|
|
98
|
+
// claimed recently — attached alone doesn't mean anyone reads
|
|
99
|
+
// the inbox.
|
|
100
|
+
const queued = agent.pendingSteers && agent.pendingSteers > 0 ? ` · ${agent.pendingSteers} queued` : '';
|
|
101
|
+
const polling = agent.polling ? ' · polling' : '';
|
|
102
|
+
return (
|
|
103
|
+
<option key={agent.value} value={agent.value} disabled={!agent.steerable}>
|
|
104
|
+
{agent.attached
|
|
105
|
+
? `${agent.label}${polling}${queued}`
|
|
106
|
+
: agent.steerable
|
|
107
|
+
? `${agent.label} · writer${polling}${queued}`
|
|
108
|
+
: `${agent.label} · no inbox`}
|
|
109
|
+
</option>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</select>
|
|
113
|
+
)}
|
|
114
|
+
<input
|
|
115
|
+
class="command-bar-input"
|
|
116
|
+
type="text"
|
|
117
|
+
value={draft}
|
|
118
|
+
placeholder={`Steer ${effective?.label ?? 'the agent'}, or ${modChord('K')} to search the board…`}
|
|
119
|
+
aria-label="Steer the agent"
|
|
120
|
+
disabled={sending}
|
|
121
|
+
onInput={(e) => setDraft((e.target as HTMLInputElement).value)}
|
|
122
|
+
onKeyDown={(e) => {
|
|
123
|
+
if (e.key === 'Enter') {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
void submit();
|
|
126
|
+
}
|
|
127
|
+
}}
|
|
128
|
+
/>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
class="command-bar-send"
|
|
132
|
+
disabled={sending || !draft.trim()}
|
|
133
|
+
onClick={() => void submit()}
|
|
134
|
+
>
|
|
135
|
+
{sending ? 'Sending…' : 'Send'}
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|