@1agh/maude 0.37.0 → 0.38.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/README.md +2 -0
- package/apps/studio/acp/bootstrap-brief.ts +93 -0
- package/apps/studio/acp/bridge.ts +114 -1
- package/apps/studio/acp/index.ts +184 -21
- package/apps/studio/acp/plugin-bootstrap.ts +115 -0
- package/apps/studio/acp/transcript.ts +36 -3
- package/apps/studio/activity.ts +45 -0
- package/apps/studio/api.ts +265 -47
- package/apps/studio/bin/_ensure-browser.mjs +305 -0
- package/apps/studio/bin/ensure-browser.sh +26 -0
- package/apps/studio/bin/screenshot.sh +33 -8
- package/apps/studio/bin/smoke.sh +46 -0
- package/apps/studio/canvas-edit.ts +422 -6
- package/apps/studio/canvas-lib.tsx +48 -0
- package/apps/studio/canvas-shell.tsx +684 -12
- package/apps/studio/client/app.jsx +683 -33
- package/apps/studio/client/panels/ChatPanel.jsx +593 -31
- package/apps/studio/client/panels/acp-runtime.js +227 -70
- package/apps/studio/client/panels/chat-context.js +124 -0
- package/apps/studio/client/panels/slash-commands.js +147 -0
- package/apps/studio/client/styles/3-shell-maude.css +15 -0
- package/apps/studio/client/styles/6-acp-chat.css +244 -0
- package/apps/studio/commands/reorder-command.ts +77 -0
- package/apps/studio/config.schema.json +6 -0
- package/apps/studio/dist/client.bundle.js +25 -53617
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +27 -19
- package/apps/studio/http.ts +168 -26
- package/apps/studio/inspect.ts +108 -1
- package/apps/studio/paths.ts +32 -0
- package/apps/studio/readiness.ts +79 -30
- package/apps/studio/server.ts +11 -2
- package/apps/studio/test/acp-activity.test.ts +154 -0
- package/apps/studio/test/acp-ai-activity.test.ts +182 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
- package/apps/studio/test/acp-commands.test.ts +108 -0
- package/apps/studio/test/acp-origin-gate.test.ts +64 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
- package/apps/studio/test/acp-session-plugins.test.ts +132 -0
- package/apps/studio/test/acp-transcript.test.ts +53 -0
- package/apps/studio/test/active-state.test.ts +41 -0
- package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/canvas-reorder.test.ts +211 -0
- package/apps/studio/test/chat-context.test.ts +129 -0
- package/apps/studio/test/csrf-write-guard.test.ts +24 -0
- package/apps/studio/test/edit-suppress.test.ts +170 -0
- package/apps/studio/test/ensure-browser.test.ts +92 -0
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
- package/apps/studio/test/hmr-broadcast.test.ts +44 -0
- package/apps/studio/test/inspect-selections.test.ts +219 -0
- package/apps/studio/test/paths.test.ts +57 -0
- package/apps/studio/test/readiness.test.ts +83 -13
- package/apps/studio/test/reorder-api.test.ts +210 -0
- package/apps/studio/test/slash-commands.test.ts +117 -0
- package/apps/studio/undo-stack.ts +6 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +37 -0
- package/cli/commands/design.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +3 -3
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// ACP session-scoped plugin auto-bootstrap (DDR-143).
|
|
2
|
+
//
|
|
3
|
+
// A Maude Desktop user with only Claude Code installed should get `/design:*`
|
|
4
|
+
// working in the chat panel with ZERO manual install — no `npm i`, no `/plugin
|
|
5
|
+
// marketplace add`, no `/reload-plugins`. (`/flow:*` auto-load is intentionally
|
|
6
|
+
// disabled for now — 2026-07-03 — the chat ships design-only; the plumbing below
|
|
7
|
+
// stays wired so restoring flow is a one-line change in computeSessionPlugins.)
|
|
8
|
+
// We achieve that by handing
|
|
9
|
+
// the ACP-spawned `claude` a session-scoped local plugin dir through the same
|
|
10
|
+
// `_meta` seam the bootstrap brief uses: `_meta.claudeCode.options.plugins`.
|
|
11
|
+
// The adapter spreads that into the SDK `query()` options (verified live —
|
|
12
|
+
// Task-1 spike: an injected local plugin's command appears in the session's
|
|
13
|
+
// `available_commands_update` with no install), and the SDK loads its commands/
|
|
14
|
+
// agents/skills/hooks for that session only. NOTHING is written to `~/.claude`,
|
|
15
|
+
// no package manager runs, no network is touched (DDR-126/128) — it's reversible
|
|
16
|
+
// and per-session by construction.
|
|
17
|
+
//
|
|
18
|
+
// Three gates, ALL must hold for a plugin to be injected:
|
|
19
|
+
// 1. NATIVE / BUNDLE context (guard #4, DDR-119/123). The web `maude design
|
|
20
|
+
// serve` (npm) path ships no plugin manifest (DDR-044) and its users have a
|
|
21
|
+
// terminal — never inject there. The signal is the manifest's presence on
|
|
22
|
+
// disk (DESIGN_PLUGIN_DIR/FLOW_PLUGIN_DIR non-null), which is true ONLY in
|
|
23
|
+
// the desktop Resources bundle or the dev tree; plus the desktop sidecar's
|
|
24
|
+
// MAUDE_DEV_SERVER_ROOT as an explicit stricter marker.
|
|
25
|
+
// 2. BUNDLED FILES present — the per-plugin dir resolved (Task 2). Null on the
|
|
26
|
+
// npm/web layout ⇒ skip that plugin.
|
|
27
|
+
// 3. NO-OP for power users (guard #3, hard). If the plugin is already installed
|
|
28
|
+
// via the marketplace OR enabled in the user's settings.json, it loads
|
|
29
|
+
// NATIVELY under the adapter's `settingSources:user` — injecting the bundled
|
|
30
|
+
// copy on top would double-register commands + risk version skew. So a
|
|
31
|
+
// `scanPlugins()` hit ⇒ skip that plugin. A pristine ~/.claude (the target
|
|
32
|
+
// non-power-user) reads as not-installed ⇒ inject.
|
|
33
|
+
|
|
34
|
+
import { DESIGN_PLUGIN_DIR, FLOW_PLUGIN_DIR } from '../paths.ts';
|
|
35
|
+
import { type PluginScan, scanPlugins } from '../readiness.ts';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* SDK plugin-load config (`@anthropic-ai/claude-agent-sdk` `SdkPluginConfig`).
|
|
39
|
+
* Defined locally: that SDK is a TRANSITIVE dep (reached only through the
|
|
40
|
+
* adapter), so we don't import its type surface directly. Shape pinned to
|
|
41
|
+
* sdk.d.ts:3766 — an adapter/SDK bump that changes it is caught by the
|
|
42
|
+
* presence-test in test/acp-session-plugins.test.ts.
|
|
43
|
+
*/
|
|
44
|
+
export interface SdkPluginConfig {
|
|
45
|
+
type: 'local';
|
|
46
|
+
path: string;
|
|
47
|
+
/** The SDK host (this bridge) owns MCP connections — don't read the plugin's .mcp.json. */
|
|
48
|
+
skipMcpDiscovery?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SessionPluginDeps {
|
|
52
|
+
/** Desktop bundle or dev tree; false on the npm web-serve path (guard #4). */
|
|
53
|
+
native: boolean;
|
|
54
|
+
/** Bundled `design` plugin dir, or null (npm/web layout). */
|
|
55
|
+
designDir: string | null;
|
|
56
|
+
/** Bundled `flow` plugin dir, or null (npm/web layout). */
|
|
57
|
+
flowDir: string | null;
|
|
58
|
+
/** ~/.claude registry+settings scan — a present plugin loads natively (no-op). */
|
|
59
|
+
scan: Pick<PluginScan, 'design' | 'flow'>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pure resolver — returns the `SdkPluginConfig[]` to inject (possibly empty).
|
|
64
|
+
* Exported for unit tests; the real wiring is {@link resolveSessionPlugins}.
|
|
65
|
+
*/
|
|
66
|
+
export function computeSessionPlugins(deps: SessionPluginDeps): SdkPluginConfig[] {
|
|
67
|
+
if (!deps.native) return [];
|
|
68
|
+
const out: SdkPluginConfig[] = [];
|
|
69
|
+
const add = (dir: string | null, alreadyPresent: boolean): void => {
|
|
70
|
+
// dir === null → not bundled in this layout (web/npm) → skip.
|
|
71
|
+
// alreadyPresent → loads natively → skip (no double-registration / skew).
|
|
72
|
+
if (dir && !alreadyPresent) out.push({ type: 'local', path: dir, skipMcpDiscovery: true });
|
|
73
|
+
};
|
|
74
|
+
add(deps.designDir, deps.scan.design);
|
|
75
|
+
// `/flow` auto-load is intentionally OFF for now (2026-07-03) — the chat ships
|
|
76
|
+
// design-only. `deps.flowDir` / `deps.scan.flow` stay resolved (harmless) so
|
|
77
|
+
// restoring it is a one-liner: re-add `add(deps.flowDir, deps.scan.flow)`.
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Native/bundle context — true in the packaged desktop app (sidecar sets
|
|
83
|
+
* MAUDE_DEV_SERVER_ROOT, DDR-106) OR the dev tree (a bundled plugin manifest
|
|
84
|
+
* resolves on disk, so the feature is dogfoodable without building the `.app`).
|
|
85
|
+
* False on the npm `maude design serve` path: no MAUDE_DEV_SERVER_ROOT and no
|
|
86
|
+
* staged manifest (DDR-044) ⇒ both plugin dirs null.
|
|
87
|
+
*
|
|
88
|
+
* `MAUDE_NO_PLUGIN_BOOTSTRAP=1` is a hard opt-out: it reverts to the DDR-128
|
|
89
|
+
* detect-and-guide posture (no injection, the readiness `plugins` row shows the
|
|
90
|
+
* manual marketplace remediation). For a power user who wants to force the manual
|
|
91
|
+
* path, a locked-down deployment, or test isolation.
|
|
92
|
+
*/
|
|
93
|
+
export function isNativePluginContext(): boolean {
|
|
94
|
+
if (process.env.MAUDE_NO_PLUGIN_BOOTSTRAP === '1') return false;
|
|
95
|
+
return (
|
|
96
|
+
!!process.env.MAUDE_DEV_SERVER_ROOT || DESIGN_PLUGIN_DIR !== null || FLOW_PLUGIN_DIR !== null
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the session-scoped plugins to inject for a fresh ACP session. Wires the
|
|
102
|
+
* real deps (native context, bundled dirs, live ~/.claude scan) into
|
|
103
|
+
* {@link computeSessionPlugins}. Empty array ⇒ inject nothing (the power-user /
|
|
104
|
+
* web no-op). Recomputed per bridge construction; stable across an adapter
|
|
105
|
+
* re-spawn because it's carried on the bridge's readonly options.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveSessionPlugins(): SdkPluginConfig[] {
|
|
108
|
+
const scan = scanPlugins();
|
|
109
|
+
return computeSessionPlugins({
|
|
110
|
+
native: isNativePluginContext(),
|
|
111
|
+
designDir: DESIGN_PLUGIN_DIR,
|
|
112
|
+
flowDir: FLOW_PLUGIN_DIR,
|
|
113
|
+
scan: { design: scan.design, flow: scan.flow },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -44,10 +44,12 @@ function readLines(file: string): Array<Record<string, unknown>> {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/** First user line, truncated — the chat's display title.
|
|
47
|
+
/** First user line, truncated — the chat's display title. Context-attachment
|
|
48
|
+
* lines are stripped first, so a chat never titles itself `[maude-context …`
|
|
49
|
+
* (the 2026-07-03 dogfood finding). */
|
|
48
50
|
function deriveTitle(lines: Array<Record<string, unknown>>): string {
|
|
49
51
|
const firstUser = lines.find((l) => l.role === 'user' && typeof l.text === 'string');
|
|
50
|
-
const text = (firstUser?.text as string) ?? '';
|
|
52
|
+
const text = stripContextBlock((firstUser?.text as string) ?? '');
|
|
51
53
|
const trimmed = text.replace(/\s+/g, ' ').trim();
|
|
52
54
|
return trimmed ? trimmed.slice(0, 60) : 'New chat';
|
|
53
55
|
}
|
|
@@ -85,6 +87,34 @@ export function deleteChat(designRoot: string, chatId: string): boolean {
|
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
/**
|
|
91
|
+
* UI-projection strip of the frozen chat-context a send attaches
|
|
92
|
+
* (feature-acp-context-hardening). PROJECTION ONLY — the on-disk jsonl keeps
|
|
93
|
+
* the raw prompt (it's the audit record of what steered the auto-approving
|
|
94
|
+
* agent); the rendered bubble/title shows just what the user typed. Handles
|
|
95
|
+
* both formats: the current TRAILING `[maude-context …]` (+ `[selected: …]`)
|
|
96
|
+
* bracket lines, and the legacy leading `<maude-context>` fence (2026-07-02,
|
|
97
|
+
* one release window). `role:'bootstrap'` brief entries are skipped by this
|
|
98
|
+
* reader by construction (only user/stop/agent roles are consumed below).
|
|
99
|
+
*/
|
|
100
|
+
function stripContextBlock(text: string): string {
|
|
101
|
+
let out = text;
|
|
102
|
+
if (out.startsWith('<maude-context')) {
|
|
103
|
+
const end = out.indexOf('</maude-context>');
|
|
104
|
+
if (end !== -1) out = out.slice(end + '</maude-context>'.length).replace(/^\s+/, '');
|
|
105
|
+
}
|
|
106
|
+
const i = out.indexOf('\n[maude-context ');
|
|
107
|
+
if (i !== -1) {
|
|
108
|
+
const tail = out.slice(i + 1);
|
|
109
|
+
// Only strip when the remainder is EXACTLY the context block (all lines are
|
|
110
|
+
// bracket lines) — a user merely quoting `[maude-context …]` mid-text stays.
|
|
111
|
+
if (/^\[maude-context [^\n]*\](\n\[selected:[^\n]*\])*\s*$/.test(tail)) {
|
|
112
|
+
out = out.slice(0, i).replace(/\s+$/, '');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
88
118
|
/**
|
|
89
119
|
* Convert the raw transcript into clean per-turn messages: user lines become
|
|
90
120
|
* user messages; the agent updates between them aggregate into one assistant
|
|
@@ -108,7 +138,10 @@ export function readChatMessages(designRoot: string, chatId: string): ChatMessag
|
|
|
108
138
|
for (const line of lines) {
|
|
109
139
|
if (line.role === 'user' && typeof line.text === 'string') {
|
|
110
140
|
flush();
|
|
111
|
-
messages.push({
|
|
141
|
+
messages.push({
|
|
142
|
+
role: 'user',
|
|
143
|
+
parts: [{ type: 'text', text: stripContextBlock(line.text) }],
|
|
144
|
+
});
|
|
112
145
|
continue;
|
|
113
146
|
}
|
|
114
147
|
if (line.role === 'stop') {
|
package/apps/studio/activity.ts
CHANGED
|
@@ -19,6 +19,9 @@ import type { Context } from './context.ts';
|
|
|
19
19
|
/** Idle debounce — fs silence longer than this flips a file `active` → `idle`. */
|
|
20
20
|
export const ACTIVITY_IDLE_MS = 3000;
|
|
21
21
|
|
|
22
|
+
/** Default user-write suppression window (see ActivityOptions.suppressTtlMs). */
|
|
23
|
+
export const SUPPRESS_TTL_MS = 2500;
|
|
24
|
+
|
|
22
25
|
/** LRU cap on the per-file text stash used by the Task 7 region diff. */
|
|
23
26
|
const STASH_CAP = 50;
|
|
24
27
|
|
|
@@ -104,6 +107,8 @@ export interface ActivityOptions {
|
|
|
104
107
|
idleMs?: number;
|
|
105
108
|
/** Disable the Task 7 per-artboard diff (file-level only). Default: enabled. */
|
|
106
109
|
diff?: boolean;
|
|
110
|
+
/** Override the user-write suppression window (tests pass a small value). */
|
|
111
|
+
suppressTtlMs?: number;
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
export function createActivity(ctx: Context, opts: ActivityOptions = {}): Activity {
|
|
@@ -114,6 +119,18 @@ export function createActivity(ctx: Context, opts: ActivityOptions = {}): Activi
|
|
|
114
119
|
const idleTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
115
120
|
// Last-seen file text, for the Task 7 region diff. Insertion-ordered Map = LRU.
|
|
116
121
|
const lastText = new Map<string, string>();
|
|
122
|
+
// Phase 12.1 — writes the USER just made through the shell (drag / keyboard
|
|
123
|
+
// reorder, inspector CSS/text/attr edits + their undo/redo) must NOT light the
|
|
124
|
+
// "editing" rim: the overlay means "an agent works here" (DDR-029), and
|
|
125
|
+
// flashing it on the user's own direct manipulation reads as interference.
|
|
126
|
+
// The write path emits `activity:suppress` with the designRoot-relative path
|
|
127
|
+
// right before writing; EVERY fs:any for that file inside the TTL window is
|
|
128
|
+
// swallowed (RC2, rca/issue-canvas-hmr-optimistic-update-consistency — the
|
|
129
|
+
// old one-shot delete let the 2nd event of a same-file write burst through:
|
|
130
|
+
// slider drags and reorder spam produce several debounced fs:any per arm).
|
|
131
|
+
// The TTL bounds the mute so a crashed write can't silence a file forever.
|
|
132
|
+
const suppressTtlMs = opts.suppressTtlMs ?? SUPPRESS_TTL_MS;
|
|
133
|
+
const suppressed = new Map<string, number>();
|
|
117
134
|
|
|
118
135
|
function emit(file: string) {
|
|
119
136
|
const e = state[file];
|
|
@@ -191,15 +208,43 @@ export function createActivity(ctx: Context, opts: ActivityOptions = {}): Activi
|
|
|
191
208
|
|
|
192
209
|
const off = ctx.bus.on('fs:any', (rel: string) => {
|
|
193
210
|
if (!isCanvasFile(rel)) return;
|
|
211
|
+
const until = suppressed.get(rel);
|
|
212
|
+
if (until != null) {
|
|
213
|
+
if (Date.now() < until) {
|
|
214
|
+
// User-originated write window — keep the diff baseline fresh so the
|
|
215
|
+
// NEXT (agent) edit still region-scopes correctly, but skip the rim.
|
|
216
|
+
// The entry stays armed until the TTL expires or the writer disarms.
|
|
217
|
+
if (diffEnabled) void refineArtboards(rel);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
suppressed.delete(rel); // expired — clean up and fall through to mark()
|
|
221
|
+
}
|
|
194
222
|
mark(rel);
|
|
195
223
|
if (diffEnabled) void refineArtboards(rel);
|
|
196
224
|
});
|
|
197
225
|
|
|
226
|
+
const offSuppress = ctx.bus.on('activity:suppress', (rel: string) => {
|
|
227
|
+
if (typeof rel !== 'string' || !rel) return;
|
|
228
|
+
suppressed.set(rel.replace(/\\/g, '/'), Date.now() + suppressTtlMs);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// The write path arms `activity:suppress` BEFORE the write (so it catches the
|
|
232
|
+
// debounced fs:any). If the write turns out to be a no-op or throws, it disarms
|
|
233
|
+
// via this event — otherwise a failed/spam reorder would swallow the NEXT
|
|
234
|
+
// genuine (agent / synced-peer) edit's rim (adversarial F2).
|
|
235
|
+
const offUnsuppress = ctx.bus.on('activity:unsuppress', (rel: string) => {
|
|
236
|
+
if (typeof rel !== 'string' || !rel) return;
|
|
237
|
+
suppressed.delete(rel.replace(/\\/g, '/'));
|
|
238
|
+
});
|
|
239
|
+
|
|
198
240
|
function stop() {
|
|
199
241
|
off();
|
|
242
|
+
offSuppress();
|
|
243
|
+
offUnsuppress();
|
|
200
244
|
for (const t of idleTimers.values()) clearTimeout(t);
|
|
201
245
|
idleTimers.clear();
|
|
202
246
|
lastText.clear();
|
|
247
|
+
suppressed.clear();
|
|
203
248
|
}
|
|
204
249
|
|
|
205
250
|
return { state, mark, stop };
|
package/apps/studio/api.ts
CHANGED
|
@@ -10,10 +10,13 @@ import { renderBriefBoard, validateCanvasName } from './canvas-create.ts';
|
|
|
10
10
|
import {
|
|
11
11
|
CanvasEditError,
|
|
12
12
|
editAttribute,
|
|
13
|
+
type MovePosition,
|
|
14
|
+
moveElement,
|
|
13
15
|
removeAttribute,
|
|
14
16
|
editText as runEditText,
|
|
15
17
|
} from './canvas-edit.ts';
|
|
16
18
|
import type { Context } from './context.ts';
|
|
19
|
+
import { createHistory } from './history.ts';
|
|
17
20
|
|
|
18
21
|
// Directories that never hold user-facing canvases. Exported so the
|
|
19
22
|
// external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
|
|
@@ -180,6 +183,21 @@ export type EditOpResult =
|
|
|
180
183
|
| { ok: true; delta: number }
|
|
181
184
|
| { ok: false; status: number; error: string };
|
|
182
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Phase 12.1 (DDR-138) — result of a node-move reorder. Carries the re-settle
|
|
188
|
+
* hints the client uses to re-select the moved element through the positional
|
|
189
|
+
* `data-cd-id` churn: `movedId` (recomputed positional id == the post-reload DOM
|
|
190
|
+
* id, best-effort) and `semanticId` (the moved element's `data-dc-element`, which
|
|
191
|
+
* survives the move verbatim — the reliable key when present).
|
|
192
|
+
*/
|
|
193
|
+
export type ReorderOpResult =
|
|
194
|
+
| { ok: true; delta: number; movedId: string | null; semanticId: string | null; seq: number }
|
|
195
|
+
| { ok: false; status: number; error: string };
|
|
196
|
+
|
|
197
|
+
export type ReorderRevertResult =
|
|
198
|
+
| { ok: true; dir: 'undo' | 'redo' }
|
|
199
|
+
| { ok: false; status: number; error: string };
|
|
200
|
+
|
|
183
201
|
export interface Api {
|
|
184
202
|
// File tree
|
|
185
203
|
fileSlug(file: string): string;
|
|
@@ -221,6 +239,9 @@ export interface Api {
|
|
|
221
239
|
saveAnnotations(file: string, svg: string): Promise<boolean>;
|
|
222
240
|
// Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
|
|
223
241
|
saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
|
|
242
|
+
// Persist a clipboard-pasted ACP composer image → runtime `_chat/attachments/`,
|
|
243
|
+
// returns an absolute path (Phase 31 follow-up — POST /_api/acp/attachment).
|
|
244
|
+
saveChatAttachment(bytes: Uint8Array): Promise<SaveAssetResult>;
|
|
224
245
|
// Create a blank brief board from the browser (Phase 22 — POST /_api/canvas)
|
|
225
246
|
createCanvas(input: {
|
|
226
247
|
name?: unknown;
|
|
@@ -248,6 +269,24 @@ export interface Api {
|
|
|
248
269
|
attr?: unknown;
|
|
249
270
|
value?: unknown;
|
|
250
271
|
}): Promise<EditOpResult>;
|
|
272
|
+
// Phase 12.1 (DDR-138) — node-move reorder (POST /_api/reorder). Main-origin
|
|
273
|
+
// only. Moves the element with data-cd-id `id` to `position` relative to
|
|
274
|
+
// `refId` (reparent-capable), snapshotting pre-move for /design:rollback.
|
|
275
|
+
reorder(input: {
|
|
276
|
+
canvas?: unknown;
|
|
277
|
+
id?: unknown;
|
|
278
|
+
refId?: unknown;
|
|
279
|
+
position?: unknown;
|
|
280
|
+
}): Promise<ReorderOpResult>;
|
|
281
|
+
// Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole-
|
|
282
|
+
// file content swap from the in-memory revert log — immune to the positional
|
|
283
|
+
// data-cd-id churn a reorder causes (inverse-descriptor undo would go stale).
|
|
284
|
+
// Refuses (409) when the canvas changed since the reorder (external edit).
|
|
285
|
+
reorderRevert(input: {
|
|
286
|
+
canvas?: unknown;
|
|
287
|
+
seq?: unknown;
|
|
288
|
+
dir?: unknown;
|
|
289
|
+
}): Promise<ReorderRevertResult>;
|
|
251
290
|
// Aggregate data
|
|
252
291
|
buildIndexData(): Promise<unknown>;
|
|
253
292
|
buildSystemData(dsName?: string | null): Promise<unknown>;
|
|
@@ -355,6 +394,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
355
394
|
const onAnnotationsChanged = hooks.onAnnotationsChanged;
|
|
356
395
|
const { paths, cfg } = ctx;
|
|
357
396
|
|
|
397
|
+
// Phase 12.1 — in-memory reorder revert log (Cmd+Z for drag/keyboard moves).
|
|
398
|
+
// Whole-file {before, after} per reorder, keyed by a monotonic seq the client
|
|
399
|
+
// stores in its undo record. Ephemeral by design: a server restart drops it
|
|
400
|
+
// (undo answers 404 and the canvas stack entry is a no-op, honest failure).
|
|
401
|
+
const REORDER_LOG_CAP = 50;
|
|
402
|
+
const reorderLog = new Map<number, { abs: string; before: string; after: string }>();
|
|
403
|
+
let reorderSeq = 0;
|
|
404
|
+
|
|
358
405
|
function fileSlug(file: string): string {
|
|
359
406
|
return canvasSlugFromRel(file, paths.designRel);
|
|
360
407
|
}
|
|
@@ -984,6 +1031,56 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
984
1031
|
return { ok: true, path: `assets/${name}` };
|
|
985
1032
|
}
|
|
986
1033
|
|
|
1034
|
+
// Phase 31 follow-up — persist an image pasted straight from the clipboard into
|
|
1035
|
+
// the ACP composer (a screenshot has no path yet), so the chip can point Claude
|
|
1036
|
+
// at a real file to Read. Sibling of saveAsset, but:
|
|
1037
|
+
// • writes under the RUNTIME `_chat/attachments/` (gitignored — DDR-115), not
|
|
1038
|
+
// the versioned `assets/`; pasted screenshots are ephemeral, not canvas media.
|
|
1039
|
+
// • returns an ABSOLUTE path — Claude runs with its own cwd, so a project-
|
|
1040
|
+
// relative string could miss; an absolute path always resolves.
|
|
1041
|
+
// Same load-bearing caps as saveAsset (magic-byte sniff → no SVG/script, 10 MB,
|
|
1042
|
+
// content-addressed name, shared session write budget). MAIN-ORIGIN ONLY at the
|
|
1043
|
+
// route layer (the untrusted canvas can't reach it).
|
|
1044
|
+
async function saveChatAttachment(bytes: Uint8Array): Promise<SaveAssetResult> {
|
|
1045
|
+
if (!bytes || bytes.length === 0) return { ok: false, status: 400, error: 'empty body' };
|
|
1046
|
+
if (bytes.length > ASSET_MAX_BYTES) {
|
|
1047
|
+
return { ok: false, status: 413, error: 'attachment exceeds the 10 MB cap' };
|
|
1048
|
+
}
|
|
1049
|
+
const kind = sniffImageType(bytes);
|
|
1050
|
+
if (!kind) {
|
|
1051
|
+
return {
|
|
1052
|
+
ok: false,
|
|
1053
|
+
status: 415,
|
|
1054
|
+
error: 'unsupported image type — png/jpeg/gif/webp only (SVG rejected)',
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
const sha8 = crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 8);
|
|
1058
|
+
const name = `${sha8}.${kind}`;
|
|
1059
|
+
const dir = path.join(paths.designRoot, '_chat', 'attachments');
|
|
1060
|
+
const fileAbs = path.join(dir, name);
|
|
1061
|
+
// Containment backstop — the name is content-addressed (no user input), assert anyway.
|
|
1062
|
+
if (path.resolve(fileAbs) !== path.join(path.resolve(dir), name)) {
|
|
1063
|
+
return { ok: false, status: 400, error: 'resolved attachment path escapes _chat dir' };
|
|
1064
|
+
}
|
|
1065
|
+
try {
|
|
1066
|
+
if (!(await Bun.file(fileAbs).exists())) {
|
|
1067
|
+
if (assetBytesWritten + bytes.length > ASSET_SESSION_BUDGET) {
|
|
1068
|
+
return {
|
|
1069
|
+
ok: false,
|
|
1070
|
+
status: 429,
|
|
1071
|
+
error: 'asset write budget exceeded for this server session',
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
await mkdir(dir, { recursive: true });
|
|
1075
|
+
await Bun.write(fileAbs, bytes);
|
|
1076
|
+
assetBytesWritten += bytes.length;
|
|
1077
|
+
}
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
return { ok: false, status: 500, error: err instanceof Error ? err.message : 'write failed' };
|
|
1080
|
+
}
|
|
1081
|
+
return { ok: true, path: fileAbs };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
987
1084
|
// Phase 22 — create a blank brief board from the browser file tree. Wired ONLY
|
|
988
1085
|
// on the main origin (server.ts startMainServer); the segregated canvas origin
|
|
989
1086
|
// (DDR-054) never exposes this — an untrusted canvas iframe must not be able to
|
|
@@ -1242,6 +1339,36 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1242
1339
|
// 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
|
|
1243
1340
|
const CD_ID_RE = /^[0-9a-f]{8}$/;
|
|
1244
1341
|
|
|
1342
|
+
// RC1 (rca/issue-canvas-hmr-optimistic-update-consistency) — every inline
|
|
1343
|
+
// edit is USER-originated (inspector CSS knobs, inline text, attr panel, and
|
|
1344
|
+
// their undo/redo which re-POST the same endpoints), so it must not light the
|
|
1345
|
+
// "agent works here" rim. Mirror the reorder pattern (below): arm the
|
|
1346
|
+
// suppression BEFORE the write so it catches the debounced fs:any, disarm on
|
|
1347
|
+
// a no-op (delta 0 = no write) or throw so a failed edit can't mute the next
|
|
1348
|
+
// genuine agent edit's rim.
|
|
1349
|
+
async function suppressedEdit(
|
|
1350
|
+
abs: string,
|
|
1351
|
+
run: () => Promise<{ delta: number; changed?: boolean }>,
|
|
1352
|
+
errLabel: string
|
|
1353
|
+
): Promise<EditOpResult> {
|
|
1354
|
+
const rel = path.relative(paths.designRoot, abs);
|
|
1355
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
1356
|
+
try {
|
|
1357
|
+
const res = await run();
|
|
1358
|
+
// `changed === false` = the op collapsed to a no-op and nothing hit disk —
|
|
1359
|
+
// NOT `delta === 0`, which an equal-length replacement also produces.
|
|
1360
|
+
if (res.changed === false) ctx.bus.emit('activity:unsuppress', rel);
|
|
1361
|
+
return { ok: true, delta: res.delta };
|
|
1362
|
+
} catch (err) {
|
|
1363
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
1364
|
+
return {
|
|
1365
|
+
ok: false,
|
|
1366
|
+
status: 422,
|
|
1367
|
+
error: err instanceof CanvasEditError ? err.message : errLabel,
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1245
1372
|
async function editCss(input: {
|
|
1246
1373
|
canvas?: unknown;
|
|
1247
1374
|
id?: unknown;
|
|
@@ -1265,16 +1392,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1265
1392
|
// Phase 12.3 — `reset: true` REMOVES the inline property (back to class /
|
|
1266
1393
|
// inherited). No value needed; a missing key is a no-op (delta 0).
|
|
1267
1394
|
if (input.reset === true) {
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
ok: false,
|
|
1274
|
-
status: 422,
|
|
1275
|
-
error: err instanceof CanvasEditError ? err.message : 'reset failed',
|
|
1276
|
-
};
|
|
1277
|
-
}
|
|
1395
|
+
return suppressedEdit(
|
|
1396
|
+
r.abs,
|
|
1397
|
+
() => removeAttribute(r.abs, id, `style.${camel}`),
|
|
1398
|
+
'reset failed'
|
|
1399
|
+
);
|
|
1278
1400
|
}
|
|
1279
1401
|
const value = typeof input.value === 'string' ? input.value : '';
|
|
1280
1402
|
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
@@ -1283,16 +1405,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1283
1405
|
// quotes/backslashes/newlines so it can never break out of the string, and
|
|
1284
1406
|
// React accepts string values for every style prop — so `var(--accent)`,
|
|
1285
1407
|
// `#fff`, `8px`, `700`, `1.5` all ride verbatim.
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
ok: false,
|
|
1292
|
-
status: 422,
|
|
1293
|
-
error: err instanceof CanvasEditError ? err.message : 'edit failed',
|
|
1294
|
-
};
|
|
1295
|
-
}
|
|
1408
|
+
return suppressedEdit(
|
|
1409
|
+
r.abs,
|
|
1410
|
+
() => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value)),
|
|
1411
|
+
'edit failed'
|
|
1412
|
+
);
|
|
1296
1413
|
}
|
|
1297
1414
|
|
|
1298
1415
|
async function editText(input: {
|
|
@@ -1306,16 +1423,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1306
1423
|
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
1307
1424
|
if (typeof input.text !== 'string') return { ok: false, status: 400, error: 'text required' };
|
|
1308
1425
|
if (input.text.length > 5000) return { ok: false, status: 413, error: 'text too long' };
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
return { ok: true, delta: res.delta };
|
|
1312
|
-
} catch (err) {
|
|
1313
|
-
return {
|
|
1314
|
-
ok: false,
|
|
1315
|
-
status: 422,
|
|
1316
|
-
error: err instanceof CanvasEditError ? err.message : 'edit failed',
|
|
1317
|
-
};
|
|
1318
|
-
}
|
|
1426
|
+
const text = input.text;
|
|
1427
|
+
return suppressedEdit(r.abs, () => runEditText(r.abs, id, text), 'edit failed');
|
|
1319
1428
|
}
|
|
1320
1429
|
|
|
1321
1430
|
async function editAttr(input: {
|
|
@@ -1344,36 +1453,142 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1344
1453
|
}
|
|
1345
1454
|
// Phase 12.3 — `reset: true` REMOVES the custom attribute. No-op if absent.
|
|
1346
1455
|
if (input.reset === true) {
|
|
1347
|
-
|
|
1348
|
-
const res = await removeAttribute(r.abs, id, attr);
|
|
1349
|
-
return { ok: true, delta: res.delta };
|
|
1350
|
-
} catch (err) {
|
|
1351
|
-
return {
|
|
1352
|
-
ok: false,
|
|
1353
|
-
status: 422,
|
|
1354
|
-
error: err instanceof CanvasEditError ? err.message : 'reset failed',
|
|
1355
|
-
};
|
|
1356
|
-
}
|
|
1456
|
+
return suppressedEdit(r.abs, () => removeAttribute(r.abs, id, attr), 'reset failed');
|
|
1357
1457
|
}
|
|
1358
1458
|
const value = typeof input.value === 'string' ? input.value : '';
|
|
1359
1459
|
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
1360
1460
|
if (value.length > 256) return { ok: false, status: 413, error: 'value too long' };
|
|
1461
|
+
// Non-`style.` attr name → editAttribute writes a plain quoted JSX attribute.
|
|
1462
|
+
// Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
|
|
1463
|
+
// on replace, escapeAttr on insert) — pre-stringifying here double-encoded
|
|
1464
|
+
// the value (`data-x="\"ok\""`; knob-smoke finding, 2026-06-12).
|
|
1465
|
+
return suppressedEdit(r.abs, () => editAttribute(r.abs, id, attr, value), 'edit failed');
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// Phase 12.1 (DDR-138) — snapshot stack so a reorder is undoable via
|
|
1469
|
+
// /design:rollback (a positional move has no value-inverse the edit-source
|
|
1470
|
+
// undo command can re-apply, so it rides `_history` instead).
|
|
1471
|
+
const history = createHistory(ctx);
|
|
1472
|
+
|
|
1473
|
+
const MOVE_POSITIONS = new Set(['before', 'after', 'inside-start', 'inside-end']);
|
|
1474
|
+
|
|
1475
|
+
async function reorder(input: {
|
|
1476
|
+
canvas?: unknown;
|
|
1477
|
+
id?: unknown;
|
|
1478
|
+
refId?: unknown;
|
|
1479
|
+
position?: unknown;
|
|
1480
|
+
idIndex?: unknown;
|
|
1481
|
+
refIndex?: unknown;
|
|
1482
|
+
}): Promise<ReorderOpResult> {
|
|
1483
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
1484
|
+
if (!r.ok) return r;
|
|
1485
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
1486
|
+
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
1487
|
+
const refId = typeof input.refId === 'string' ? input.refId.trim() : '';
|
|
1488
|
+
if (!CD_ID_RE.test(refId)) {
|
|
1489
|
+
return { ok: false, status: 400, error: 'invalid reference data-cd-id' };
|
|
1490
|
+
}
|
|
1491
|
+
const position = input.position;
|
|
1492
|
+
if (typeof position !== 'string' || !MOVE_POSITIONS.has(position)) {
|
|
1493
|
+
return { ok: false, status: 400, error: 'invalid position' };
|
|
1494
|
+
}
|
|
1495
|
+
// Occurrence index of a reused-component instance (which rendered copy). 0/absent
|
|
1496
|
+
// for a normal element; moveElement maps it to the parent USAGE.
|
|
1497
|
+
const idIndex = Number.isInteger(input.idIndex) ? (input.idIndex as number) : undefined;
|
|
1498
|
+
const refIndex = Number.isInteger(input.refIndex) ? (input.refIndex as number) : undefined;
|
|
1499
|
+
// A same-id move CAN be valid now: two instances of the same reused component
|
|
1500
|
+
// resolve to DIFFERENT usage elements. Only bail when they're the same instance.
|
|
1501
|
+
if (id === refId && (idIndex ?? 0) === (refIndex ?? 0)) {
|
|
1502
|
+
return { ok: false, status: 422, error: 'cannot move an element relative to itself' };
|
|
1503
|
+
}
|
|
1504
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
1505
|
+
// Arm the "user did this — don't light the agent-editing rim" suppression
|
|
1506
|
+
// BEFORE the write so it catches the debounced fs:any. It's disarmed below if
|
|
1507
|
+
// the move turns out to be a no-op or throws (adversarial F2 — else a
|
|
1508
|
+
// failed/spam reorder would swallow the next genuine edit's rim).
|
|
1509
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
1361
1510
|
try {
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1511
|
+
const before = await Bun.file(r.abs).text();
|
|
1512
|
+
const res = await moveElement(r.abs, id, refId, position as MovePosition, idIndex, refIndex);
|
|
1513
|
+
const after = await Bun.file(r.abs).text();
|
|
1514
|
+
if (after === before) {
|
|
1515
|
+
// No-op move (the reparse-clean edit collapsed to the same source) — no
|
|
1516
|
+
// write happened, so disarm the rim suppression and DON'T snapshot/log
|
|
1517
|
+
// (adversarial F2 + F3: never deposit a _history blob for a non-write).
|
|
1518
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
1519
|
+
return { ok: true, delta: 0, movedId: res.movedId, semanticId: res.semanticId, seq: 0 };
|
|
1520
|
+
}
|
|
1521
|
+
// A real write landed. Snapshot the pre-move source for /design:rollback
|
|
1522
|
+
// (RELATIVE path → `_history/<slug>/`; the absolute path would mangle the
|
|
1523
|
+
// slug slug.sh can't find) — best-effort. Log {before, after} under a seq so
|
|
1524
|
+
// Cmd+Z can revert by whole-file swap (inverse descriptors go stale as
|
|
1525
|
+
// positional ids churn).
|
|
1526
|
+
try {
|
|
1527
|
+
await history.writeSnapshot(rel, before, 'pre-reorder');
|
|
1528
|
+
} catch {
|
|
1529
|
+
/* snapshot is a safety net, not a gate */
|
|
1530
|
+
}
|
|
1531
|
+
const seq = ++reorderSeq;
|
|
1532
|
+
reorderLog.set(seq, { abs: r.abs, before, after });
|
|
1533
|
+
while (reorderLog.size > REORDER_LOG_CAP) {
|
|
1534
|
+
const oldest = reorderLog.keys().next().value;
|
|
1535
|
+
if (oldest === undefined) break;
|
|
1536
|
+
reorderLog.delete(oldest);
|
|
1537
|
+
}
|
|
1538
|
+
return { ok: true, delta: res.delta, movedId: res.movedId, semanticId: res.semanticId, seq };
|
|
1368
1539
|
} catch (err) {
|
|
1540
|
+
ctx.bus.emit('activity:unsuppress', rel); // move threw, no write — disarm
|
|
1369
1541
|
return {
|
|
1370
1542
|
ok: false,
|
|
1371
1543
|
status: 422,
|
|
1372
|
-
error: err instanceof CanvasEditError ? err.message : '
|
|
1544
|
+
error: err instanceof CanvasEditError ? err.message : 'reorder failed',
|
|
1373
1545
|
};
|
|
1374
1546
|
}
|
|
1375
1547
|
}
|
|
1376
1548
|
|
|
1549
|
+
async function reorderRevert(input: {
|
|
1550
|
+
canvas?: unknown;
|
|
1551
|
+
seq?: unknown;
|
|
1552
|
+
dir?: unknown;
|
|
1553
|
+
}): Promise<ReorderRevertResult> {
|
|
1554
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
1555
|
+
if (!r.ok) return r;
|
|
1556
|
+
const dir = input.dir;
|
|
1557
|
+
if (dir !== 'undo' && dir !== 'redo') return { ok: false, status: 400, error: 'invalid dir' };
|
|
1558
|
+
const seq = typeof input.seq === 'number' ? input.seq : Number.NaN;
|
|
1559
|
+
const entry = Number.isInteger(seq) ? reorderLog.get(seq) : undefined;
|
|
1560
|
+
if (!entry || entry.abs !== r.abs) {
|
|
1561
|
+
return {
|
|
1562
|
+
ok: false,
|
|
1563
|
+
status: 404,
|
|
1564
|
+
error: 'reorder not found (server restarted or log rotated)',
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
try {
|
|
1568
|
+
const current = await Bun.file(r.abs).text();
|
|
1569
|
+
const expect = dir === 'undo' ? entry.after : entry.before;
|
|
1570
|
+
const write = dir === 'undo' ? entry.before : entry.after;
|
|
1571
|
+
if (current !== expect) {
|
|
1572
|
+
return {
|
|
1573
|
+
ok: false,
|
|
1574
|
+
status: 409,
|
|
1575
|
+
error: 'canvas changed since this reorder — undo skipped',
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
1579
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
1580
|
+
try {
|
|
1581
|
+
await Bun.write(r.abs, write);
|
|
1582
|
+
} catch (e) {
|
|
1583
|
+
ctx.bus.emit('activity:unsuppress', rel); // write failed — disarm the rim suppression
|
|
1584
|
+
throw e;
|
|
1585
|
+
}
|
|
1586
|
+
return { ok: true, dir };
|
|
1587
|
+
} catch {
|
|
1588
|
+
return { ok: false, status: 500, error: 'revert failed' };
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1377
1592
|
async function saveCanvasState(file: string, state: Record<string, unknown>) {
|
|
1378
1593
|
if (!state || typeof state !== 'object') return;
|
|
1379
1594
|
const safe: Record<string, unknown> = {};
|
|
@@ -1778,11 +1993,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1778
1993
|
loadAnnotations,
|
|
1779
1994
|
saveAnnotations,
|
|
1780
1995
|
saveAsset,
|
|
1996
|
+
saveChatAttachment,
|
|
1781
1997
|
createCanvas,
|
|
1782
1998
|
deleteCanvas,
|
|
1783
1999
|
editCss,
|
|
1784
2000
|
editText,
|
|
1785
2001
|
editAttr,
|
|
2002
|
+
reorder,
|
|
2003
|
+
reorderRevert,
|
|
1786
2004
|
buildIndexData,
|
|
1787
2005
|
buildSystemData,
|
|
1788
2006
|
loadExportHistory,
|