@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.
Files changed (58) hide show
  1. package/README.md +5 -1
  2. package/apps/studio/acp/bridge.ts +385 -26
  3. package/apps/studio/acp/index.ts +498 -102
  4. package/apps/studio/acp/running.ts +97 -0
  5. package/apps/studio/acp/transcript.ts +64 -0
  6. package/apps/studio/acp/write-scope.ts +459 -0
  7. package/apps/studio/api.ts +69 -4
  8. package/apps/studio/build.ts +28 -1
  9. package/apps/studio/client/app.jsx +187 -39
  10. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  11. package/apps/studio/client/panels/CloudBar.jsx +72 -13
  12. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  13. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  14. package/apps/studio/client/panels/acp-runtime.js +96 -5
  15. package/apps/studio/client/panels/chat-markdown.jsx +76 -5
  16. package/apps/studio/client/panels/file-preview.jsx +115 -0
  17. package/apps/studio/client/styles/3-shell-maude.css +56 -2
  18. package/apps/studio/client/styles/6-acp-chat.css +79 -0
  19. package/apps/studio/dist/client.bundle.js +1436 -1436
  20. package/apps/studio/dist/comment-mount.js +2 -2
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/http.ts +59 -2
  23. package/apps/studio/input-router.tsx +39 -1
  24. package/apps/studio/server.ts +11 -0
  25. package/apps/studio/sync/connection-state.ts +116 -6
  26. package/apps/studio/sync/index.ts +50 -1
  27. package/apps/studio/sync/presentation.ts +273 -0
  28. package/apps/studio/sync/remote-docs.ts +191 -0
  29. package/apps/studio/sync/status.ts +4 -1
  30. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  31. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  32. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  33. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  34. package/apps/studio/test/acp-commands.test.ts +5 -0
  35. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  36. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  37. package/apps/studio/test/acp-permission.test.ts +18 -2
  38. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  39. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  40. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  41. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  42. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  43. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  44. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  45. package/apps/studio/test/cloud-endpoints.test.ts +11 -4
  46. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  47. package/apps/studio/test/config-version.test.ts +88 -0
  48. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  49. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  50. package/apps/studio/test/input-router.test.ts +56 -0
  51. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  52. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  53. package/apps/studio/test/sync-presentation.test.ts +185 -0
  54. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  55. package/apps/studio/test/whats-new.test.ts +13 -1
  56. package/apps/studio/whats-new.json +27 -0
  57. package/apps/studio/whats-new.ts +34 -10
  58. package/package.json +8 -8
@@ -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))) out.push(rel);
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(groupAbs, groupRel, ['.tsx', '.html', '.md', '.css', '.json'], dirs)
5279
- : await findFiles(groupAbs, groupRel, ['.tsx', '.html', '.css', '.json'], dirs);
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
@@ -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
- try {
208
- if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
209
- new Notification(title, { body });
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
- } catch {
212
- /* best-effort — the in-app badge is the reliable signal */
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
- const isSel = file.path === activePath;
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
- // Non-canvas rows (PROJECT *.md, RUNTIME _active.json, ...) are display-only —
2019
- // clicking them doesn't open an iframe; we leave the click as no-op + cursor
2020
- // hint via `aria-disabled`.
2021
- const inert = !isCanvas;
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 (!inert) onOpen(file.path);
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. Orphans surface only when `showHidden` is on.
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
- orphans.map((entry) => (
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
- {tabs.length === 0 && (
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). Map each to a label + dot tone.
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
- if (!syncStatus || syncStatus.linked === false) return null;
4565
- if (syncStatus.notSyncable) {
4566
- return {
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: false,
4583
- label: `${q} ↑`,
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) => r.json())
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];