@1agh/maude 0.55.0 → 0.56.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 (47) hide show
  1. package/apps/studio/bin/_smart-frames.mjs +187 -20
  2. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  3. package/apps/studio/bin/_transcribe.mjs +40 -3
  4. package/apps/studio/bin/smoke.sh +7 -1
  5. package/apps/studio/client/app.jsx +31 -3
  6. package/apps/studio/client/panels/CloudBar.jsx +44 -5
  7. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  8. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  9. package/apps/studio/client/styles/4-components.css +72 -0
  10. package/apps/studio/cloud/endpoints.ts +56 -3
  11. package/apps/studio/collab/persistence.ts +29 -2
  12. package/apps/studio/config.schema.json +3 -3
  13. package/apps/studio/context.ts +41 -0
  14. package/apps/studio/dist/client.bundle.js +1464 -1464
  15. package/apps/studio/dist/comment-mount.js +2 -2
  16. package/apps/studio/dist/styles.css +1 -1
  17. package/apps/studio/generation/gemma-models.ts +312 -14
  18. package/apps/studio/generation/prefs.ts +7 -2
  19. package/apps/studio/generation/runtime-probe.ts +50 -0
  20. package/apps/studio/generation/whisper-models.ts +124 -0
  21. package/apps/studio/hmr-broadcast.ts +67 -0
  22. package/apps/studio/http.ts +196 -109
  23. package/apps/studio/input-router.tsx +55 -2
  24. package/apps/studio/server.ts +11 -9
  25. package/apps/studio/sync/autocommit.ts +61 -2
  26. package/apps/studio/sync/cell-pairing.ts +174 -0
  27. package/apps/studio/sync/codec.ts +11 -5
  28. package/apps/studio/sync/index.ts +239 -26
  29. package/apps/studio/sync/limits.ts +49 -0
  30. package/apps/studio/sync/loopback.ts +21 -0
  31. package/apps/studio/sync/projection.ts +47 -12
  32. package/apps/studio/sync/supervisor.ts +178 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +95 -0
  34. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  35. package/apps/studio/test/gemma-models.test.ts +245 -0
  36. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  37. package/apps/studio/test/input-router.test.ts +95 -0
  38. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  39. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  40. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  41. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  42. package/apps/studio/test/whisper-setup.test.ts +97 -0
  43. package/apps/studio/whats-new.json +27 -0
  44. package/apps/studio/ws.ts +9 -1
  45. package/cli/commands/kg.mjs +9 -2
  46. package/package.json +8 -8
  47. package/plugins/design/dependencies.json +21 -3
@@ -155,8 +155,19 @@ export default function GitPanel({
155
155
  // checkboxes) — the developer commits and pushes from their terminal
156
156
  // (DDR-119). Native app keeps the full plain-words cycle.
157
157
  readOnly = false,
158
+ // THE SERVER ALREADY COMMITTED IT. In a cloud cell the hub owns this
159
+ // project's history and commits every edit as it lands (the studio child's
160
+ // own autocommit is disabled precisely so there is one committer) — so an
161
+ // "unsaved changes" list is not a to-do, it is a lie about work that is
162
+ // already saved, and Save / Publish offer to do a thing that has happened.
163
+ // The read-only variant of this panel used to answer that with "save and
164
+ // publish from your terminal", which is advice a browser user cannot take.
165
+ //
166
+ // History STAYS, and is the point: what the server committed is exactly what
167
+ // someone wants to look back at. Only the working-tree half goes.
168
+ historyOnly = false,
158
169
  }) {
159
- const [tab, setTab] = useState('changes');
170
+ const [tab, setTab] = useState(historyOnly ? 'history' : 'changes');
160
171
  const [message, setMessage] = useState('');
161
172
  const [unchecked, setUnchecked] = useState(() => new Set()); // default = all checked
162
173
  const [expanded, setExpanded] = useState(() => new Set()); // unit keys with supporting files shown
@@ -443,13 +454,13 @@ export default function GitPanel({
443
454
  <aside
444
455
  className={'st-rpanel gp-panel' + (resizing ? ' is-resizing' : '')}
445
456
  style={width ? { width, flexBasis: width } : undefined}
446
- aria-label="Changes"
457
+ aria-label={historyOnly ? 'History' : 'Changes'}
447
458
  data-testid="git-panel"
448
459
  >
449
460
  <div className="gp-head">
450
461
  <div className="gp-panel-hd">
451
- <span className="gp-panel-title">Changes</span>
452
- {count > 0 && <span className="gp-count">{count} unsaved</span>}
462
+ <span className="gp-panel-title">{historyOnly ? 'History' : 'Changes'}</span>
463
+ {!historyOnly && count > 0 && <span className="gp-count">{count} unsaved</span>}
453
464
  <span className="gp-spacer" />
454
465
  <span className="gp-draft" title="Your project and shared draft">
455
466
  <Icon name="folder" size={12} />
@@ -465,26 +476,28 @@ export default function GitPanel({
465
476
  ×
466
477
  </button>
467
478
  </div>
468
- <div className="gp-tabs" role="tablist" aria-label="Changes and history">
469
- <button
470
- type="button"
471
- role="tab"
472
- aria-selected={tab === 'changes'}
473
- className={'gp-tab' + (tab === 'changes' ? ' is-active' : '')}
474
- onClick={() => setTab('changes')}
475
- >
476
- Changes
477
- </button>
478
- <button
479
- type="button"
480
- role="tab"
481
- aria-selected={tab === 'history'}
482
- className={'gp-tab' + (tab === 'history' ? ' is-active' : '')}
483
- onClick={openHistory}
484
- >
485
- History
486
- </button>
487
- </div>
479
+ {!historyOnly && (
480
+ <div className="gp-tabs" role="tablist" aria-label="Changes and history">
481
+ <button
482
+ type="button"
483
+ role="tab"
484
+ aria-selected={tab === 'changes'}
485
+ className={'gp-tab' + (tab === 'changes' ? ' is-active' : '')}
486
+ onClick={() => setTab('changes')}
487
+ >
488
+ Changes
489
+ </button>
490
+ <button
491
+ type="button"
492
+ role="tab"
493
+ aria-selected={tab === 'history'}
494
+ className={'gp-tab' + (tab === 'history' ? ' is-active' : '')}
495
+ onClick={openHistory}
496
+ >
497
+ History
498
+ </button>
499
+ </div>
500
+ )}
488
501
  </div>
489
502
 
490
503
  {banner && (
@@ -530,7 +543,7 @@ export default function GitPanel({
530
543
  </div>
531
544
  )}
532
545
 
533
- {tab === 'changes' ? (
546
+ {tab === 'changes' && !historyOnly ? (
534
547
  notRepo ? (
535
548
  <div className="gp-empty">
536
549
  <span className="gp-empty-glyph">
@@ -170,6 +170,11 @@ function ProviderCard({ provider, onChanged }) {
170
170
  // default. Local whisper is free/offline/no-key; the cloud engines need a key
171
171
  // (managed in the provider cards above).
172
172
  const TRANSCRIPTION_ENGINES = [
173
+ {
174
+ id: 'auto',
175
+ label: 'Auto',
176
+ note: 'Use the best engine this machine is set up for — the card says which.',
177
+ },
173
178
  {
174
179
  id: 'whisper',
175
180
  label: 'Local whisper.cpp',
@@ -183,10 +188,15 @@ const TRANSCRIPTION_ENGINES = [
183
188
  { id: 'groq', label: 'Groq Whisper', note: 'Cloud · fast · needs a Groq key' },
184
189
  ];
185
190
 
191
+ const ENGINE_LABELS = Object.fromEntries(TRANSCRIPTION_ENGINES.map((e) => [e.id, e.label]));
192
+
186
193
  function TranscriptionEngineCard() {
187
194
  const [engine, setEngine] = useState(null); // null = loading
188
195
  const [busy, setBusy] = useState(false);
189
196
  const [status, setStatus] = useState(null);
197
+ // What `auto` currently resolves to, from the server (key presence lives in
198
+ // the keychain). Shown verbatim so a delegated choice is still a visible one.
199
+ const [auto, setAuto] = useState(null);
190
200
 
191
201
  useEffect(() => {
192
202
  fetch('/_api/generate/prefs')
@@ -195,6 +205,15 @@ function TranscriptionEngineCard() {
195
205
  .catch(() => setEngine('whisper'));
196
206
  }, []);
197
207
 
208
+ // Re-read on every selection change: adding a key elsewhere in Settings moves
209
+ // what Auto resolves to, and the user should see that immediately.
210
+ useEffect(() => {
211
+ fetch('/_api/generate/whisper-model')
212
+ .then((r) => (r.ok ? r.json() : null))
213
+ .then((d) => setAuto(d?.auto || null))
214
+ .catch(() => {});
215
+ }, []);
216
+
198
217
  async function choose(id) {
199
218
  if (id === engine) return;
200
219
  const prev = engine;
@@ -223,9 +242,16 @@ function TranscriptionEngineCard() {
223
242
  <span className="st-provider-name">Subtitles — transcription engine</span>
224
243
  </div>
225
244
  <div className="st-provider-notes">
226
- Which engine turns audio into subtitles. This is an explicit choice — Maude never silently
227
- switches engines; a chosen-but-unavailable engine tells you how to fix it.
245
+ Which engine turns audio into subtitles. Maude never switches engines behind your back — pin
246
+ one, or pick <strong>Auto</strong> and it uses the best you're set up for and says so.
228
247
  </div>
248
+ {engine === 'auto' && auto && (
249
+ <div className="st-provider-notes">
250
+ Right now Auto uses <strong>{ENGINE_LABELS[auto.engine] || auto.engine}</strong> —{' '}
251
+ {auto.reason}
252
+ {auto.cloud && ' Audio is uploaded to that provider and billed to your account.'}
253
+ </div>
254
+ )}
229
255
  <div className="st-engine-radios" role="radiogroup" aria-label="Transcription engine">
230
256
  {TRANSCRIPTION_ENGINES.map((e) => (
231
257
  <label key={e.id} className={'st-engine-radio' + (engine === e.id ? ' is-selected' : '')}>
@@ -314,6 +340,7 @@ function WhisperModelCard() {
314
340
 
315
341
  const dl = state?.downloading;
316
342
  const pctOf = (r, t) => (t > 0 ? Math.min(100, Math.round((r / t) * 100)) : 0);
343
+ const modelsReady = (state?.models || []).some((m) => m.downloaded);
317
344
 
318
345
  return (
319
346
  <div className="st-provider-card">
@@ -321,10 +348,58 @@ function WhisperModelCard() {
321
348
  <span className="st-provider-name">Local subtitle models (whisper.cpp)</span>
322
349
  </div>
323
350
  <div className="st-provider-notes">
324
- Download a model once and local subtitles work offline, free, with no key. Requires the
325
- whisper.cpp binary (<code>brew install whisper-cpp</code>). Models are stored on this machine
326
- only and never committed.
351
+ Local subtitles need two things: the whisper.cpp engine and one model. Models are stored on
352
+ this machine only and never committed.
327
353
  </div>
354
+ {state && (
355
+ <div className="st-setup">
356
+ <div className={`st-step${state.setup?.installed ? ' is-done' : ''}`}>
357
+ <span className="st-step-n">{state.setup?.installed ? '✓' : '1'}</span>
358
+ <div className="st-step-body">
359
+ <span className="st-step-title">
360
+ {state.setup?.installed
361
+ ? 'whisper.cpp engine is installed'
362
+ : 'Install the whisper.cpp engine'}
363
+ </span>
364
+ {/* Only routes that work on THIS machine — no `brew install`
365
+ without Homebrew. whisper.cpp ships no macOS prebuilt, so the
366
+ brew-less answer is a cloud engine or a source build. */}
367
+ {(state.setup?.options || []).slice(0, 1).map((opt) =>
368
+ opt.kind === 'command' ? (
369
+ <CopyCommand key={opt.id} command={opt.command} label={`Copy: ${opt.label}`} />
370
+ ) : (
371
+ <span key={opt.id} className="st-provider-notes">
372
+ {opt.note}
373
+ </span>
374
+ )
375
+ )}
376
+ </div>
377
+ </div>
378
+ <div className={`st-step${modelsReady ? ' is-done' : ''}`}>
379
+ <span className="st-step-n">{modelsReady ? '✓' : '2'}</span>
380
+ <div className="st-step-body">
381
+ <span className="st-step-title">
382
+ {modelsReady ? 'Model ready' : 'Download a model (below)'}
383
+ </span>
384
+ </div>
385
+ </div>
386
+ {(state.setup?.options || []).length > 1 && (
387
+ <details className="st-setup-more">
388
+ <summary>Other ways to get the engine</summary>
389
+ {state.setup.options.slice(1).map((opt) => (
390
+ <div key={opt.id} className="st-setup-option">
391
+ <span className="st-setup-option-label">{opt.label}</span>
392
+ <CopyCommand
393
+ command={opt.kind === 'command' ? opt.command : opt.url}
394
+ label={`Copy: ${opt.label}`}
395
+ />
396
+ {opt.note ? <span className="st-provider-notes">{opt.note}</span> : null}
397
+ </div>
398
+ ))}
399
+ </details>
400
+ )}
401
+ </div>
402
+ )}
328
403
  {err && (
329
404
  <div className="st-provider-status" style={{ color: 'var(--danger, #e5484d)' }}>
330
405
  {err}
@@ -389,7 +464,7 @@ function WhisperModelCard() {
389
464
  // sibling of the Subtitles section. Persists `keyframeEngine` to /_api/generate/prefs.
390
465
  const KEYFRAME_ENGINES = [
391
466
  { id: 'auto', label: 'Auto', note: 'Use the best available: Gemma scout → ffmpeg → blind.' },
392
- { id: 'gemma', label: 'Gemma scout', note: 'Semantic action beats. Needs Apple Silicon + mlx-vlm + a model.' },
467
+ { id: 'gemma', label: 'Gemma scout', note: 'Semantic action beats. Needs Ollama (gemma3 vision) or mlx-vlm + a model.' },
393
468
  { id: 'ffmpeg', label: 'ffmpeg scene-detect', note: 'Scene cuts + endpoints. Needs ffmpeg. No model download.' },
394
469
  { id: 'blind', label: 'Blind (even-spaced)', note: 'Chromium fallback — works with neither ffmpeg nor Gemma.' },
395
470
  ];
@@ -468,11 +543,115 @@ function KeyframeEngineCard() {
468
543
  );
469
544
  }
470
545
 
546
+ // A copy/paste terminal command with a Copy button (same look as the ACP
547
+ // readiness rows — the rdy-* classes are global in the bundled stylesheet).
548
+ function CopyCommand({ command, label }) {
549
+ const [copied, setCopied] = useState(false);
550
+ const copy = () => {
551
+ navigator.clipboard?.writeText(command).then(
552
+ () => {
553
+ setCopied(true);
554
+ setTimeout(() => setCopied(false), 1500);
555
+ },
556
+ () => {}
557
+ );
558
+ };
559
+ return (
560
+ <span className="rdy-cmd">
561
+ <code className="rdy-cmd-tx">{command}</code>
562
+ <button type="button" className="rdy-copy" onClick={copy} aria-label={label || 'Copy command'}>
563
+ {copied ? 'Copied' : 'Copy'}
564
+ </button>
565
+ </span>
566
+ );
567
+ }
568
+
569
+ // The setup path, as STEPS rather than a wall of options. Only the shortest
570
+ // route is visible (install → pull); everything else — Homebrew, the download
571
+ // link, the whole mlx-vlm alternative — sits folded behind one disclosure, so a
572
+ // first-time reader sees two commands, not seven.
573
+ function SetupSteps({ state }) {
574
+ const ollama = state.ollama || {};
575
+ const setup = ollama.setup || [];
576
+ // The server orders routes best-first, so the head is the one step to take now
577
+ // ("install", or "start" when the binary is already there). Everything after
578
+ // it is an alternative way to reach the same place.
579
+ const [primary, ...alternatives] = setup;
580
+ const step1Done = Boolean(ollama.available);
581
+ const step2Done = Boolean(ollama.model);
582
+ // Once Ollama is running, its own route list collapses to the pull command —
583
+ // which IS step 2, so it must not also render as step 1.
584
+ const step1 = step1Done || primary?.id === 'pull' ? null : primary;
585
+
586
+ return (
587
+ <div className="st-setup">
588
+ <div className={`st-step${step1Done ? ' is-done' : ''}`}>
589
+ <span className="st-step-n">{step1Done ? '✓' : '1'}</span>
590
+ <div className="st-step-body">
591
+ <span className="st-step-title">
592
+ {step1Done
593
+ ? 'Ollama is running'
594
+ : ollama.installed
595
+ ? 'Start Ollama'
596
+ : 'Install Ollama'}
597
+ </span>
598
+ {step1?.kind === 'command' ? (
599
+ <CopyCommand command={step1.command} label="Copy the install command" />
600
+ ) : null}
601
+ </div>
602
+ </div>
603
+ <div className={`st-step${step2Done ? ' is-done' : ''}`}>
604
+ <span className="st-step-n">{step2Done ? '✓' : '2'}</span>
605
+ <div className="st-step-body">
606
+ <span className="st-step-title">
607
+ {step2Done ? `Model ready (${ollama.model})` : 'Download the model'}
608
+ </span>
609
+ {step2Done ? null : (
610
+ <CopyCommand command={ollama.pullCommand} label="Copy the model pull command" />
611
+ )}
612
+ </div>
613
+ </div>
614
+ <details className="st-setup-more">
615
+ <summary>Other ways to install</summary>
616
+ {alternatives.map((opt) => (
617
+ <div key={opt.id} className="st-setup-option">
618
+ <span className="st-setup-option-label">{opt.label}</span>
619
+ {/* A link is rendered as a copyable URL, not a button: the desktop
620
+ shell has no general URL opener by design (DDR-054). */}
621
+ <CopyCommand
622
+ command={opt.kind === 'command' ? opt.command : opt.url}
623
+ label={`Copy: ${opt.label}`}
624
+ />
625
+ </div>
626
+ ))}
627
+ <div className="st-setup-option">
628
+ <span className="st-setup-option-label">
629
+ mlx-vlm instead of Ollama — faster, Apple Silicon only
630
+ </span>
631
+ {state.mlx?.supported ? (
632
+ <>
633
+ <CopyCommand command={state.mlx.command} label="Copy the mlx-vlm install command" />
634
+ <span className="st-provider-notes">
635
+ Then download a model below. Models live in your HuggingFace cache.
636
+ </span>
637
+ </>
638
+ ) : (
639
+ // Never show a command that would fail here — say why instead.
640
+ <span className="st-provider-notes">{state.mlx?.reason}</span>
641
+ )}
642
+ </div>
643
+ </details>
644
+ </div>
645
+ );
646
+ }
647
+
471
648
  // The MODEL half of the Gemma scout — one click, mirroring WhisperModelCard. The
472
- // RUNTIME (mlx-vlm) is a manual `pip install` the app can't do, so the download is
473
- // gated on mlxVlmAvailable and the card says which tier will actually run.
649
+ // RUNTIME is a manual install the app can't do for you (DDR-183), but the card
650
+ // hands you copy/paste commands for BOTH runtime paths — Ollama (simplest: one
651
+ // app, `ollama pull`, no Python) and mlx-vlm (fastest, the benchmarked path) —
652
+ // and re-probes on its own, so the card unlocks itself once you've run one.
474
653
  function GemmaModelCard() {
475
- const [state, setState] = useState(null); // { models, downloading, mlxVlmAvailable, ffmpegAvailable }
654
+ const [state, setState] = useState(null); // { models, downloading, mlxVlmAvailable, ffmpegAvailable, installCommand, ollama }
476
655
  const [err, setErr] = useState(null);
477
656
 
478
657
  const load = useCallback(() => {
@@ -492,6 +671,16 @@ function GemmaModelCard() {
492
671
  return () => clearInterval(t);
493
672
  }, [state?.downloading, load]);
494
673
 
674
+ // No scout runtime yet → the user is likely running one of the install
675
+ // commands in a terminal right now. Re-probe every 10 s (server-side the
676
+ // probes are TTL-cached) so the card unlocks itself without a reopen.
677
+ const noRuntime = state ? !state.mlxVlmAvailable && !state.ollama?.model : false;
678
+ useEffect(() => {
679
+ if (!noRuntime) return undefined;
680
+ const t = setInterval(load, 10_000);
681
+ return () => clearInterval(t);
682
+ }, [noRuntime, load]);
683
+
495
684
  async function download(id) {
496
685
  setErr(null);
497
686
  try {
@@ -510,8 +699,14 @@ function GemmaModelCard() {
510
699
  const dl = state?.downloading;
511
700
  const mlx = state?.mlxVlmAvailable;
512
701
  const ff = state?.ffmpegAvailable;
702
+ const ollama = state?.ollama;
703
+ const scoutReady = Boolean(mlx || ollama?.model);
513
704
  const pctOf = (r, t) => (t > 0 ? Math.min(100, Math.round((r / t) * 100)) : 0);
514
- const activeTier = mlx ? 'Gemma scout' : ff ? 'ffmpeg scene-detect' : 'blind (even-spaced)';
705
+ const activeTier = scoutReady
706
+ ? `Gemma scout${mlx ? '' : ` (Ollama · ${ollama.model})`}`
707
+ : ff
708
+ ? 'ffmpeg scene-detect'
709
+ : 'blind (even-spaced)';
515
710
 
516
711
  return (
517
712
  <div className="st-provider-card">
@@ -519,16 +714,14 @@ function GemmaModelCard() {
519
714
  <span className="st-provider-name">Gemma scout model (optional)</span>
520
715
  </div>
521
716
  <div className="st-provider-notes">
522
- The <strong>Gemma</strong> tier adds semantic action-beat detection on top of scene cuts. It
523
- needs an <strong>Apple-Silicon Mac</strong> and the <code>mlx-vlm</code> runtime
524
- (<code>pip install mlx-vlm</code> — a manual step Maude can’t do for you). Downloading a model
525
- below is the one-click half. Models live in your HuggingFace cache, never committed.
717
+ Adds semantic action-beat detection on top of scene cuts. Two short steps in a terminal —
718
+ Maude picks the result up on its own.
526
719
  </div>
720
+ {state && !scoutReady && <SetupSteps state={state} />}
527
721
  {state && (
528
722
  <div className="st-provider-notes">
529
- On this machine the active tier is <strong>{activeTier}</strong>
530
- {!mlx && ff && ' — install mlx-vlm + download a model to unlock the Gemma scout.'}
531
- {!mlx && !ff && ' — install ffmpeg for scene-aware frames, or mlx-vlm for the Gemma scout.'}
723
+ Active tier on this machine: <strong>{activeTier}</strong>
724
+ {scoutReady && !mlx && ' — Ollama manages its own models, nothing to download below.'}
532
725
  </div>
533
726
  )}
534
727
  {err && (
@@ -545,6 +738,13 @@ function GemmaModelCard() {
545
738
  <div className="st-model-list">
546
739
  {(state?.models || []).map((m) => {
547
740
  const busy = dl && !dl.error && dl.id === m.id;
741
+ const runtimeReady = m.runtime === 'ollama' ? Boolean(ollama?.available) : Boolean(mlx);
742
+ const blockedNote =
743
+ m.runtime === 'ollama'
744
+ ? ollama?.installed
745
+ ? 'start Ollama first'
746
+ : 'needs Ollama'
747
+ : 'needs mlx-vlm';
548
748
  return (
549
749
  <div key={m.id} className="st-model-row">
550
750
  <div className="st-model-info">
@@ -554,7 +754,10 @@ function GemmaModelCard() {
554
754
  </span>
555
755
  <span className="st-engine-radio-note">{m.note}</span>
556
756
  {busy && (
557
- <span className="st-model-progress">Downloading… {pctOf(dl.received, dl.total)}%</span>
757
+ <span className="st-model-progress">
758
+ {m.runtime === 'ollama' ? 'Pulling' : 'Downloading'}…{' '}
759
+ {pctOf(dl.received, dl.total)}%
760
+ </span>
558
761
  )}
559
762
  </div>
560
763
  <div className="st-model-actions">
@@ -563,15 +766,24 @@ function GemmaModelCard() {
563
766
  <Icon name="check" size={12} /> ready
564
767
  </span>
565
768
  ) : (
566
- <button
567
- type="button"
568
- className="st-btn"
569
- disabled={!mlx || !!(dl && !dl.error)}
570
- title={mlx ? '' : 'Install mlx-vlm first (pip install mlx-vlm)'}
571
- onClick={() => download(m.id)}
572
- >
573
- {busy ? 'Downloading…' : mlx ? 'Download' : 'Needs mlx-vlm'}
574
- </button>
769
+ // Each row is enabled by ITS OWN runtime — an Ollama model is
770
+ // one click whenever Ollama is running (the server pulls it
771
+ // through /api/pull); an mlx snapshot needs mlx-vlm. The
772
+ // button keeps its label and just goes disabled, with the
773
+ // reason in small type underneath.
774
+ <span className="st-model-action">
775
+ <button
776
+ type="button"
777
+ className="st-btn"
778
+ disabled={!runtimeReady || !!(dl && !dl.error)}
779
+ onClick={() => download(m.id)}
780
+ >
781
+ {busy ? 'Downloading…' : 'Download'}
782
+ </button>
783
+ {runtimeReady ? null : (
784
+ <span className="st-model-action-note">{blockedNote}</span>
785
+ )}
786
+ </span>
575
787
  )}
576
788
  </div>
577
789
  </div>
@@ -1725,6 +1725,65 @@
1725
1725
  line-height: 1.5;
1726
1726
  margin-top: 6px;
1727
1727
  }
1728
+ /* Scout setup (feature-scene-aware-keyframes) — the shortest path as numbered
1729
+ steps; every alternative route folds behind one disclosure so the default
1730
+ view is two commands, not seven. */
1731
+ .st-setup {
1732
+ margin-top: 8px;
1733
+ }
1734
+ .st-step {
1735
+ display: flex;
1736
+ gap: 8px;
1737
+ align-items: flex-start;
1738
+ margin-top: 8px;
1739
+ }
1740
+ .st-step-n {
1741
+ flex-shrink: 0;
1742
+ width: 18px;
1743
+ height: 18px;
1744
+ border-radius: 50%;
1745
+ border: 1px solid var(--border-subtle);
1746
+ color: var(--fg-2);
1747
+ font-size: 10px;
1748
+ line-height: 16px;
1749
+ text-align: center;
1750
+ }
1751
+ .st-step.is-done .st-step-n {
1752
+ border-color: var(--accent);
1753
+ color: var(--accent);
1754
+ }
1755
+ .st-step-body {
1756
+ min-width: 0;
1757
+ flex: 1 1 auto;
1758
+ }
1759
+ .st-step-title {
1760
+ display: block;
1761
+ font-size: 11px;
1762
+ color: var(--fg-1);
1763
+ }
1764
+ .st-step.is-done .st-step-title {
1765
+ color: var(--fg-3);
1766
+ }
1767
+ .st-setup-more {
1768
+ margin-top: 10px;
1769
+ }
1770
+ .st-setup-more > summary {
1771
+ cursor: pointer;
1772
+ font-size: 11px;
1773
+ color: var(--fg-3);
1774
+ }
1775
+ .st-setup-more > summary:hover {
1776
+ color: var(--fg-1);
1777
+ }
1778
+ .st-setup-option {
1779
+ display: block;
1780
+ margin-top: 8px;
1781
+ }
1782
+ .st-setup-option-label {
1783
+ display: block;
1784
+ font-size: 11px;
1785
+ color: var(--fg-2);
1786
+ }
1728
1787
  .st-provider-keylink {
1729
1788
  display: inline-flex;
1730
1789
  align-items: center;
@@ -1840,6 +1899,19 @@
1840
1899
  gap: 8px;
1841
1900
  flex-shrink: 0;
1842
1901
  }
1902
+ /* A disabled action plus the reason it's disabled, in small type underneath —
1903
+ so the button label stays the action, not the explanation. */
1904
+ .st-model-action {
1905
+ display: flex;
1906
+ flex-direction: column;
1907
+ align-items: flex-end;
1908
+ gap: 3px;
1909
+ }
1910
+ .st-model-action-note {
1911
+ font-size: 10px;
1912
+ color: var(--fg-3);
1913
+ text-align: right;
1914
+ }
1843
1915
 
1844
1916
  /* ── Generate dialog (feature-ai-media-generation, DDR-16x) ─────────────── */
1845
1917
  .st-gen-prompt {
@@ -109,6 +109,19 @@ async function cloudFetch(
109
109
 
110
110
  interface Ctx {
111
111
  paths: { repoRoot: string; designRoot: string };
112
+ /**
113
+ * The live sync runtime's owner (server.ts → sync/supervisor.ts). Optional:
114
+ * unit tests and any non-serving embedder construct a Ctx without one, and a
115
+ * missing supervisor degrades to exactly the old behaviour — linked on disk,
116
+ * syncing after the next start.
117
+ */
118
+ syncControl?: {
119
+ restart(linkedHub?: {
120
+ url: string;
121
+ linkedAt: number;
122
+ syncTsx?: boolean;
123
+ }): Promise<{ syncing: boolean; canvases: number; reason?: string; detail?: string }>;
124
+ };
112
125
  }
113
126
 
114
127
  export function createCloudEndpoints(ctx: Ctx) {
@@ -225,7 +238,9 @@ export function createCloudEndpoints(ctx: Ctx) {
225
238
  /**
226
239
  * Attach THIS project to a cloud workspace: open → exchange at the cell →
227
240
  * store the hub credential + linkedHub, exactly as `maude design link`
228
- * would. The sync agent picks the link up on the next server start.
241
+ * would — and then START SYNCING (linkToWorkspace cycles the live runtime
242
+ * through ctx.syncControl). Connecting is the whole gesture; there is no
243
+ * second step for the person to discover.
229
244
  */
230
245
  async attach(projectId: string): Promise<CloudEndpointResult> {
231
246
  const file = readCloudFile();
@@ -380,9 +395,42 @@ export function createCloudEndpoints(ctx: Ctx) {
380
395
  } catch {
381
396
  /* absent/malformed → start minimal */
382
397
  }
383
- cfg.linkedHub = { url: norm, linkedAt: Date.now() };
398
+ const linkedHub: { url: string; linkedAt: number; syncTsx?: boolean } = {
399
+ url: norm,
400
+ linkedAt: Date.now(),
401
+ };
402
+ // Carry a project-wide TSX opt-out across the re-link. Only the RESTRICTIVE
403
+ // direction is carried (`false`, never `true`): somebody who turned canvas
404
+ // BODIES off (DDR-072/DDR-079) did so deliberately, and silently re-enabling
405
+ // them on the next Connect would start uploading source they opted out of.
406
+ // It never mattered while nothing synced until a restart; it does now.
407
+ const prior = (cfg as { linkedHub?: { syncTsx?: boolean } }).linkedHub;
408
+ if (prior?.syncTsx === false) linkedHub.syncTsx = false;
409
+ cfg.linkedHub = linkedHub;
384
410
  writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
385
411
 
412
+ // START SYNCING — do not hand the person a "restart the server" note and
413
+ // call the job done. The runtime captures linkedHub once at boot, so this
414
+ // cycles it in place, with the value we JUST wrote (never re-read from the
415
+ // committed config file). Best-effort: a supervisor-less embedder, or a
416
+ // runtime that declines, still leaves a correctly linked project behind.
417
+ let sync: { syncing: boolean; canvases: number; reason?: string; detail?: string } = {
418
+ syncing: false,
419
+ canvases: 0,
420
+ reason: 'no-supervisor',
421
+ detail: 'Restart Maude to start syncing.',
422
+ };
423
+ try {
424
+ sync = (await ctx.syncControl?.restart(linkedHub)) ?? sync;
425
+ } catch (err) {
426
+ sync = {
427
+ syncing: false,
428
+ canvases: 0,
429
+ reason: 'error',
430
+ detail: `Linked, but syncing could not start: ${(err as Error).message}`,
431
+ };
432
+ }
433
+
386
434
  return {
387
435
  status: 200,
388
436
  json: {
@@ -390,7 +438,12 @@ export function createCloudEndpoints(ctx: Ctx) {
390
438
  url: norm,
391
439
  role: role ?? null,
392
440
  project: project ?? null,
393
- note: 'Linked. Restart the studio server to start syncing.',
441
+ sync,
442
+ // Kept for older clients (and the CLI) that read `note` — now it
443
+ // reports what actually happened instead of assigning homework.
444
+ note: sync.syncing
445
+ ? `Linked — syncing ${sync.canvases} canvas${sync.canvases === 1 ? '' : 'es'}.`
446
+ : `Linked. ${sync.detail ?? 'Restart Maude to start syncing.'}`,
394
447
  },
395
448
  };
396
449
  }