pmx-canvas 0.6.5 → 0.7.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 +67 -3
- package/CHANGELOG.md +54 -0
- package/Readme.md +29 -5
- package/dist/canvas/global.css +440 -24
- package/dist/canvas/index.js +90 -75
- package/dist/types/cli/agent.d.ts +3 -0
- package/dist/types/cli/commands/backup.d.ts +1 -0
- package/dist/types/cli/commands/boards.d.ts +1 -0
- package/dist/types/cli/commands/export.d.ts +1 -0
- package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
- package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
- package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -0
- package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
- package/dist/types/client/nodes/BoardNode.d.ts +5 -0
- package/dist/types/client/state/boards-store.d.ts +79 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -0
- package/dist/types/client/state/session-store.d.ts +7 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/json-render/charts/definitions.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +6 -0
- package/dist/types/mcp/canvas-access.d.ts +10 -3
- package/dist/types/mcp/context-uri-template.d.ts +6 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/attribution.d.ts +11 -0
- package/dist/types/server/ax-context.d.ts +3 -1
- package/dist/types/server/ax-interaction.d.ts +2 -2
- package/dist/types/server/ax-state.d.ts +13 -3
- package/dist/types/server/backup.d.ts +36 -0
- package/dist/types/server/board-export.d.ts +45 -0
- package/dist/types/server/canvas-db.d.ts +63 -9
- package/dist/types/server/canvas-operations.d.ts +23 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +140 -3
- package/dist/types/server/context-brief.d.ts +111 -0
- package/dist/types/server/context-reads.d.ts +55 -0
- package/dist/types/server/document-import.d.ts +66 -0
- package/dist/types/server/index.d.ts +103 -4
- package/dist/types/server/mutation-history.d.ts +1 -0
- package/dist/types/server/operations/invoker.d.ts +2 -0
- package/dist/types/server/operations/mcp.d.ts +7 -1
- package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
- package/dist/types/server/operations/ops/backup.d.ts +2 -0
- package/dist/types/server/operations/ops/boards.d.ts +10 -0
- package/dist/types/server/operations/ops/export.d.ts +2 -0
- package/dist/types/server/operations/ops/imports.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/trace-manager.d.ts +7 -0
- package/dist/types/server/webpage-node.d.ts +22 -1
- package/dist/types/server/workbench-auth.d.ts +3 -0
- package/dist/types/shared/boards.d.ts +6 -0
- package/docs/RELEASE.md +5 -0
- package/docs/cli.md +68 -0
- package/docs/http-api.md +198 -2
- package/docs/mcp.md +71 -6
- package/docs/node-types.md +26 -0
- package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
- package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
- package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
- package/docs/plans/plan-013-static-board-export.md +47 -0
- package/docs/plans/plan-014-connected-board-memory.md +140 -0
- package/docs/product-vision-2026-09.md +118 -18
- package/docs/sdk.md +50 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +53 -4
- package/skills/pmx-canvas/references/full-reference.md +43 -7
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
- package/skills/pmx-canvas-testing/SKILL.md +4 -0
- package/src/cli/agent.ts +4 -0
- package/src/cli/commands/ax.ts +13 -0
- package/src/cli/commands/backup.ts +66 -0
- package/src/cli/commands/boards.ts +71 -0
- package/src/cli/commands/export.ts +30 -0
- package/src/cli/commands/nodes.ts +4 -1
- package/src/cli/commands/pins.ts +6 -2
- package/src/cli/commands/pump.ts +3 -0
- package/src/cli/commands/query.ts +33 -7
- package/src/cli/commands/view.ts +7 -5
- package/src/cli/index.ts +19 -9
- package/src/client/App.tsx +21 -3
- package/src/client/canvas/BoardCopyDialog.tsx +153 -0
- package/src/client/canvas/BoardFolderDialog.tsx +120 -0
- package/src/client/canvas/BoardLinkDialog.tsx +93 -0
- package/src/client/canvas/BoardSwitcher.tsx +149 -0
- package/src/client/canvas/CanvasViewport.tsx +3 -0
- package/src/client/canvas/CommandPalette.tsx +107 -4
- package/src/client/canvas/ContextMenu.tsx +4 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
- package/src/client/canvas/ExportDialog.tsx +197 -0
- package/src/client/canvas/HomeView.tsx +277 -0
- package/src/client/canvas/SessionPanel.tsx +28 -1
- package/src/client/canvas/ToolRail.tsx +38 -3
- package/src/client/canvas/TopBar.tsx +34 -18
- package/src/client/canvas/import-files.ts +19 -1
- package/src/client/canvas/kind-colors.ts +1 -0
- package/src/client/icons.tsx +2 -0
- package/src/client/nodes/AttachmentNode.tsx +148 -0
- package/src/client/nodes/BoardNode.tsx +35 -0
- package/src/client/nodes/FileNode.tsx +6 -0
- package/src/client/nodes/MarkdownNode.tsx +47 -19
- package/src/client/nodes/ax-node-actions.ts +2 -5
- package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
- package/src/client/state/boards-store.ts +167 -0
- package/src/client/state/canvas-store.ts +22 -0
- package/src/client/state/intent-bridge.ts +30 -2
- package/src/client/state/node-factory.ts +1 -0
- package/src/client/state/session-store.ts +5 -0
- package/src/client/state/sse-bridge.ts +49 -5
- package/src/client/theme/global.css +440 -24
- package/src/client/types.ts +2 -0
- package/src/json-render/server.ts +16 -9
- package/src/mcp/canvas-access.ts +59 -22
- package/src/mcp/context-uri-template.ts +28 -0
- package/src/mcp/server.ts +122 -15
- package/src/server/agent-context.ts +116 -29
- package/src/server/attribution.ts +29 -0
- package/src/server/ax-context.ts +4 -2
- package/src/server/ax-interaction.ts +1 -0
- package/src/server/ax-state-manager.ts +72 -3
- package/src/server/ax-state.ts +41 -0
- package/src/server/backup.ts +178 -0
- package/src/server/board-export.ts +686 -0
- package/src/server/canvas-db.ts +599 -107
- package/src/server/canvas-operations.ts +68 -9
- package/src/server/canvas-provenance.ts +1 -0
- package/src/server/canvas-schema.ts +12 -0
- package/src/server/canvas-state.ts +774 -45
- package/src/server/context-brief.ts +372 -0
- package/src/server/context-reads.ts +238 -0
- package/src/server/demo-state.json +659 -2
- package/src/server/document-import.ts +223 -0
- package/src/server/index.ts +231 -5
- package/src/server/mutation-history.ts +10 -13
- package/src/server/operations/composites.ts +26 -4
- package/src/server/operations/http.ts +9 -3
- package/src/server/operations/index.ts +8 -0
- package/src/server/operations/invoker.ts +14 -3
- package/src/server/operations/mcp.ts +50 -11
- package/src/server/operations/ops/app.ts +5 -0
- package/src/server/operations/ops/ax-read.ts +10 -6
- package/src/server/operations/ops/ax-timeline.ts +75 -0
- package/src/server/operations/ops/backup.ts +134 -0
- package/src/server/operations/ops/batch.ts +8 -1
- package/src/server/operations/ops/boards.ts +351 -0
- package/src/server/operations/ops/export.ts +74 -0
- package/src/server/operations/ops/ext-app.ts +4 -0
- package/src/server/operations/ops/imports.ts +220 -0
- package/src/server/operations/ops/nodes.ts +57 -5
- package/src/server/operations/ops/query.ts +206 -9
- package/src/server/operations/registry.ts +123 -31
- package/src/server/operations/types.ts +1 -1
- package/src/server/server.ts +146 -2
- package/src/server/spatial-analysis.ts +9 -17
- package/src/server/trace-manager.ts +19 -0
- package/src/server/web-artifacts.ts +2 -1
- package/src/server/webpage-node.ts +173 -13
- package/src/server/workbench-auth.ts +11 -0
- package/src/shared/boards.ts +18 -0
- package/src/shared/node-sizes.ts +1 -0
package/src/server/server.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* - POST /api/canvas/graph -> create a native graph node
|
|
24
24
|
* - GET /api/canvas/json-render/view?nodeId=... -> local json-render viewer
|
|
25
25
|
* - GET /api/canvas/file-bytes?nodeId=... -> raw bytes for a file node
|
|
26
|
+
* - GET /api/canvas/exports/<file> -> a written static board export (sandboxed)
|
|
26
27
|
* - POST /api/canvas/web-artifact -> build bundled HTML artifact + optional canvas node
|
|
27
28
|
* - GET /api/workbench/events -> SSE event stream
|
|
28
29
|
* - GET /api/workbench/poll -> proxy-safe polling transport (same events, JSON)
|
|
@@ -38,9 +39,11 @@
|
|
|
38
39
|
|
|
39
40
|
import { spawnSync } from 'node:child_process';
|
|
40
41
|
import { randomUUID } from 'node:crypto';
|
|
42
|
+
import { isWorkbenchToken, workbenchToken } from './workbench-auth.js';
|
|
43
|
+
import { withCurrentActor } from './attribution.js';
|
|
41
44
|
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
42
45
|
import { readFile } from 'node:fs/promises';
|
|
43
|
-
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
46
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
44
47
|
import * as marked from 'marked';
|
|
45
48
|
import { type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
|
|
46
49
|
import {
|
|
@@ -67,11 +70,13 @@ import { canvasThemeScheme, isCanvasTheme } from '../shared/themes.js';
|
|
|
67
70
|
import { canOpenNodeAsSurface } from '../shared/surface.js';
|
|
68
71
|
import { validateLocalImageFile } from './image-source.js';
|
|
69
72
|
import {
|
|
73
|
+
addCanvasNode,
|
|
70
74
|
cancelCodeGraphRecompute,
|
|
71
75
|
primeCanvasRuntimeBackends,
|
|
72
76
|
setCanvasLayoutUpdateEmitter,
|
|
73
77
|
syncCanvasRuntimeBackends,
|
|
74
78
|
} from './canvas-operations.js';
|
|
79
|
+
import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_NAME_LENGTH } from './document-import.js';
|
|
75
80
|
import { dispatchOperationRoute, setOperationEventEmitter } from './operations/index.js';
|
|
76
81
|
import { intentRegistry } from './intent-registry.js';
|
|
77
82
|
import { agentPresence } from './agent-presence.js';
|
|
@@ -79,6 +84,8 @@ import { HUMAN_STARTED_SESSION_LABEL } from '../shared/agent-presence.js';
|
|
|
79
84
|
import { diffLayouts } from './mutation-history.js';
|
|
80
85
|
import { humanPresence } from './human-presence.js';
|
|
81
86
|
import { startGateTtlSweeper, stopGateTtlSweeper } from './ax-gate-ttl.js';
|
|
87
|
+
import { startBackupScheduler, stopBackupScheduler } from './backup.js';
|
|
88
|
+
import { exportsFolder } from './board-export.js';
|
|
82
89
|
import { setWebviewRunner } from './operations/webview-runner.js';
|
|
83
90
|
import { closeNodeAppSession, nodeAppSessionId } from './operations/ops/nodes.js';
|
|
84
91
|
import { traceManager } from './trace-manager.js';
|
|
@@ -971,6 +978,39 @@ function normalizeMarkdownExternalUrls(markdown: string): string {
|
|
|
971
978
|
const CANVAS_ASSET_VERSION = Date.now().toString(36);
|
|
972
979
|
const MAX_FRAME_DOCUMENTS = 128;
|
|
973
980
|
const MAX_FRAME_DOCUMENT_BYTES = 5 * 1024 * 1024;
|
|
981
|
+
|
|
982
|
+
async function readBoundedBody(req: Request, limit: number): Promise<Uint8Array | null> {
|
|
983
|
+
if (!req.body) return new Uint8Array();
|
|
984
|
+
const reader = req.body.getReader();
|
|
985
|
+
const chunks: Uint8Array[] = [];
|
|
986
|
+
let size = 0;
|
|
987
|
+
try {
|
|
988
|
+
while (true) {
|
|
989
|
+
const { done, value } = await reader.read();
|
|
990
|
+
if (done) break;
|
|
991
|
+
size += value.byteLength;
|
|
992
|
+
if (size > limit) {
|
|
993
|
+
await reader.cancel();
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
chunks.push(value);
|
|
997
|
+
}
|
|
998
|
+
} finally {
|
|
999
|
+
reader.releaseLock();
|
|
1000
|
+
}
|
|
1001
|
+
const bytes = new Uint8Array(size);
|
|
1002
|
+
let offset = 0;
|
|
1003
|
+
for (const chunk of chunks) {
|
|
1004
|
+
bytes.set(chunk, offset);
|
|
1005
|
+
offset += chunk.byteLength;
|
|
1006
|
+
}
|
|
1007
|
+
return bytes;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function attachmentDisposition(name: string): string {
|
|
1011
|
+
const fallback = name.replace(/[^\x20-\x7e]|["\\]/g, '_');
|
|
1012
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
1013
|
+
}
|
|
974
1014
|
const DEFAULT_FRAME_DOCUMENT_SANDBOX = 'allow-scripts';
|
|
975
1015
|
const SAFE_FRAME_DOCUMENT_SANDBOX_TOKENS = new Set([
|
|
976
1016
|
'allow-downloads',
|
|
@@ -1112,6 +1152,7 @@ function canvasSpaHtml(): string {
|
|
|
1112
1152
|
}
|
|
1113
1153
|
<script>window.__PMX_BOOT_SERVER_VERSION = ${JSON.stringify(serverPackageVersion())};</script>
|
|
1114
1154
|
<script>window.__PMX_BOOT_BUNDLE_STAMP = ${JSON.stringify(canvasBundleStamp())};</script>
|
|
1155
|
+
<script>window.__PMX_WORKBENCH_TOKEN = ${JSON.stringify(workbenchToken)};</script>
|
|
1115
1156
|
<script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
|
|
1116
1157
|
</body>
|
|
1117
1158
|
</html>`;
|
|
@@ -1558,6 +1599,39 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
|
|
|
1558
1599
|
});
|
|
1559
1600
|
}
|
|
1560
1601
|
|
|
1602
|
+
// ── Serve a written board export (plan 013) ─────────────────
|
|
1603
|
+
// Only names the exporter writes, only from the exports folder. The page runs
|
|
1604
|
+
// its own viewer script, so it is served with an opaque-origin sandbox: it can
|
|
1605
|
+
// never reach this server's API as the workbench.
|
|
1606
|
+
const EXPORT_FILE_NAME = /^[a-z0-9-]+-\d{8}T\d{9}Z(?:-\d+)?\.html$/;
|
|
1607
|
+
|
|
1608
|
+
function handleBoardExportFile(url: URL): Response {
|
|
1609
|
+
const name = decodeURIComponent(url.pathname.slice('/api/canvas/exports/'.length));
|
|
1610
|
+
const folder = exportsFolder();
|
|
1611
|
+
if (!folder || !EXPORT_FILE_NAME.test(name)) return responseText('Export not found', 404);
|
|
1612
|
+
let path: string;
|
|
1613
|
+
try {
|
|
1614
|
+
const realFolder = realpathSync(folder);
|
|
1615
|
+
path = realpathSync(join(folder, name));
|
|
1616
|
+
const rel = relative(realFolder, path);
|
|
1617
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) || !statSync(path).isFile()) {
|
|
1618
|
+
return responseText('Export not found', 404);
|
|
1619
|
+
}
|
|
1620
|
+
} catch {
|
|
1621
|
+
return responseText('Export not found', 404);
|
|
1622
|
+
}
|
|
1623
|
+
const download = url.searchParams.get('download') === '1';
|
|
1624
|
+
return new Response(Bun.file(path), {
|
|
1625
|
+
headers: {
|
|
1626
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1627
|
+
'Content-Security-Policy': 'sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox',
|
|
1628
|
+
'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${name}"`,
|
|
1629
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1630
|
+
'Cache-Control': 'no-store',
|
|
1631
|
+
},
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1561
1635
|
// ── Serve raw bytes for file nodes (PDF viewer, downloads) ───
|
|
1562
1636
|
/**
|
|
1563
1637
|
* Ceiling for the raw byte route. The route streams via Bun.file, so this is a
|
|
@@ -3220,6 +3294,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3220
3294
|
const loaded = canvasState.loadFromDisk({ clearExisting: true });
|
|
3221
3295
|
// Unattended-approval policy: hold pending gates whose TTL elapsed.
|
|
3222
3296
|
startGateTtlSweeper((event, payload) => emitPrimaryWorkbenchEvent(event, payload));
|
|
3297
|
+
startBackupScheduler();
|
|
3223
3298
|
setCanvasLayoutUpdateEmitter(() => {
|
|
3224
3299
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
3225
3300
|
});
|
|
@@ -3373,6 +3448,69 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3373
3448
|
// theme/update/viewport/annotation/refresh (plan-009 C1 slices 1-3). A
|
|
3374
3449
|
// null return falls through to the remaining hand-written routes
|
|
3375
3450
|
// (SSE, binary, HTML/static serving, and the server-coupled handlers).
|
|
3451
|
+
if (url.pathname === '/api/canvas/attachments' && req.method === 'POST') {
|
|
3452
|
+
const boardId = url.searchParams.get('boardId')?.trim() ?? '';
|
|
3453
|
+
const name = url.searchParams.get('name')?.trim() ?? '';
|
|
3454
|
+
const mime = url.searchParams.get('mime')?.trim() || 'application/octet-stream';
|
|
3455
|
+
const x = Number(url.searchParams.get('x') ?? 40);
|
|
3456
|
+
const y = Number(url.searchParams.get('y') ?? 40);
|
|
3457
|
+
if (!boardId || !name || !Number.isFinite(x) || !Number.isFinite(y))
|
|
3458
|
+
return responseJson({ ok: false, error: 'boardId, name, and finite x/y are required.' }, 400);
|
|
3459
|
+
if (name.length > MAX_ATTACHMENT_NAME_LENGTH)
|
|
3460
|
+
return responseJson(
|
|
3461
|
+
{ ok: false, error: `Attachment name exceeds ${MAX_ATTACHMENT_NAME_LENGTH} characters.` },
|
|
3462
|
+
400,
|
|
3463
|
+
);
|
|
3464
|
+
if (Number(req.headers.get('content-length') ?? 0) > MAX_ATTACHMENT_BYTES)
|
|
3465
|
+
return responseJson({ ok: false, error: 'Attachment exceeds the 20 MiB limit.' }, 413);
|
|
3466
|
+
const bytes = await readBoundedBody(req, MAX_ATTACHMENT_BYTES);
|
|
3467
|
+
if (!bytes) return responseJson({ ok: false, error: 'Attachment exceeds the 20 MiB limit.' }, 413);
|
|
3468
|
+
if (bytes.byteLength === 0) return responseJson({ ok: false, error: 'Attachment is empty.' }, 400);
|
|
3469
|
+
if (canvasState.activeBoardId !== boardId)
|
|
3470
|
+
return responseJson({ ok: false, error: 'The captured target board is no longer active.' }, 409);
|
|
3471
|
+
if (!isWorkbenchToken(req.headers.get('x-pmx-workbench-token')))
|
|
3472
|
+
return responseJson({ ok: false, error: 'A trusted human upload is required.' }, 403);
|
|
3473
|
+
const attachment = withCurrentActor({ actor: 'human', source: 'browser' }, () =>
|
|
3474
|
+
canvasState.storeAttachment({ boardId, name, mime, bytes }),
|
|
3475
|
+
);
|
|
3476
|
+
if (!attachment) return responseJson({ ok: false, error: 'Board not found.' }, 404);
|
|
3477
|
+
const { node } = withCurrentActor({ actor: 'human', source: 'browser' }, () =>
|
|
3478
|
+
addCanvasNode({
|
|
3479
|
+
type: 'file',
|
|
3480
|
+
title: name,
|
|
3481
|
+
data: { attachmentId: attachment.id, mime, size: attachment.size },
|
|
3482
|
+
x,
|
|
3483
|
+
y,
|
|
3484
|
+
defaultWidth: 440,
|
|
3485
|
+
defaultHeight: 360,
|
|
3486
|
+
fileMode: 'auto',
|
|
3487
|
+
}),
|
|
3488
|
+
);
|
|
3489
|
+
const created = canvasState.createDocumentImport(attachment.id, { x, y: y + 400 });
|
|
3490
|
+
const documentImport = created;
|
|
3491
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
3492
|
+
return responseJson({ ok: true, attachment, nodeId: node.id, import: documentImport }, 201);
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
const attachmentBytesMatch = url.pathname.match(/^\/api\/canvas\/attachments\/([^/]+)\/bytes$/);
|
|
3496
|
+
if (attachmentBytesMatch && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
3497
|
+
const id = decodeURIComponent(attachmentBytesMatch[1]!);
|
|
3498
|
+
const attachment = canvasState.getAttachment(id);
|
|
3499
|
+
if (!attachment || !canvasState.listBoards().some((board) => board.id === attachment.boardId))
|
|
3500
|
+
return responseJson({ ok: false, error: 'Attachment not found.' }, 404);
|
|
3501
|
+
const bytes = canvasState.readAttachmentBytes(id);
|
|
3502
|
+
if (!bytes) return responseJson({ ok: false, error: 'Attachment bytes are missing.' }, 404);
|
|
3503
|
+
return new Response(req.method === 'HEAD' ? null : new Blob([Uint8Array.from(bytes)]), {
|
|
3504
|
+
headers: {
|
|
3505
|
+
'Content-Type': 'application/octet-stream',
|
|
3506
|
+
'Content-Length': String(bytes.byteLength),
|
|
3507
|
+
'Content-Disposition': attachmentDisposition(attachment.name),
|
|
3508
|
+
'X-Content-Type-Options': 'nosniff',
|
|
3509
|
+
'Cache-Control': 'no-store',
|
|
3510
|
+
},
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3376
3514
|
if (url.pathname.startsWith('/api/')) {
|
|
3377
3515
|
const operationResponse = await dispatchOperationRoute(req, url);
|
|
3378
3516
|
if (operationResponse) return operationResponse;
|
|
@@ -3396,6 +3534,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3396
3534
|
|
|
3397
3535
|
// HEAD answers like GET (see the standalone-surface note above): a PDF
|
|
3398
3536
|
// viewer probes this URL for type/length before fetching the bytes.
|
|
3537
|
+
if (url.pathname.startsWith('/api/canvas/exports/') && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
3538
|
+
return handleBoardExportFile(url);
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3399
3541
|
if (url.pathname === '/api/canvas/file-bytes' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
3400
3542
|
return await handleCanvasFileBytes(url);
|
|
3401
3543
|
}
|
|
@@ -3431,11 +3573,13 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3431
3573
|
}
|
|
3432
3574
|
|
|
3433
3575
|
export function stopCanvasServer(): void {
|
|
3576
|
+
// A failed flush must leave the live server and its runtime state available for recovery.
|
|
3577
|
+
canvasState.close();
|
|
3434
3578
|
stopGateTtlSweeper();
|
|
3579
|
+
stopBackupScheduler();
|
|
3435
3580
|
agentPresence.reset();
|
|
3436
3581
|
humanPresence.reset();
|
|
3437
3582
|
intentRegistry.reset();
|
|
3438
|
-
canvasState.close();
|
|
3439
3583
|
closeAllMcpAppSessions();
|
|
3440
3584
|
cancelCodeGraphRecompute();
|
|
3441
3585
|
setCanvasLayoutUpdateEmitter(null);
|
|
@@ -306,14 +306,12 @@ export function searchNodes(
|
|
|
306
306
|
|
|
307
307
|
for (const node of nodes) {
|
|
308
308
|
const title = ((node.data.title as string) ?? '').toLowerCase();
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
''
|
|
316
|
-
).toLowerCase();
|
|
309
|
+
// Search the same meaning delivered to agents, without a brief's truncation.
|
|
310
|
+
const fullContent = summarizeNodeForAgentContext(node, {
|
|
311
|
+
defaultTextLength: Number.POSITIVE_INFINITY,
|
|
312
|
+
webpageTextLength: Number.POSITIVE_INFINITY,
|
|
313
|
+
});
|
|
314
|
+
const content = fullContent.toLowerCase();
|
|
317
315
|
const path = ((node.data.path as string) ?? '').toLowerCase();
|
|
318
316
|
const description = ((node.data.description as string) ?? '').toLowerCase();
|
|
319
317
|
const summary = (
|
|
@@ -337,16 +335,10 @@ export function searchNodes(
|
|
|
337
335
|
|
|
338
336
|
if (score === 0) continue;
|
|
339
337
|
|
|
340
|
-
//
|
|
338
|
+
// Any query term may have matched, not necessarily the first one.
|
|
341
339
|
let snippet = '';
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
(node.data.agentSummary as string) ??
|
|
345
|
-
(node.data.contentSummary as string) ??
|
|
346
|
-
(node.data.description as string) ??
|
|
347
|
-
(node.data.fileContent as string) ??
|
|
348
|
-
'';
|
|
349
|
-
const matchIdx = fullContent.toLowerCase().indexOf(terms[0]);
|
|
340
|
+
const matches = terms.map((term) => content.indexOf(term)).filter((index) => index >= 0);
|
|
341
|
+
const matchIdx = matches.length ? Math.min(...matches) : -1;
|
|
350
342
|
if (matchIdx >= 0) {
|
|
351
343
|
const start = Math.max(0, matchIdx - 40);
|
|
352
344
|
const end = Math.min(fullContent.length, matchIdx + 80);
|
|
@@ -88,6 +88,7 @@ class TraceManager {
|
|
|
88
88
|
mcpToolName?: string | null;
|
|
89
89
|
}): void {
|
|
90
90
|
if (!this._enabled) return;
|
|
91
|
+
this.dropStaleChain();
|
|
91
92
|
|
|
92
93
|
const id = nextTraceNodeId();
|
|
93
94
|
const category = categorize(payload.name, payload.mcpServerName);
|
|
@@ -178,6 +179,7 @@ class TraceManager {
|
|
|
178
179
|
|
|
179
180
|
onSubagentStarted(payload: { agentName: string; agentDisplayName?: string }): void {
|
|
180
181
|
if (!this._enabled) return;
|
|
182
|
+
this.dropStaleChain();
|
|
181
183
|
|
|
182
184
|
const id = nextTraceNodeId();
|
|
183
185
|
const origin = this.getOrigin();
|
|
@@ -264,6 +266,23 @@ class TraceManager {
|
|
|
264
266
|
this.broadcastUpdate();
|
|
265
267
|
}
|
|
266
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The chain's last node is gone (another board was opened, or it was
|
|
271
|
+
* deleted): start a new chain instead of wiring edges to a missing node.
|
|
272
|
+
*/
|
|
273
|
+
private dropStaleChain(): void {
|
|
274
|
+
if (this.lastTraceNodeId && !canvasState.getNode(this.lastTraceNodeId)) this.resetChain();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Forget the trace chain without touching nodes. */
|
|
278
|
+
resetChain(): void {
|
|
279
|
+
this.traceNodeIds = [];
|
|
280
|
+
this.lastTraceNodeId = null;
|
|
281
|
+
this.toolCallToNodeId.clear();
|
|
282
|
+
this.traceOrigin = null;
|
|
283
|
+
this.chainIndex = 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
267
286
|
clearTrace(): void {
|
|
268
287
|
const traceNodeIds = new Set(this.traceNodeIds);
|
|
269
288
|
for (const node of canvasState.getLayout().nodes) {
|
|
@@ -754,6 +754,7 @@ export async function buildWebArtifactOnCanvas(
|
|
|
754
754
|
openInCanvas?: boolean;
|
|
755
755
|
},
|
|
756
756
|
): Promise<WebArtifactCanvasBuildResult> {
|
|
757
|
+
const boardGeneration = canvasState.boardGeneration;
|
|
757
758
|
const startedMs = Date.now();
|
|
758
759
|
const startedAt = new Date(startedMs).toISOString();
|
|
759
760
|
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -765,7 +766,7 @@ export async function buildWebArtifactOnCanvas(
|
|
|
765
766
|
durationMs: completedMs - startedMs,
|
|
766
767
|
timeoutMs,
|
|
767
768
|
};
|
|
768
|
-
if (input.openInCanvas === false) {
|
|
769
|
+
if (input.openInCanvas === false || canvasState.boardGeneration !== boardGeneration) {
|
|
769
770
|
return { ...build, openedInCanvas: false, ...timing };
|
|
770
771
|
}
|
|
771
772
|
const opened = openWebArtifactInCanvas({
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { request as httpRequest } from 'node:http';
|
|
3
|
+
import { request as httpsRequest } from 'node:https';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
|
|
1
7
|
const FETCH_TIMEOUT_MS = 10000;
|
|
2
8
|
const MAX_HTML_LENGTH = 1_000_000;
|
|
3
9
|
const MAX_TEXT_LENGTH = 50_000;
|
|
4
10
|
const EXCERPT_LENGTH = 420;
|
|
11
|
+
const MAX_REDIRECTS = 5;
|
|
5
12
|
|
|
6
13
|
export const WEBPAGE_NODE_DEFAULT_SIZE = {
|
|
7
14
|
width: 520,
|
|
@@ -236,24 +243,177 @@ export function summarizeWebpageContent(data: Record<string, unknown>, maxLength
|
|
|
236
243
|
return parts.join('\n').trim();
|
|
237
244
|
}
|
|
238
245
|
|
|
239
|
-
export
|
|
246
|
+
export type AddressScope = 'public' | 'private' | 'link-local';
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Where an IP literal points. `private` covers loopback, RFC 1918, CGNAT and
|
|
250
|
+
* IPv6 ULA; `link-local` is kept apart because it holds cloud metadata
|
|
251
|
+
* (169.254.169.254) and is refused on every hop.
|
|
252
|
+
*/
|
|
253
|
+
export function classifyAddress(address: string): AddressScope {
|
|
254
|
+
let ip = address.toLowerCase().replace(/^\[|\]$/g, '');
|
|
255
|
+
// URL canonicalization also covers expanded IPv6 and dotted mapped IPv4.
|
|
256
|
+
// Checking only ::ffff:a.b.c.d misses the hex form used by URL.hostname.
|
|
257
|
+
if (isIP(ip) === 6) ip = new URL(`http://[${ip}]/`).hostname.slice(1, -1);
|
|
258
|
+
const mapped = ip.match(/^::ffff:([\da-f]+):([\da-f]+)$/);
|
|
259
|
+
if (mapped) {
|
|
260
|
+
const high = Number.parseInt(mapped[1], 16);
|
|
261
|
+
const low = Number.parseInt(mapped[2], 16);
|
|
262
|
+
return classifyAddress(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
|
|
263
|
+
}
|
|
264
|
+
if (isIP(ip) === 4) {
|
|
265
|
+
const [a, b] = ip.split('.').map(Number);
|
|
266
|
+
if (a === 169 && b === 254) return 'link-local';
|
|
267
|
+
if (a === 127 || a === 10 || a === 0) return 'private';
|
|
268
|
+
if (a === 172 && b >= 16 && b <= 31) return 'private';
|
|
269
|
+
if (a === 192 && b === 168) return 'private';
|
|
270
|
+
if (a === 100 && b >= 64 && b <= 127) return 'private';
|
|
271
|
+
return 'public';
|
|
272
|
+
}
|
|
273
|
+
if (/^fe[89ab]/.test(ip)) return 'link-local';
|
|
274
|
+
if (ip === '::1' || ip === '::' || /^f[cd]/.test(ip)) return 'private';
|
|
275
|
+
return 'public';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface ResolvedHop {
|
|
279
|
+
addresses: string[];
|
|
280
|
+
scope: AddressScope;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
type ResolveHost = (hostname: string) => Promise<Array<{ address: string; family: number }>>;
|
|
284
|
+
|
|
285
|
+
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
const abort = () => reject(new DOMException('The operation was aborted.', 'AbortError'));
|
|
288
|
+
if (signal.aborted) return abort();
|
|
289
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
290
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Resolves a hop once, retaining the narrowest scope any returned address reaches. */
|
|
295
|
+
async function resolveHop(url: URL, resolveHost: ResolveHost): Promise<ResolvedHop> {
|
|
296
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
297
|
+
const addresses = isIP(host) ? [{ address: host, family: isIP(host) }] : await resolveHost(host);
|
|
298
|
+
const selected = addresses[0];
|
|
299
|
+
if (!selected || (selected.family !== 4 && selected.family !== 6)) {
|
|
300
|
+
throw new WebpageFetchError(`Could not resolve ${host}.`);
|
|
301
|
+
}
|
|
302
|
+
const scopes = addresses.map((entry) => classifyAddress(entry.address));
|
|
303
|
+
const scope = scopes.includes('link-local') ? 'link-local' : scopes.includes('private') ? 'private' : 'public';
|
|
304
|
+
return { addresses: addresses.map((entry) => entry.address), scope };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function requestHop(url: URL, resolved: ResolvedHop, signal: AbortSignal): Promise<Response> {
|
|
308
|
+
let failure: unknown;
|
|
309
|
+
for (const address of resolved.addresses) {
|
|
310
|
+
try {
|
|
311
|
+
return await requestAddress(url, address, signal);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (signal.aborted) throw error;
|
|
314
|
+
failure = error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
throw failure;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function requestAddress(url: URL, address: string, signal: AbortSignal): Promise<Response> {
|
|
321
|
+
return new Promise((resolve, reject) => {
|
|
322
|
+
const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(
|
|
323
|
+
url,
|
|
324
|
+
{
|
|
325
|
+
method: 'GET',
|
|
326
|
+
hostname: address,
|
|
327
|
+
servername: isIP(url.hostname.replace(/^\[|\]$/g, '')) ? undefined : url.hostname,
|
|
328
|
+
signal,
|
|
329
|
+
headers: {
|
|
330
|
+
Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.1',
|
|
331
|
+
Host: url.host,
|
|
332
|
+
'User-Agent': 'pmx-canvas webpage node',
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
(incoming) => {
|
|
336
|
+
const headers = new Headers();
|
|
337
|
+
for (let index = 0; index < incoming.rawHeaders.length; index += 2) {
|
|
338
|
+
headers.append(incoming.rawHeaders[index], incoming.rawHeaders[index + 1]);
|
|
339
|
+
}
|
|
340
|
+
const status = incoming.statusCode ?? 500;
|
|
341
|
+
const empty = status === 204 || status === 205 || status === 304;
|
|
342
|
+
// Node and DOM declare separate types for the same web-stream API.
|
|
343
|
+
const body = empty ? null : (Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array>);
|
|
344
|
+
if (empty) incoming.resume();
|
|
345
|
+
resolve(new Response(body, { status, statusText: incoming.statusMessage, headers }));
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
request.on('error', reject);
|
|
349
|
+
request.end();
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function readBoundedBody(response: Response): Promise<string> {
|
|
354
|
+
const reader = response.body?.getReader();
|
|
355
|
+
if (!reader) return '';
|
|
356
|
+
const decoder = new TextDecoder();
|
|
357
|
+
let body = '';
|
|
358
|
+
let bytes = 0;
|
|
359
|
+
while (bytes < MAX_HTML_LENGTH) {
|
|
360
|
+
const { done, value } = await reader.read();
|
|
361
|
+
if (done) break;
|
|
362
|
+
const remaining = MAX_HTML_LENGTH - bytes;
|
|
363
|
+
body += decoder.decode(value.subarray(0, remaining), { stream: value.byteLength <= remaining });
|
|
364
|
+
bytes += Math.min(value.byteLength, remaining);
|
|
365
|
+
if (value.byteLength > remaining || bytes === MAX_HTML_LENGTH) {
|
|
366
|
+
await reader.cancel();
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return body + decoder.decode();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Refuses link-local on every hop, and a redirect that moves a public fetch
|
|
375
|
+
* onto a loopback or private address. A URL the caller points at a local dev
|
|
376
|
+
* server on purpose still loads, and may redirect within the local network.
|
|
377
|
+
*/
|
|
378
|
+
export function checkWebpageHop(originScope: AddressScope, hopScope: AddressScope, hopUrl: string): void {
|
|
379
|
+
if (hopScope === 'link-local') {
|
|
380
|
+
throw new WebpageFetchError(`Refused to fetch ${hopUrl}: link-local addresses are not allowed.`);
|
|
381
|
+
}
|
|
382
|
+
if (originScope === 'public' && hopScope === 'private') {
|
|
383
|
+
throw new WebpageFetchError(`Refused redirect to ${hopUrl}: a public page may not redirect to a private address.`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function fetchWebpageSnapshot(
|
|
388
|
+
inputUrl: string,
|
|
389
|
+
options: { resolveHost?: ResolveHost; timeoutMs?: number } = {},
|
|
390
|
+
): Promise<WebpageSnapshot> {
|
|
240
391
|
const url = normalizeWebpageUrl(inputUrl);
|
|
241
392
|
const controller = new AbortController();
|
|
242
|
-
const
|
|
393
|
+
const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
394
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
395
|
+
const resolveHost = options.resolveHost ?? ((host) => lookup(host, { all: true }));
|
|
243
396
|
|
|
244
397
|
try {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
398
|
+
// Redirects are followed by hand so every hop's host is checked before it is fetched.
|
|
399
|
+
let resolved = await abortable(resolveHop(new URL(url), resolveHost), controller.signal);
|
|
400
|
+
const originScope = resolved.scope;
|
|
401
|
+
let hopUrl = url;
|
|
402
|
+
let response: Response;
|
|
403
|
+
for (let hop = 0; ; hop++) {
|
|
404
|
+
checkWebpageHop(originScope, resolved.scope, hopUrl);
|
|
405
|
+
response = await requestHop(new URL(hopUrl), resolved, controller.signal);
|
|
406
|
+
const location = response.headers.get('location');
|
|
407
|
+
if (response.status < 300 || response.status >= 400 || !location) break;
|
|
408
|
+
await response.body?.cancel();
|
|
409
|
+
if (hop === MAX_REDIRECTS) throw new WebpageFetchError(`Too many redirects while fetching ${url}.`);
|
|
410
|
+
hopUrl = normalizeWebpageUrl(new URL(location, hopUrl).toString());
|
|
411
|
+
resolved = await abortable(resolveHop(new URL(hopUrl), resolveHost), controller.signal);
|
|
412
|
+
}
|
|
253
413
|
|
|
254
414
|
const contentType = response.headers.get('content-type');
|
|
255
|
-
const responseUrl =
|
|
256
|
-
const body =
|
|
415
|
+
const responseUrl = hopUrl;
|
|
416
|
+
const body = await readBoundedBody(response);
|
|
257
417
|
|
|
258
418
|
if (!response.ok) {
|
|
259
419
|
throw new WebpageFetchError(`Request failed with ${response.status} ${response.statusText}`.trim(), {
|
|
@@ -292,7 +452,7 @@ export async function fetchWebpageSnapshot(inputUrl: string): Promise<WebpageSna
|
|
|
292
452
|
} catch (error) {
|
|
293
453
|
if (error instanceof WebpageFetchError) throw error;
|
|
294
454
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
295
|
-
throw new WebpageFetchError(`Timed out after ${
|
|
455
|
+
throw new WebpageFetchError(`Timed out after ${timeoutMs}ms while fetching ${url}.`);
|
|
296
456
|
}
|
|
297
457
|
throw new WebpageFetchError(error instanceof Error ? error.message : `Failed to fetch ${url}.`);
|
|
298
458
|
} finally {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** Per-process capability used only to distinguish workbench clicks from HTTP agents. */
|
|
4
|
+
export const workbenchToken = randomBytes(32).toString('base64url');
|
|
5
|
+
|
|
6
|
+
export function isWorkbenchToken(value: string | null): boolean {
|
|
7
|
+
if (!value) return false;
|
|
8
|
+
const actual = Buffer.from(value);
|
|
9
|
+
const expected = Buffer.from(workbenchToken);
|
|
10
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The Home category migrated 0.6 snapshots land on, so they do not bury the boards in use. */
|
|
2
|
+
export const MIGRATED_SNAPSHOT_CATEGORY = 'From old snapshots';
|
|
3
|
+
|
|
4
|
+
/** Slash-separated folder paths shared by the UI, HTTP/MCP and SDK. */
|
|
5
|
+
export function normalizeBoardCategory(value: string | null): string | null {
|
|
6
|
+
if (!value?.trim()) return null;
|
|
7
|
+
const parts = value.split('/').map((part) => part.trim());
|
|
8
|
+
if (parts.length > 8) throw new Error('Folders can be nested up to 8 levels.');
|
|
9
|
+
if (parts.some((part) => !part || part === '.' || part === '..' || part.length > 60)) {
|
|
10
|
+
throw new Error('Each folder name must be 1–60 characters, and cannot be "." or "..".');
|
|
11
|
+
}
|
|
12
|
+
return parts.join('/');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Includes implicit parents, so a board in A/B can also be moved into A. */
|
|
16
|
+
export function categoryAncestors(path: string): string[] {
|
|
17
|
+
return path.split('/').map((_, index, parts) => parts.slice(0, index + 1).join('/'));
|
|
18
|
+
}
|
package/src/shared/node-sizes.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
export const NODE_MIN_SIZES: Record<string, { width: number; height: number }> = {
|
|
21
21
|
markdown: { width: 360, height: 180 },
|
|
22
|
+
board: { width: 320, height: 140 },
|
|
22
23
|
context: { width: 360, height: 180 },
|
|
23
24
|
file: { width: 360, height: 200 },
|
|
24
25
|
diff: { width: 420, height: 240 },
|