@1agh/maude 0.56.0 → 0.58.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 +5 -1
- package/apps/studio/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/api.ts +69 -4
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +187 -39
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +72 -13
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/panels/chat-markdown.jsx +76 -5
- package/apps/studio/client/panels/file-preview.jsx +115 -0
- package/apps/studio/client/styles/3-shell-maude.css +56 -2
- package/apps/studio/client/styles/6-acp-chat.css +79 -0
- package/apps/studio/dist/client.bundle.js +1436 -1436
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +59 -2
- package/apps/studio/input-router.tsx +39 -1
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +50 -1
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +11 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/config-version.test.ts +88 -0
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/input-router.test.ts +56 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/test/whats-new.test.ts +13 -1
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/whats-new.ts +34 -10
- package/package.json +8 -8
package/apps/studio/api.ts
CHANGED
|
@@ -117,6 +117,33 @@ export const SKIP_DIRS = new Set([
|
|
|
117
117
|
]);
|
|
118
118
|
const HIDDEN_OK = new Set(['.ai', '.claude', '.design']);
|
|
119
119
|
|
|
120
|
+
// feature-studio-file-preview — binary/media extensions the tree lists so a
|
|
121
|
+
// DS's assets/{fonts,graphics,logos,photos,...} files show up (previously
|
|
122
|
+
// only their parent folders did, via findFiles's dirsOut accounting). Kept as
|
|
123
|
+
// an explicit enumerated list rather than a broad pattern so it can never
|
|
124
|
+
// accidentally widen to swallow runtime JSON — findFiles already excludes
|
|
125
|
+
// `_`-prefixed entries before this list is even consulted.
|
|
126
|
+
const PREVIEW_ASSET_EXTS = [
|
|
127
|
+
'.svg',
|
|
128
|
+
'.png',
|
|
129
|
+
'.jpg',
|
|
130
|
+
'.jpeg',
|
|
131
|
+
'.gif',
|
|
132
|
+
'.webp',
|
|
133
|
+
'.avif',
|
|
134
|
+
'.woff',
|
|
135
|
+
'.woff2',
|
|
136
|
+
'.ttf',
|
|
137
|
+
'.otf',
|
|
138
|
+
'.mp4',
|
|
139
|
+
'.webm',
|
|
140
|
+
'.mov',
|
|
141
|
+
'.mp3',
|
|
142
|
+
'.wav',
|
|
143
|
+
'.ogg',
|
|
144
|
+
'.m4a',
|
|
145
|
+
];
|
|
146
|
+
|
|
120
147
|
// ---------- File tree ----------
|
|
121
148
|
|
|
122
149
|
/**
|
|
@@ -181,7 +208,31 @@ async function findFiles(
|
|
|
181
208
|
if (e.isDirectory()) {
|
|
182
209
|
dirsOut?.push(rel);
|
|
183
210
|
out.push(...(await findFiles(full, rel, exts, dirsOut)));
|
|
184
|
-
} else if (exts.some((x) => e.name.toLowerCase().endsWith(x)))
|
|
211
|
+
} else if (e.isFile() && exts.some((x) => e.name.toLowerCase().endsWith(x))) {
|
|
212
|
+
// feature-studio-file-preview security review — `e.isFile()` (not just
|
|
213
|
+
// "not a directory") excludes symlinks: a symlink Dirent is neither
|
|
214
|
+
// isDirectory() nor isFile(), so without this check a symlink dropped
|
|
215
|
+
// into an assets/ folder (e.g. pointing at ~/.ssh/id_rsa, named to fit
|
|
216
|
+
// an allowlisted extension) would be listed and, since this feature
|
|
217
|
+
// makes every listed row one-click-fetchable, served straight into the
|
|
218
|
+
// preview panel.
|
|
219
|
+
//
|
|
220
|
+
// A HARDLINK survives `isFile()` (hardlinks are, by design, ordinary
|
|
221
|
+
// files — same inode, indistinguishable from the "original" at the
|
|
222
|
+
// Dirent level), so it needs a second check: `nlink > 1` means this
|
|
223
|
+
// directory entry shares its inode with at least one other name
|
|
224
|
+
// somewhere on the filesystem. A design system's own assets are never
|
|
225
|
+
// legitimately multiply-linked, so excluding them closes the same
|
|
226
|
+
// one-click-disclosure path for a hardlink planted at, say, a
|
|
227
|
+
// teammate's readable dotfile.
|
|
228
|
+
try {
|
|
229
|
+
const st = await lstat(full);
|
|
230
|
+
if (st.nlink > 1) continue;
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
out.push(rel);
|
|
235
|
+
}
|
|
185
236
|
}
|
|
186
237
|
return out;
|
|
187
238
|
}
|
|
@@ -5268,15 +5319,29 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5268
5319
|
const isDs = g.label === 'Design system' || /^system(\/|$)/.test(g.path);
|
|
5269
5320
|
// Always include canvas sidecars (`.meta.json`, `.css`, `.registry.json`)
|
|
5270
5321
|
// so the client can nest them under their primary `.tsx` / `.html`. DS
|
|
5271
|
-
// groups additionally surface `.md` for README + SKILL docs
|
|
5322
|
+
// groups additionally surface `.md` for README + SKILL docs, plus
|
|
5323
|
+
// PREVIEW_ASSET_EXTS (images/fonts/video/audio) so assets/ subfolders
|
|
5324
|
+
// list their files instead of rendering as permanently-empty tree nodes
|
|
5325
|
+
// (feature-studio-file-preview) — click-time behavior lives client-side
|
|
5326
|
+
// in FileRow's previewKind() branch.
|
|
5272
5327
|
// feature-file-tree-drag-drop-folders (Task 6) — `dirs` accumulates
|
|
5273
5328
|
// every directory in this group (incl. empty ones) via the SAME
|
|
5274
5329
|
// traversal, so a freshly `mkdir`'d folder with no files yet is still
|
|
5275
5330
|
// representable in the tree.
|
|
5276
5331
|
const dirs: string[] = [];
|
|
5277
5332
|
const filePaths = isDs
|
|
5278
|
-
? await findFiles(
|
|
5279
|
-
|
|
5333
|
+
? await findFiles(
|
|
5334
|
+
groupAbs,
|
|
5335
|
+
groupRel,
|
|
5336
|
+
['.tsx', '.html', '.md', '.css', '.json', ...PREVIEW_ASSET_EXTS],
|
|
5337
|
+
dirs
|
|
5338
|
+
)
|
|
5339
|
+
: await findFiles(
|
|
5340
|
+
groupAbs,
|
|
5341
|
+
groupRel,
|
|
5342
|
+
['.tsx', '.html', '.css', '.json', ...PREVIEW_ASSET_EXTS],
|
|
5343
|
+
dirs
|
|
5344
|
+
);
|
|
5280
5345
|
// DDR-093 — record each `.tsx` canvas's design system. canvasUrl() only
|
|
5281
5346
|
// injects tokens for `.tsx`, so skip everything else. Path-owned DS wins
|
|
5282
5347
|
// (system/<ds>/…); otherwise the sidecar's `meta.designSystem`, defaulting
|
package/apps/studio/build.ts
CHANGED
|
@@ -24,7 +24,6 @@ import { browserslistToTargets, bundle as lcssBundle } from 'lightningcss';
|
|
|
24
24
|
import { cloudStubPlugin } from './cloud-build.ts';
|
|
25
25
|
|
|
26
26
|
const ROOT = dirname(fileURLToPath(import.meta.url));
|
|
27
|
-
const DIST = join(ROOT, 'dist');
|
|
28
27
|
|
|
29
28
|
const ARGS = new Set(process.argv.slice(2));
|
|
30
29
|
const FLAG_TARGET = process.argv.find((a) => a.startsWith('--target='))?.slice('--target='.length);
|
|
@@ -33,6 +32,34 @@ const MODE: 'dev' | 'release' | 'dry' = ARGS.has('--dry-run')
|
|
|
33
32
|
: ARGS.has('--release')
|
|
34
33
|
? 'release'
|
|
35
34
|
: 'dev';
|
|
35
|
+
|
|
36
|
+
// DEV-ONLY override, so a test can build somewhere disposable.
|
|
37
|
+
//
|
|
38
|
+
// `test/bundle-smoke.test.ts` shells out to this script to prove the bundle
|
|
39
|
+
// still parses — and, until this existed, wrote its unminified DEV output
|
|
40
|
+
// straight over the COMMITTED release artifacts (14 MB where 2 MB ships). What
|
|
41
|
+
// is committed is what ships, so a suite run silently staged a broken release.
|
|
42
|
+
// CLAUDE.md recorded this as "observed, root cause unconfirmed"; it is this.
|
|
43
|
+
//
|
|
44
|
+
// REFUSED OUTSIDE DEV, and that guard is the whole point of the variable being
|
|
45
|
+
// safe. DIST is the write target for every artifact — client bundle, styles,
|
|
46
|
+
// dist/runtime/*, and the compiled server binary. One environment variable
|
|
47
|
+
// reaching a release runner would send all of them to a scratch directory, exit
|
|
48
|
+
// 0 with normal-looking size lines, and leave packaging to ship the COMMITTED
|
|
49
|
+
// artifacts instead. `check-runtime-bundles.sh` would validate those stale
|
|
50
|
+
// files and pass; `check-client-boots.mjs` would boot them and pass. Every gate
|
|
51
|
+
// green, nothing built actually shipped — a silent downgrade primitive with no
|
|
52
|
+
// diff and no red CI. This repo's doctrine is "what is committed is what ships"
|
|
53
|
+
// AND "verify the built artifact"; an unguarded override severs the two.
|
|
54
|
+
if (process.env.MAUDE_DIST_DIR && MODE !== 'dev') {
|
|
55
|
+
console.error(
|
|
56
|
+
`[build] MAUDE_DIST_DIR is a dev/test-only override and must not be set for a --${MODE} build.\n` +
|
|
57
|
+
` Refusing to build: a release that writes elsewhere ships the previously committed\n` +
|
|
58
|
+
` artifacts while every packaging gate stays green.`
|
|
59
|
+
);
|
|
60
|
+
process.exit(2);
|
|
61
|
+
}
|
|
62
|
+
const DIST = process.env.MAUDE_DIST_DIR || join(ROOT, 'dist');
|
|
36
63
|
const WATCH = ARGS.has('--watch');
|
|
37
64
|
/** Cloud Phase 27 D1 — build the CELL's variant (secret-bearing surfaces
|
|
38
65
|
* removed from the binary, not merely un-routed). `--cloud` with `--release`. */
|
|
@@ -21,6 +21,11 @@ import { GRID_KEYWORD_UNITS, parseTrackList, serializeTrackList } from '../grid-
|
|
|
21
21
|
// canvas-cursors.ts above.
|
|
22
22
|
import { PAPER_PRESETS, resolvePrintArtboard } from '../print/units.ts';
|
|
23
23
|
import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
|
|
24
|
+
// The single "what is the hub link doing" rule, shared with the cloud rail's
|
|
25
|
+
// connect note so the two surfaces can never disagree. Pure data + strings —
|
|
26
|
+
// same "pull only pure logic into the client bundle" shape as the imports
|
|
27
|
+
// above (a type-only SyncStatusSnapshot import that Bun erases).
|
|
28
|
+
import { syncPresentation } from '../sync/presentation.ts';
|
|
24
29
|
import { canvasUrl } from './canvas-url.js';
|
|
25
30
|
import { TreeRowMenu, useRowMenu } from './tree-row-menu.jsx';
|
|
26
31
|
import { useTreeDrag } from './use-tree-drag.js';
|
|
@@ -33,6 +38,7 @@ import OnboardingWizard from './panels/OnboardingWizard.jsx';
|
|
|
33
38
|
import { ReadinessDialog } from './panels/ReadinessList.jsx';
|
|
34
39
|
import IntroVideoDialog from './panels/IntroVideoDialog.jsx';
|
|
35
40
|
import BrandUploadPanel from './panels/BrandUploadPanel.jsx';
|
|
41
|
+
import { FilePreview, sanitizeDisplayText } from './panels/file-preview.jsx';
|
|
36
42
|
import SetupChecklistDialog, { useSetupReadiness } from './panels/SetupChecklist.jsx';
|
|
37
43
|
import TimelinePanel from './panels/TimelinePanel.jsx';
|
|
38
44
|
import { parseCompTimeline } from './panels/timeline-parse.js';
|
|
@@ -77,6 +83,7 @@ const Lu = ({ as: C, size = 14 }) => <C size={size} strokeWidth={1.75} style={{
|
|
|
77
83
|
import { PhotoKnobs } from './photo-knobs.jsx';
|
|
78
84
|
import {
|
|
79
85
|
appIsFirstRun,
|
|
86
|
+
invoke,
|
|
80
87
|
isNativeApp,
|
|
81
88
|
onMenuReportBug,
|
|
82
89
|
onUpdateReady,
|
|
@@ -183,6 +190,23 @@ function DockSlot({ side, width, open, ids, activeId, onPick, children, labels =
|
|
|
183
190
|
);
|
|
184
191
|
}
|
|
185
192
|
const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
|
|
193
|
+
// feature-studio-file-preview — classifies a non-canvas tree row so FileRow
|
|
194
|
+
// can open an inline preview instead of the old inert no-op. Kept in sync
|
|
195
|
+
// with apps/studio/api.ts's PREVIEW_ASSET_EXTS (server won't list anything
|
|
196
|
+
// outside this set anyway, but the client stays explicit rather than
|
|
197
|
+
// assuming server-side filtering).
|
|
198
|
+
const PREVIEW_KIND_RULES = [
|
|
199
|
+
[/\.md$/i, 'markdown'],
|
|
200
|
+
[/\.(css|json|txt|ya?ml)$/i, 'text'],
|
|
201
|
+
[/\.(svg|png|jpe?g|gif|webp|avif)$/i, 'image'],
|
|
202
|
+
[/\.(mp4|webm|mov)$/i, 'video'],
|
|
203
|
+
[/\.(mp3|wav|ogg|m4a)$/i, 'audio'],
|
|
204
|
+
[/\.(woff2?|ttf|otf)$/i, 'font'],
|
|
205
|
+
];
|
|
206
|
+
function previewKind(name) {
|
|
207
|
+
for (const [re, kind] of PREVIEW_KIND_RULES) if (re.test(name)) return kind;
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
186
210
|
// Shared testid-slug derivation (desktop-e2e skill convention: kebab-case,
|
|
187
211
|
// designRoot-stripped, extension-stripped) — mirrors FileRow's inline
|
|
188
212
|
// `canvas-row-<slug>` computation so DirRow / the row-menu trigger use the
|
|
@@ -203,14 +227,30 @@ const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__
|
|
|
203
227
|
// handleAssistantAttention below (identical support/permission check +
|
|
204
228
|
// try/catch, only the title/body differ). The in-app badge stays the
|
|
205
229
|
// reliable signal if this silently fails for any reason.
|
|
230
|
+
//
|
|
231
|
+
// feature-acp-turn-notifications Task 6 — under Tauri, route through the
|
|
232
|
+
// native `send_notification` command (notify.rs) instead of the Web `Notification` API.
|
|
233
|
+
// This is the SAME native notifier the shell's cross-project poller uses for
|
|
234
|
+
// every OTHER project, so the visible project now goes through the identical
|
|
235
|
+
// OS-level path rather than a webview API whose behavior inside WKWebView was
|
|
236
|
+
// never empirically confirmed (see Task 1's finding in the plan). Falls back
|
|
237
|
+
// to the Web API on any failure — an older desktop build without the command,
|
|
238
|
+
// or a plain browser tab — so the signal degrades rather than disappearing.
|
|
206
239
|
function notifyDesktop(title, body) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
240
|
+
const webFallback = () => {
|
|
241
|
+
try {
|
|
242
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
|
243
|
+
new Notification(title, { body });
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
/* best-effort — the in-app badge is the reliable signal */
|
|
210
247
|
}
|
|
211
|
-
}
|
|
212
|
-
|
|
248
|
+
};
|
|
249
|
+
if (isNativeApp()) {
|
|
250
|
+
invoke('send_notification', { title, body }).catch(webFallback);
|
|
251
|
+
return;
|
|
213
252
|
}
|
|
253
|
+
webFallback();
|
|
214
254
|
}
|
|
215
255
|
|
|
216
256
|
function readInitialTheme() {
|
|
@@ -2002,7 +2042,9 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
|
|
|
2002
2042
|
function FileRow({
|
|
2003
2043
|
file,
|
|
2004
2044
|
activePath,
|
|
2045
|
+
previewPath,
|
|
2005
2046
|
onOpen,
|
|
2047
|
+
onPreview,
|
|
2006
2048
|
onDelete,
|
|
2007
2049
|
openCount: oc,
|
|
2008
2050
|
depth,
|
|
@@ -2013,12 +2055,20 @@ function FileRow({
|
|
|
2013
2055
|
drag,
|
|
2014
2056
|
menu,
|
|
2015
2057
|
}) {
|
|
2016
|
-
|
|
2058
|
+
// feature-studio-file-preview (a11y fix) — a previewed row must expose
|
|
2059
|
+
// aria-selected too, or a screen-reader user gets no confirmation their
|
|
2060
|
+
// click did anything (a11y-auditor finding: state never changed).
|
|
2061
|
+
const isSel = file.path === activePath || file.path === previewPath;
|
|
2017
2062
|
const isCanvas = CANVAS_EXT_RE.test(file.name);
|
|
2018
|
-
//
|
|
2019
|
-
//
|
|
2020
|
-
//
|
|
2021
|
-
|
|
2063
|
+
// feature-studio-file-preview — RUNTIME rows (_active.json, _server.json, …)
|
|
2064
|
+
// stay inert no matter their extension: they're gitignored process state,
|
|
2065
|
+
// never user-facing content, and must not become previewable just because
|
|
2066
|
+
// `.json` also matches the `text` preview kind (DDR-115 exclusion).
|
|
2067
|
+
const pKind = !isCanvas && kind !== 'runtime' ? previewKind(file.name) : null;
|
|
2068
|
+
// Non-canvas, non-previewable rows (RUNTIME files, unrecognized extensions)
|
|
2069
|
+
// are display-only — clicking them doesn't open anything; we leave the
|
|
2070
|
+
// click as no-op + cursor hint via `aria-disabled`.
|
|
2071
|
+
const inert = !isCanvas && !pKind;
|
|
2022
2072
|
const label = isCanvas ? displayName(file.name) : file.name;
|
|
2023
2073
|
// Delete only real canvases in a deletable group (onDelete is undefined for the
|
|
2024
2074
|
// DS group + runtime files); the server enforces the rest.
|
|
@@ -2065,7 +2115,8 @@ function FileRow({
|
|
|
2065
2115
|
style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
|
|
2066
2116
|
title={file.path + (oc ? ` — ${oc} open` : inert ? ' (file index only)' : '')}
|
|
2067
2117
|
onClick={() => {
|
|
2068
|
-
if (
|
|
2118
|
+
if (isCanvas) onOpen(file.path);
|
|
2119
|
+
else if (pKind) onPreview?.(file.path);
|
|
2069
2120
|
}}
|
|
2070
2121
|
onContextMenu={
|
|
2071
2122
|
canMove ? (e) => menu.openAt(e, { kind: 'file', path: file.path, dir: fileDir }) : undefined
|
|
@@ -2136,7 +2187,9 @@ function CanvasRow({
|
|
|
2136
2187
|
depth,
|
|
2137
2188
|
kind,
|
|
2138
2189
|
activePath,
|
|
2190
|
+
previewPath,
|
|
2139
2191
|
onOpen,
|
|
2192
|
+
onPreview,
|
|
2140
2193
|
onDelete,
|
|
2141
2194
|
openCount: oc,
|
|
2142
2195
|
showHidden,
|
|
@@ -2160,7 +2213,9 @@ function CanvasRow({
|
|
|
2160
2213
|
<FileRow
|
|
2161
2214
|
file={primary}
|
|
2162
2215
|
activePath={activePath}
|
|
2216
|
+
previewPath={previewPath}
|
|
2163
2217
|
onOpen={onOpen}
|
|
2218
|
+
onPreview={onPreview}
|
|
2164
2219
|
onDelete={onDelete}
|
|
2165
2220
|
openCount={oc}
|
|
2166
2221
|
depth={depth}
|
|
@@ -2260,7 +2315,9 @@ function CanvasRow({
|
|
|
2260
2315
|
key={sc.path}
|
|
2261
2316
|
file={sc}
|
|
2262
2317
|
activePath={activePath}
|
|
2318
|
+
previewPath={previewPath}
|
|
2263
2319
|
onOpen={onOpen}
|
|
2320
|
+
onPreview={onPreview}
|
|
2264
2321
|
openCount={0}
|
|
2265
2322
|
depth={depth + 1}
|
|
2266
2323
|
kind={kind}
|
|
@@ -2274,7 +2331,9 @@ function CanvasRow({
|
|
|
2274
2331
|
function Tree({
|
|
2275
2332
|
node,
|
|
2276
2333
|
activePath,
|
|
2334
|
+
previewPath,
|
|
2277
2335
|
onOpen,
|
|
2336
|
+
onPreview,
|
|
2278
2337
|
commentsByFile,
|
|
2279
2338
|
depth = 1,
|
|
2280
2339
|
kind,
|
|
@@ -2302,8 +2361,20 @@ function Tree({
|
|
|
2302
2361
|
const files = node._files || [];
|
|
2303
2362
|
// VS Code-style sidecar grouping. Canvas (`.tsx`/`.html`) becomes the primary
|
|
2304
2363
|
// row; same-basename non-canvas files (`.meta.json`, `.css`, …) collapse
|
|
2305
|
-
// under it.
|
|
2364
|
+
// under it. Meta/doc orphans (README.md, tokens.css, …) surface only when
|
|
2365
|
+
// `showHidden` is on; media/asset orphans (images, fonts, video, audio —
|
|
2366
|
+
// feature-studio-file-preview) are content the user asked to see by
|
|
2367
|
+
// default, not sidecar noise, so they always render.
|
|
2306
2368
|
const { canvases, orphans } = useMemo(() => groupBySidecar(files), [files]);
|
|
2369
|
+
const { mediaOrphans, metaOrphans } = useMemo(() => {
|
|
2370
|
+
const media = [];
|
|
2371
|
+
const meta = [];
|
|
2372
|
+
for (const entry of orphans) {
|
|
2373
|
+
const k = previewKind(entry.primary.name);
|
|
2374
|
+
(k === 'image' || k === 'video' || k === 'audio' || k === 'font' ? media : meta).push(entry);
|
|
2375
|
+
}
|
|
2376
|
+
return { mediaOrphans: media, metaOrphans: meta };
|
|
2377
|
+
}, [orphans]);
|
|
2307
2378
|
const hasSearch = !!(search && search.trim());
|
|
2308
2379
|
// DS-folder lookup: only meaningful at the top level of a DS group. The
|
|
2309
2380
|
// server emits `dsFolders: [{name, folder}, ...]` so the client knows which
|
|
@@ -2329,7 +2400,9 @@ function Tree({
|
|
|
2329
2400
|
primary={entry.primary}
|
|
2330
2401
|
sidecars={entry.sidecars}
|
|
2331
2402
|
activePath={activePath}
|
|
2403
|
+
previewPath={previewPath}
|
|
2332
2404
|
onOpen={onOpen}
|
|
2405
|
+
onPreview={onPreview}
|
|
2333
2406
|
onDelete={onDelete}
|
|
2334
2407
|
openCount={openCount(commentsByFile[entry.primary.path])}
|
|
2335
2408
|
depth={depth}
|
|
@@ -2343,13 +2416,28 @@ function Tree({
|
|
|
2343
2416
|
/>
|
|
2344
2417
|
);
|
|
2345
2418
|
})}
|
|
2419
|
+
{mediaOrphans.map((entry) => (
|
|
2420
|
+
<FileRow
|
|
2421
|
+
key={entry.primary.path}
|
|
2422
|
+
file={entry.primary}
|
|
2423
|
+
activePath={activePath}
|
|
2424
|
+
previewPath={previewPath}
|
|
2425
|
+
onOpen={onOpen}
|
|
2426
|
+
onPreview={onPreview}
|
|
2427
|
+
openCount={openCount(commentsByFile[entry.primary.path])}
|
|
2428
|
+
depth={depth}
|
|
2429
|
+
kind={kind}
|
|
2430
|
+
/>
|
|
2431
|
+
))}
|
|
2346
2432
|
{showHidden &&
|
|
2347
|
-
|
|
2433
|
+
metaOrphans.map((entry) => (
|
|
2348
2434
|
<FileRow
|
|
2349
2435
|
key={entry.primary.path}
|
|
2350
2436
|
file={entry.primary}
|
|
2351
2437
|
activePath={activePath}
|
|
2438
|
+
previewPath={previewPath}
|
|
2352
2439
|
onOpen={onOpen}
|
|
2440
|
+
onPreview={onPreview}
|
|
2353
2441
|
openCount={openCount(commentsByFile[entry.primary.path])}
|
|
2354
2442
|
depth={depth}
|
|
2355
2443
|
kind={kind}
|
|
@@ -2363,7 +2451,9 @@ function Tree({
|
|
|
2363
2451
|
<Tree
|
|
2364
2452
|
node={node[d]}
|
|
2365
2453
|
activePath={activePath}
|
|
2454
|
+
previewPath={previewPath}
|
|
2366
2455
|
onOpen={onOpen}
|
|
2456
|
+
onPreview={onPreview}
|
|
2367
2457
|
commentsByFile={commentsByFile}
|
|
2368
2458
|
depth={depth + 1}
|
|
2369
2459
|
kind={kind}
|
|
@@ -2446,8 +2536,10 @@ function Sidebar({
|
|
|
2446
2536
|
cloud,
|
|
2447
2537
|
groups,
|
|
2448
2538
|
activePath,
|
|
2539
|
+
previewPath,
|
|
2449
2540
|
activeDsName,
|
|
2450
2541
|
onOpen,
|
|
2542
|
+
onPreview,
|
|
2451
2543
|
onOpenSystem,
|
|
2452
2544
|
wsConnected,
|
|
2453
2545
|
search,
|
|
@@ -2473,6 +2565,9 @@ function Sidebar({
|
|
|
2473
2565
|
onMoveCanvas,
|
|
2474
2566
|
onNewFolder,
|
|
2475
2567
|
onDeleteFolder,
|
|
2568
|
+
// Passed through to CloudBar only — the live `sync:status` payload that makes
|
|
2569
|
+
// the connect note follow the link instead of freezing at attach time.
|
|
2570
|
+
syncStatus,
|
|
2476
2571
|
}) {
|
|
2477
2572
|
const filteredGroups = useMemo(() => {
|
|
2478
2573
|
if (!search) return groups;
|
|
@@ -2804,7 +2899,9 @@ function Sidebar({
|
|
|
2804
2899
|
<Tree
|
|
2805
2900
|
node={g.tree}
|
|
2806
2901
|
activePath={activePath}
|
|
2902
|
+
previewPath={previewPath}
|
|
2807
2903
|
onOpen={onOpen}
|
|
2904
|
+
onPreview={onPreview}
|
|
2808
2905
|
commentsByFile={commentsByFile}
|
|
2809
2906
|
depth={1}
|
|
2810
2907
|
kind={g.kind}
|
|
@@ -2846,7 +2943,7 @@ function Sidebar({
|
|
|
2846
2943
|
Absent rather than disabled, because unlike the agent chat there is
|
|
2847
2944
|
nothing here to explain — the capability is not missing, it is
|
|
2848
2945
|
already satisfied. */}
|
|
2849
|
-
{cloud === null ? <CloudBar /> : null}
|
|
2946
|
+
{cloud === null ? <CloudBar syncStatus={syncStatus} /> : null}
|
|
2850
2947
|
{/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
|
|
2851
2948
|
sign in, connected account + New/Pull/Share, sign out. Self-contained
|
|
2852
2949
|
(owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
|
|
@@ -4180,10 +4277,38 @@ function Viewport({
|
|
|
4180
4277
|
onIframeLoad,
|
|
4181
4278
|
showQuickSetup,
|
|
4182
4279
|
onStartQuickSetup,
|
|
4280
|
+
previewPath,
|
|
4183
4281
|
}) {
|
|
4282
|
+
// feature-studio-file-preview (a11y fix) — a11y-auditor found the preview
|
|
4283
|
+
// overlay gave keyboard/AT users no signal anything happened: the click
|
|
4284
|
+
// stayed on the (unrelated) tree row, nothing was announced, and the
|
|
4285
|
+
// scrollable region itself wasn't in the tab order. Moving focus onto the
|
|
4286
|
+
// region on open covers all three: it's a real focus change (AT announces
|
|
4287
|
+
// the new `role="region"` + its label), and a focused element with
|
|
4288
|
+
// `overflow: auto` is keyboard-scrollable (arrow/Page keys) without a
|
|
4289
|
+
// separate tabIndex fix.
|
|
4290
|
+
const previewRef = useRef(null);
|
|
4291
|
+
useEffect(() => {
|
|
4292
|
+
if (previewPath) previewRef.current?.focus();
|
|
4293
|
+
}, [previewPath]);
|
|
4184
4294
|
return (
|
|
4185
4295
|
<div className="viewport st-stage" data-tour="viewport">
|
|
4186
|
-
{
|
|
4296
|
+
{previewPath && (
|
|
4297
|
+
// feature-studio-file-preview — an overlay, not a tab: the canvas
|
|
4298
|
+
// iframe (if any) stays mounted underneath so switching back to it
|
|
4299
|
+
// is instant and never remounts. previewPath is intentionally never
|
|
4300
|
+
// written into `tabs`/`activePath` (see App()'s onPreview).
|
|
4301
|
+
<div
|
|
4302
|
+
ref={previewRef}
|
|
4303
|
+
className="st-file-preview-overlay"
|
|
4304
|
+
role="region"
|
|
4305
|
+
aria-label={`Preview: ${sanitizeDisplayText(basename(previewPath))}`}
|
|
4306
|
+
tabIndex={0}
|
|
4307
|
+
>
|
|
4308
|
+
<FilePreview path={previewPath} kind={previewKind(basename(previewPath))} />
|
|
4309
|
+
</div>
|
|
4310
|
+
)}
|
|
4311
|
+
{tabs.length === 0 && !previewPath && (
|
|
4187
4312
|
<div className="st-empty">
|
|
4188
4313
|
<div className="st-empty-brand">
|
|
4189
4314
|
<span className="st-brand-mark">
|
|
@@ -4537,10 +4662,12 @@ function StatusBar({
|
|
|
4537
4662
|
onToggleTheme,
|
|
4538
4663
|
onClearSelected,
|
|
4539
4664
|
syncStatus,
|
|
4665
|
+
syncProject,
|
|
4540
4666
|
changesCount = 0,
|
|
4541
4667
|
unpushed = 0,
|
|
4542
4668
|
changesOpen = false,
|
|
4543
4669
|
onOpenChanges,
|
|
4670
|
+
version,
|
|
4544
4671
|
}) {
|
|
4545
4672
|
const isSystem = activePath === SYSTEM_TAB;
|
|
4546
4673
|
const text =
|
|
@@ -4559,32 +4686,21 @@ function StatusBar({
|
|
|
4559
4686
|
// shapes: solo `{linked:false}` (hide), DDR-060 `{notSyncable,tsxCount,reason}`
|
|
4560
4687
|
// (linked but 0 syncable), or the connection-state machine `{state,queuedOps,
|
|
4561
4688
|
// flash,…}` (the common linked case the old notSyncable-only guard never
|
|
4562
|
-
// showed — the "hub sync se neukazuje" bug).
|
|
4689
|
+
// showed — the "hub sync se neukazuje" bug).
|
|
4690
|
+
//
|
|
4691
|
+
// The mapping used to live here, and it referenced `docs` ZERO times: it read
|
|
4692
|
+
// `state` alone, so a link whose every document the hub had refused still
|
|
4693
|
+
// showed a green dot and the word "synced". `syncPresentation` is now the one
|
|
4694
|
+
// rule, shared with the cloud rail's connect note, so the two surfaces cannot
|
|
4695
|
+
// give a person different answers about the same payload.
|
|
4563
4696
|
const syncSlot = (() => {
|
|
4564
|
-
|
|
4565
|
-
if (
|
|
4566
|
-
|
|
4567
|
-
online: false,
|
|
4568
|
-
label: `0 syncable${syncStatus.tsxCount > 0 ? ` · ${syncStatus.tsxCount} tsx` : ''}`,
|
|
4569
|
-
title: syncStatus.reason || 'Linked to a hub, but no canvases are syncable.',
|
|
4570
|
-
};
|
|
4571
|
-
}
|
|
4572
|
-
const q = syncStatus.queuedOps ?? 0;
|
|
4573
|
-
const synced = syncStatus.state === 'online' || syncStatus.flash === 'synced';
|
|
4574
|
-
if (synced) {
|
|
4575
|
-
return {
|
|
4576
|
-
online: true,
|
|
4577
|
-
label: q > 0 ? `${q} ↑` : 'synced',
|
|
4578
|
-
title: q > 0 ? `${q} edit(s) queued to push` : 'All changes synced to the hub',
|
|
4579
|
-
};
|
|
4580
|
-
}
|
|
4697
|
+
const p = syncPresentation(syncStatus, { project: syncProject });
|
|
4698
|
+
if (!p) return null;
|
|
4699
|
+
const detail = p.names.length ? ` (${p.names.join(', ')})` : '';
|
|
4581
4700
|
return {
|
|
4582
|
-
online:
|
|
4583
|
-
label:
|
|
4584
|
-
title:
|
|
4585
|
-
syncStatus.state === 'connecting'
|
|
4586
|
-
? 'Connecting to the hub…'
|
|
4587
|
-
: 'Offline — edits queued, will sync when the hub reconnects',
|
|
4701
|
+
online: p.online,
|
|
4702
|
+
label: p.label,
|
|
4703
|
+
title: `${p.title}${detail}${p.next ? ` — ${p.next}` : ''}`,
|
|
4588
4704
|
};
|
|
4589
4705
|
})();
|
|
4590
4706
|
|
|
@@ -4675,6 +4791,19 @@ function StatusBar({
|
|
|
4675
4791
|
</span>
|
|
4676
4792
|
)}
|
|
4677
4793
|
|
|
4794
|
+
{/* Which release this is. Reading it used to mean probing production by
|
|
4795
|
+
hand — and the desktop, the browser and a cloud tab all serve the same
|
|
4796
|
+
client, so one slot covers all three. Absent on an older server that
|
|
4797
|
+
omits `version` from /_config, exactly as `cloud` and `canvasToken`
|
|
4798
|
+
already are. */}
|
|
4799
|
+
{version && (
|
|
4800
|
+
<span className="st-sb-slot st-sb-version" role="group" aria-label="Maude version">
|
|
4801
|
+
<span className="val" data-testid="statusbar-version">
|
|
4802
|
+
v{version}
|
|
4803
|
+
</span>
|
|
4804
|
+
</span>
|
|
4805
|
+
)}
|
|
4806
|
+
|
|
4678
4807
|
<button
|
|
4679
4808
|
type="button"
|
|
4680
4809
|
className="st-sb-theme"
|
|
@@ -9161,6 +9290,14 @@ function App() {
|
|
|
9161
9290
|
const [project, setProject] = useState('Design');
|
|
9162
9291
|
const [tabs, setTabs] = useState([]);
|
|
9163
9292
|
const [activePath, setActivePath] = useState(null);
|
|
9293
|
+
// feature-studio-file-preview — deliberately separate from `tabs`/
|
|
9294
|
+
// `activePath`: a previewed file was never a canvas, so it must never
|
|
9295
|
+
// trigger the WS `active`/`tabs` broadcasts, the compile-skeleton loading
|
|
9296
|
+
// state, or iframe registration that openTab()/openSystem() drive.
|
|
9297
|
+
const [previewPath, setPreviewPath] = useState(null);
|
|
9298
|
+
const onPreview = useCallback((path) => {
|
|
9299
|
+
setPreviewPath(path);
|
|
9300
|
+
}, []);
|
|
9164
9301
|
const [selected, setSelected] = useState(null);
|
|
9165
9302
|
// Phase 12.3 — latest selection, readable from the (stale-closure) onMessage
|
|
9166
9303
|
// handler so an HMR reload (triggered by a CSS/attr edit) can re-select the
|
|
@@ -9431,6 +9568,10 @@ function App() {
|
|
|
9431
9568
|
// both of these went missing and the cloud studio rendered without
|
|
9432
9569
|
// its chrome and with every canvas iframe unauthenticated.
|
|
9433
9570
|
canvasToken: data.canvasToken,
|
|
9571
|
+
// Which release this server is — rendered as the status-bar chip.
|
|
9572
|
+
// Undefined on a server too old to report it, which is why the chip
|
|
9573
|
+
// renders conditionally rather than printing `vundefined`.
|
|
9574
|
+
version: data.version,
|
|
9434
9575
|
// C2/C4 — `{ dashboardUrl, projectName, user, role }` when this is a
|
|
9435
9576
|
// cloud tab. Its presence is what tells the shared client it is in a
|
|
9436
9577
|
// browser tab on somebody else's machine; `user`/`role` are what let
|
|
@@ -11155,6 +11296,7 @@ function App() {
|
|
|
11155
11296
|
});
|
|
11156
11297
|
setActivePath(path);
|
|
11157
11298
|
setFocusedCommentId(null);
|
|
11299
|
+
setPreviewPath(null);
|
|
11158
11300
|
// Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
|
|
11159
11301
|
// the onLoad fallback timer (legacy .html), or a hard 15s cap.
|
|
11160
11302
|
if (path !== SYSTEM_TAB) setLoadingPath(path);
|
|
@@ -14061,8 +14203,10 @@ function App() {
|
|
|
14061
14203
|
readOnly={viewerMode}
|
|
14062
14204
|
groups={groups}
|
|
14063
14205
|
activePath={activePath}
|
|
14206
|
+
previewPath={previewPath}
|
|
14064
14207
|
activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
|
|
14065
14208
|
onOpen={openTab}
|
|
14209
|
+
onPreview={onPreview}
|
|
14066
14210
|
onOpenSystem={openSystem}
|
|
14067
14211
|
wsConnected={wsConnected}
|
|
14068
14212
|
search={search}
|
|
@@ -14087,6 +14231,7 @@ function App() {
|
|
|
14087
14231
|
remoteSync={remoteSync}
|
|
14088
14232
|
onGetLatest={gitGetLatest}
|
|
14089
14233
|
canvasKinds={cfg?.canvasKinds}
|
|
14234
|
+
syncStatus={syncStatus}
|
|
14090
14235
|
/>
|
|
14091
14236
|
);
|
|
14092
14237
|
if (id === 'changes')
|
|
@@ -14403,6 +14548,7 @@ function App() {
|
|
|
14403
14548
|
isNativeApp() && !viewerMode && !!setupReadiness && !setupReadiness.ready
|
|
14404
14549
|
}
|
|
14405
14550
|
onStartQuickSetup={() => setQuickSetupOpen(true)}
|
|
14551
|
+
previewPath={previewPath}
|
|
14406
14552
|
/>
|
|
14407
14553
|
</div>
|
|
14408
14554
|
{rightActive && (
|
|
@@ -15147,10 +15293,12 @@ function App() {
|
|
|
15147
15293
|
onToggleTheme={toggleTheme}
|
|
15148
15294
|
onClearSelected={clearSelected}
|
|
15149
15295
|
syncStatus={syncStatus}
|
|
15296
|
+
syncProject={cfg?.cloud?.projectName || project}
|
|
15150
15297
|
changesCount={unsavedCount}
|
|
15151
15298
|
unpushed={gitStatus?.unpushed || 0}
|
|
15152
15299
|
changesOpen={changesOpen}
|
|
15153
15300
|
onOpenChanges={gitStatus?.repo ? () => setChangesOpen(true) : undefined}
|
|
15301
|
+
version={cfg?.version}
|
|
15154
15302
|
/>
|
|
15155
15303
|
</div>
|
|
15156
15304
|
{presentMode && (
|
|
@@ -1855,6 +1855,11 @@ export default function ChatPanel({
|
|
|
1855
1855
|
const connsRef = useRef(new Map()); // chatId → connection
|
|
1856
1856
|
const busyRef = useRef(new Map()); // chatId → busy (aggregated for the menubar)
|
|
1857
1857
|
const hydratedRef = useRef(new Map()); // chatId → initial messages
|
|
1858
|
+
// chatId → the transcript LINE the hydration above covered (the
|
|
1859
|
+
// `X-Maude-Chat-Seq` response header). Handed to the connection's `attach`
|
|
1860
|
+
// so the server replays only what the client missed — Addendum Task 8's
|
|
1861
|
+
// re-attach seam. Absent/0 for a chat that was never hydrated (a new chat).
|
|
1862
|
+
const hydratedSeqRef = useRef(new Map());
|
|
1858
1863
|
const [openChatIds, setOpenChatIds] = useState([]);
|
|
1859
1864
|
const [activeChatId, setActiveChatId] = useState(null);
|
|
1860
1865
|
const [busyChats, setBusyChats] = useState({}); // reactive: chatId → busy (for the dot)
|
|
@@ -1872,6 +1877,13 @@ export default function ChatPanel({
|
|
|
1872
1877
|
const existing = connsRef.current.get(chatId);
|
|
1873
1878
|
if (existing) return existing;
|
|
1874
1879
|
const conn = createAcpConnection();
|
|
1880
|
+
// Addendum Task 8 — bind BEFORE anything can open the socket. The `attach`
|
|
1881
|
+
// frame goes out from `onopen` and is what subscribes this socket to the
|
|
1882
|
+
// chat's server-side bridge (which now outlives its socket, so it may
|
|
1883
|
+
// already be mid-turn from before a reload). `hydratedSeqRef` holds the
|
|
1884
|
+
// transcript line the history fetch covered, so the server replays exactly
|
|
1885
|
+
// the gap — no duplicated tail, no hole.
|
|
1886
|
+
conn.bindChat(chatId, hydratedSeqRef.current.get(chatId) || 0);
|
|
1875
1887
|
connsRef.current.set(chatId, conn);
|
|
1876
1888
|
// Agent-generated title (session_info_update — fires at turn-end, so a
|
|
1877
1889
|
// fresh chat shows the client's "New chat" heuristic until the first
|
|
@@ -1949,7 +1961,12 @@ export default function ChatPanel({
|
|
|
1949
1961
|
return;
|
|
1950
1962
|
}
|
|
1951
1963
|
fetch(`/_api/acp/chat?id=${encodeURIComponent(id)}`)
|
|
1952
|
-
.then((r) =>
|
|
1964
|
+
.then(async (r) => {
|
|
1965
|
+
// Capture the seam marker alongside the body — see hydratedSeqRef.
|
|
1966
|
+
const seq = Number(r.headers.get('X-Maude-Chat-Seq') || 0);
|
|
1967
|
+
hydratedSeqRef.current.set(id, Number.isFinite(seq) ? seq : 0);
|
|
1968
|
+
return r.json();
|
|
1969
|
+
})
|
|
1953
1970
|
.then((msgs) => openChat(id, toThreadMessages(msgs)))
|
|
1954
1971
|
.catch(() => {});
|
|
1955
1972
|
},
|
|
@@ -1965,6 +1982,7 @@ export default function ChatPanel({
|
|
|
1965
1982
|
connsRef.current.delete(id);
|
|
1966
1983
|
busyRef.current.delete(id);
|
|
1967
1984
|
hydratedRef.current.delete(id);
|
|
1985
|
+
hydratedSeqRef.current.delete(id);
|
|
1968
1986
|
setBusyChats((prev) => {
|
|
1969
1987
|
const next = { ...prev };
|
|
1970
1988
|
delete next[id];
|