@genesislcap/ai-assistant 15.19.6 → 15.20.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 (42) hide show
  1. package/dist/ai-assistant.api.json +605 -72
  2. package/dist/ai-assistant.d.ts +404 -25
  3. package/dist/chat-driver.cjs +341 -28
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +341 -28
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +630 -20
  8. package/dist/dts/components/ai-driver/ai-driver.d.ts +33 -7
  9. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +63 -2
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +9 -3
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  14. package/dist/dts/config/config.d.ts +44 -0
  15. package/dist/dts/config/config.d.ts.map +1 -1
  16. package/dist/dts/main/main.d.ts +187 -5
  17. package/dist/dts/main/main.d.ts.map +1 -1
  18. package/dist/dts/main/main.styles.d.ts.map +1 -1
  19. package/dist/dts/main/main.template.d.ts.map +1 -1
  20. package/dist/dts/utils/condense-history.d.ts.map +1 -1
  21. package/dist/dts/utils/context-tokens.d.ts +156 -0
  22. package/dist/dts/utils/context-tokens.d.ts.map +1 -0
  23. package/dist/dts/utils/history-transform.d.ts +76 -14
  24. package/dist/dts/utils/history-transform.d.ts.map +1 -1
  25. package/dist/dts/utils/resolve-context-budget.d.ts +98 -0
  26. package/dist/dts/utils/resolve-context-budget.d.ts.map +1 -0
  27. package/dist/esm/components/chat-driver/chat-driver.js +179 -34
  28. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +12 -4
  29. package/dist/esm/main/main.js +391 -21
  30. package/dist/esm/main/main.styles.js +128 -0
  31. package/dist/esm/main/main.template.js +64 -29
  32. package/dist/esm/state/debug-event-log.js +1 -1
  33. package/dist/esm/utils/condense-history.js +1 -5
  34. package/dist/esm/utils/context-tokens.js +339 -0
  35. package/dist/esm/utils/history-transform.js +101 -19
  36. package/dist/esm/utils/resolve-context-budget.js +84 -0
  37. package/package.json +16 -16
  38. package/sandbox/README.md +93 -4
  39. package/sandbox/controls.ts +77 -10
  40. package/sandbox/fixtures.ts +163 -6
  41. package/sandbox/sandbox.css +54 -1
  42. package/sandbox/sandbox.ts +384 -7
@@ -13,6 +13,7 @@ import '../src/index';
13
13
  import type { FoundationAiAssistant } from '../src/main/main';
14
14
  import type { PopoutMode } from '../src/main/main.types';
15
15
  import { WebStorageSessionProvider } from '../src/state/persistence';
16
+ import { estimateContextTokens } from '../src/utils/context-tokens';
16
17
  import { renderControls, type ControlGroup } from './controls';
17
18
  import {
18
19
  buildAgents,
@@ -35,12 +36,77 @@ registerRapidDesignSystem();
35
36
  // resolves to the built-in empty registry and every send is a no-op; with it,
36
37
  // Send returns a canned reply so the busy states are real. Registered before the
37
38
  // element connects — `createDriver()` reads the registry there.
38
- registerAIProviders(DI.getOrCreateDOMContainer(), { sandbox: new SandboxAIProvider() });
39
+ // Held rather than inlined: the sandbox writes its `contextLimit` on every apply
40
+ // so the element's own re-resolve agrees with what the panel is showing.
41
+ const aiProvider = new SandboxAIProvider();
42
+ registerAIProviders(DI.getOrCreateDOMContainer(), { sandbox: aiProvider });
39
43
 
40
44
  const sessionProvider = new WebStorageSessionProvider(window.sessionStorage, {
41
45
  keyPrefix: 'ai-assistant-sandbox',
42
46
  });
43
47
 
48
+ // ─── Context-gate presets ────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * One-click jumps to each state the gate can be in (GENC-1567).
52
+ *
53
+ * Reaching these by hand means getting a transcript, a window and a reserve to
54
+ * agree, and the two blocked states differ by something not obvious from the
55
+ * panel at all — whether a compaction would reclaim enough to be worth offering.
56
+ * Each preset drives the ordinary controls rather than forcing the element, so
57
+ * what you land on is the component deciding for itself; the readouts underneath
58
+ * show it doing so.
59
+ *
60
+ * All four use the measured fill, so the gate and the compaction planner are
61
+ * always looking at the same conversation.
62
+ */
63
+ const CONTEXT_PRESETS = {
64
+ normal: {
65
+ label: 'Normal (plenty of room)',
66
+ transcript: 'rich',
67
+ },
68
+ warning: {
69
+ label: 'Warning (approaching the limit)',
70
+ transcript: 'huge',
71
+ },
72
+ 'blocked-compactable': {
73
+ label: 'Blocked — compacting will clear it',
74
+ // A long tool loop: over the block, and with cuttable boundaries inside it.
75
+ transcript: 'loop',
76
+ },
77
+ 'blocked-stuck': {
78
+ label: 'Blocked — compacting will NOT clear it',
79
+ // One indivisible tool result. No cut can shrink it, so the copy has to offer
80
+ // a new chat instead of Compact.
81
+ transcript: 'wall',
82
+ },
83
+ } as const satisfies Record<string, { label: string; transcript: TranscriptName }>;
84
+
85
+ type ContextPresetName = keyof typeof CONTEXT_PRESETS;
86
+
87
+ /**
88
+ * Shown once any control in the group has been moved by hand, so the dropdown
89
+ * never claims a state the panel has since been steered away from.
90
+ *
91
+ * It also makes re-selecting a preset work at all: a `<select>` fires no
92
+ * `change` event when you pick the option already showing, so without something
93
+ * to fall back to, "jump to Normal" would be a no-op for anyone already sitting
94
+ * on Normal — which is the default, and so the first thing most people try.
95
+ */
96
+ const CUSTOM_PRESET = 'custom';
97
+
98
+ /** Keys the preset owns. Touching any of them by hand drops the panel to custom. */
99
+ const CONTEXT_PRESET_KEYS = [
100
+ 'transcript',
101
+ 'contextSource',
102
+ 'contextWindow',
103
+ 'contextFill',
104
+ 'reserveTokens',
105
+ 'warnMultiplier',
106
+ 'contextGateEnabled',
107
+ 'flowInProgress',
108
+ ] as const;
109
+
44
110
  // ─── State ───────────────────────────────────────────────────────────────────
45
111
 
46
112
  interface SandboxState {
@@ -70,6 +136,15 @@ interface SandboxState {
70
136
  showThinkingSteps: boolean;
71
137
  showNarration: boolean;
72
138
  animations: boolean;
139
+ // Context window (GENC-1567)
140
+ contextPreset: ContextPresetName | typeof CUSTOM_PRESET;
141
+ contextWindow: number;
142
+ contextSource: 'auto' | 'manual' | 'none';
143
+ contextFill: number;
144
+ contextGateEnabled: boolean;
145
+ reserveTokens: number;
146
+ warnMultiplier: number;
147
+ flowInProgress: boolean;
73
148
  // Simulated runtime
74
149
  runtime: 'idle' | 'loading' | 'cancelling' | 'blocked' | 'compacting' | 'restoring';
75
150
  transcript: TranscriptName;
@@ -102,6 +177,18 @@ const DEFAULT_STATE: SandboxState = {
102
177
  showThinkingSteps: true,
103
178
  showNarration: true,
104
179
  animations: true,
180
+ // Chosen so the `huge` (~20k) and `loop` (~22k) seeds actually clear the tail
181
+ // budget these produce (~12.8k) and so have something to compact. Verified, not
182
+ // guessed — a slightly roomier pairing leaves the tail larger than either seed,
183
+ // and Compact then greys out for a reason that looks like a bug.
184
+ contextPreset: 'normal',
185
+ contextWindow: 30_000,
186
+ contextSource: 'auto',
187
+ contextFill: 60,
188
+ contextGateEnabled: true,
189
+ reserveTokens: 8_000,
190
+ warnMultiplier: 2,
191
+ flowInProgress: false,
105
192
  runtime: 'idle',
106
193
  transcript: 'rich',
107
194
  width: 480,
@@ -109,12 +196,25 @@ const DEFAULT_STATE: SandboxState = {
109
196
  theme: 'dark',
110
197
  };
111
198
 
199
+ /**
200
+ * Narrowest stage the sandbox offers.
201
+ *
202
+ * The assistant's blocked-state copy is sized against this — see the
203
+ * `.composer.context-blocked` floor in `main.styles.ts` — so letting the stage go
204
+ * narrower would show a clipped message that no supported width produces.
205
+ */
206
+ const MIN_STAGE_WIDTH = 400;
207
+
112
208
  const STORAGE_KEY = 'ai-assistant-sandbox:state';
113
209
 
114
210
  const loadState = (): SandboxState => {
115
211
  try {
116
212
  const raw = localStorage.getItem(STORAGE_KEY);
117
- return raw ? { ...DEFAULT_STATE, ...JSON.parse(raw) } : { ...DEFAULT_STATE };
213
+ const loaded = raw ? { ...DEFAULT_STATE, ...JSON.parse(raw) } : { ...DEFAULT_STATE };
214
+ // A panel saved before the floor existed can carry a narrower width, and it
215
+ // would survive every reload while the slider refused to reproduce it.
216
+ loaded.width = Math.max(MIN_STAGE_WIDTH, loaded.width);
217
+ return loaded;
118
218
  } catch {
119
219
  return { ...DEFAULT_STATE };
120
220
  }
@@ -163,6 +263,11 @@ const seedTranscript = (s: SandboxState): void => {
163
263
  const seed = TRANSCRIPTS[s.transcript].map((m) => ({ ...m }));
164
264
  (assistant as any).driver?.loadHistory?.(seed.map((m) => ({ ...m })));
165
265
  assistant.messages = seed;
266
+ // `auto` measures the transcript, which only exists as of this tick — so the
267
+ // fill and every readout have to be recomputed now rather than in the
268
+ // `apply()` that scheduled this.
269
+ applyContext(s);
270
+ refreshReadouts();
166
271
  }, 0);
167
272
  };
168
273
 
@@ -172,6 +277,12 @@ const seedTranscript = (s: SandboxState): void => {
172
277
  * cog-menu choices — which is what you want when a template edit hot-reloads.
173
278
  */
174
279
  const mountAssistant = (): void => {
280
+ // BEFORE the element connects. `connectedCallback` resolves the context limit
281
+ // from the provider straight away, and `getStatus()` reads this field
282
+ // synchronously — so setting it in `apply()` (which runs after `appendChild`)
283
+ // loses the race and the element keeps the stub's default window for the rest
284
+ // of the session.
285
+ aiProvider.contextLimit = state.contextWindow;
175
286
  provider.textContent = '';
176
287
  assistant = document.createElement('foundation-ai-assistant') as FoundationAiAssistant;
177
288
  assistant.setAttribute('session-key', 'sandbox');
@@ -202,6 +313,11 @@ const buildChatConfig = (s: SandboxState): ChatConfig => ({
202
313
  // `userConfigurable` turns the defaults on and adds the multiselect to the cog.
203
314
  animations: s.animations ? { userConfigurable: true } : undefined,
204
315
  },
316
+ context: {
317
+ enabled: s.contextGateEnabled,
318
+ reserveTokens: s.reserveTokens,
319
+ warnMultiplier: s.warnMultiplier,
320
+ },
205
321
  picker: {
206
322
  mode: s.pickerMode,
207
323
  disablePinColours: s.disablePinColours,
@@ -250,6 +366,92 @@ const applyDivider = (on: boolean): void => {
250
366
  );
251
367
  };
252
368
 
369
+ /**
370
+ * Drives the context gate (GENC-1567).
371
+ *
372
+ * The window goes onto the PROVIDER as well as the element. The element
373
+ * re-resolves its limit from the active provider, so a value poked only onto the
374
+ * element is live until that happens and then silently reverts — writing both
375
+ * means the panel and the component cannot disagree.
376
+ *
377
+ * **`contextTokens` has to be simulated** — the stub reports no usage, so nothing
378
+ * would ever set it and the gate would sit inert forever. How it is simulated
379
+ * matters more than it looks:
380
+ *
381
+ * - `auto` measures the seeded transcript with the component's own estimator, so
382
+ * the gate and the compaction planner are looking at the same conversation.
383
+ * This is the honest mode and the default.
384
+ * - `manual` forces a percentage. Useful for parking on a threshold without
385
+ * seeding anything, but it can DISAGREE with the transcript — claim 85% of the
386
+ * window while the transcript holds 250 tokens and the gate says "full" while
387
+ * the planner correctly says there is nothing here worth compacting, so the
388
+ * blocked copy offers a new chat rather than Compact. That is the component
389
+ * being right about an impossible situation, not a bug; the readouts show both
390
+ * figures so the mismatch is visible rather than mystifying.
391
+ * - `none` leaves it unset — the real state on a provider that reports no usage,
392
+ * where the gate must stay quiet rather than guess.
393
+ */
394
+ /**
395
+ * Expand a preset into the ordinary controls.
396
+ *
397
+ * Writes the whole context group, not just the transcript — including the pieces
398
+ * a previous experiment may have left set (a manual fill, a flow in progress, the
399
+ * gate switched off). A preset that only moved some of them would land somewhere
400
+ * different depending on what you had been doing, which is exactly what a "show
401
+ * me this state" shortcut must not do.
402
+ */
403
+ const applyContextPreset = (s: SandboxState): void => {
404
+ if (s.contextPreset === CUSTOM_PRESET) return;
405
+ const preset = CONTEXT_PRESETS[s.contextPreset];
406
+ s.transcript = preset.transcript;
407
+ s.contextSource = 'auto';
408
+ s.contextWindow = 30_000;
409
+ s.reserveTokens = 8_000;
410
+ s.warnMultiplier = 2;
411
+ s.contextGateEnabled = true;
412
+ s.flowInProgress = false;
413
+ };
414
+
415
+ /**
416
+ * Keep the measured fill live.
417
+ *
418
+ * Without this, `auto` measures only when a panel control moves — so a
419
+ * compaction (which the component performs on its own) would shrink the
420
+ * transcript while the readouts, and the gate, went on quoting the old size. The
421
+ * panel would then show a block that compacting had already cleared, which is
422
+ * precisely the bug the component was fixed for and exactly the wrong thing for a
423
+ * harness to fake.
424
+ *
425
+ * Idempotent and re-checked on every apply: the driver is rebuilt whenever the
426
+ * agents change, and the listener has to follow it.
427
+ */
428
+ let boundDriver: unknown;
429
+
430
+ const bindHistoryListener = (): void => {
431
+ const driver = (assistant as any).driver;
432
+ if (!driver || driver === boundDriver) return;
433
+ boundDriver = driver;
434
+ driver.addEventListener('history-updated', () => {
435
+ if (state.contextSource !== 'auto') return;
436
+ applyContext(state);
437
+ refreshReadouts();
438
+ });
439
+ };
440
+
441
+ const applyContext = (s: SandboxState): void => {
442
+ aiProvider.contextLimit = s.contextWindow;
443
+ assistant.contextLimit = s.contextWindow;
444
+ assistant.contextTokens =
445
+ s.contextSource === 'none'
446
+ ? undefined
447
+ : s.contextSource === 'manual'
448
+ ? Math.round((s.contextWindow * s.contextFill) / 100)
449
+ : estimateContextTokens(assistant.messages);
450
+ // Standing in for a stateful agent holding the flow. The gate downgrades a
451
+ // block to a warning while this is set, so a flow can always finish.
452
+ assistant.flowOwnerAgentName = s.flowInProgress ? 'Reconciliation' : null;
453
+ };
454
+
253
455
  const applyRuntime = (s: SandboxState): void => {
254
456
  assistant.compacting = s.runtime === 'compacting';
255
457
  assistant.restoring = s.runtime === 'restoring';
@@ -280,6 +482,8 @@ function apply(): void {
280
482
  assistant.persistence = state.persistenceProvider
281
483
  ? { enabled: false, provider: sessionProvider }
282
484
  : {};
485
+ bindHistoryListener();
486
+ applyContext(state);
283
487
  applyRuntime(state);
284
488
  applyGradientSend(state.gradientSend);
285
489
  applyDivider(state.showDivider);
@@ -288,8 +492,55 @@ function apply(): void {
288
492
  seedTranscript(state);
289
493
  applyStage(state);
290
494
  saveState(state);
495
+ refreshReadouts();
291
496
  }
292
497
 
498
+ // ─── Readouts ────────────────────────────────────────────────────────────────
499
+ //
500
+ // Every figure below is read back off the ELEMENT rather than recomputed from
501
+ // panel state. Recomputing here would only ever confirm the panel agrees with
502
+ // itself; the point is to see what the component derived.
503
+
504
+ const n = (v: number | undefined): string => (v == null ? '—' : Math.round(v).toLocaleString());
505
+
506
+ const describeGate = (): string => {
507
+ const tokens = assistant.contextTokens;
508
+ const limit = assistant.contextBudget.limit;
509
+ const pct = tokens != null && limit ? ` (${Math.round((tokens / limit) * 100)}%)` : '';
510
+ return `${assistant.contextGate} — ${n(tokens)} / ${n(limit)}${pct}`;
511
+ };
512
+
513
+ const describeThresholds = (): string => {
514
+ const b = assistant.contextBudget;
515
+ if (!b.enabled) return 'gate off';
516
+ return `warn ${n(b.warnAt)} · block ${n(b.blockAt)} · abort ${n(b.abortAt)} · tail ${n(b.tailTokenBudget)}`;
517
+ };
518
+
519
+ /**
520
+ * How far a manual fill may drift from the measured transcript before the
521
+ * readout calls it out. Generous — the two are never expected to match exactly,
522
+ * and only an order-of-magnitude disagreement misleads.
523
+ */
524
+ const FILL_DRIFT_WARN_TOKENS = 2_000;
525
+
526
+ const describeTranscript = (): string => {
527
+ const measured = estimateContextTokens(assistant.messages);
528
+ const claimed = assistant.contextTokens;
529
+ const drift =
530
+ state.contextSource === 'manual' &&
531
+ claimed != null &&
532
+ Math.abs(claimed - measured) > FILL_DRIFT_WARN_TOKENS
533
+ ? ` ⚠ fill claims ${n(claimed)} — the gate and the planner are looking at different conversations`
534
+ : '';
535
+ return `${assistant.messages.length} msgs · ~${n(measured)} tokens${drift}`;
536
+ };
537
+
538
+ const describePlan = (): string => {
539
+ const plan = assistant.compactionPlan;
540
+ if (!plan) return `none — ${assistant.compactHint}`;
541
+ return `cut @${plan.cut} · −${n(plan.reclaimed)} → ${n(plan.tokensAfter)}`;
542
+ };
543
+
293
544
  // ─── Control panel ───────────────────────────────────────────────────────────
294
545
 
295
546
  const GROUPS: ReadonlyArray<ControlGroup<SandboxState>> = [
@@ -384,7 +635,12 @@ const GROUPS: ReadonlyArray<ControlGroup<SandboxState>> = [
384
635
  label: 'Persistence provider configured',
385
636
  hint: 'The menu is gated on a provider — with none, ⋮ never renders.',
386
637
  },
387
- { kind: 'toggle', key: 'showSessionMenu', label: 'chatConfig.ui.showSessionMenu' },
638
+ {
639
+ kind: 'toggle',
640
+ key: 'showSessionMenu',
641
+ label: 'chatConfig.ui.showSessionMenu',
642
+ hint: 'Turn this off (or the provider above) and push the fill past the warning — the gate forces ⋮ back on screen, showing Compact only.',
643
+ },
388
644
  {
389
645
  kind: 'action',
390
646
  label: 'Open / close session menu',
@@ -393,6 +649,104 @@ const GROUPS: ReadonlyArray<ControlGroup<SandboxState>> = [
393
649
  },
394
650
  ],
395
651
  },
652
+ {
653
+ title: 'Context window (GENC-1567)',
654
+ controls: [
655
+ {
656
+ kind: 'select',
657
+ key: 'contextPreset',
658
+ label: 'Jump to state',
659
+ featured: true,
660
+ hint: 'Sets the whole group below to land on that state. It is a shortcut, not a readout — once you move anything yourself, the Gate line is what tells you where you actually are.',
661
+ options: [
662
+ ...Object.entries(CONTEXT_PRESETS).map(([value, { label }]) => ({ value, label })),
663
+ { value: CUSTOM_PRESET, label: 'Custom (hand-tuned)' },
664
+ ],
665
+ },
666
+ {
667
+ kind: 'range',
668
+ key: 'contextWindow',
669
+ label: 'Window (tokens)',
670
+ min: 10_000,
671
+ max: 200_000,
672
+ step: 5_000,
673
+ hint: 'What the stub provider reports. Small on purpose — at a real 200k window the block sits at 175k, and seeding a transcript that big makes the page miserable.',
674
+ },
675
+ {
676
+ kind: 'select',
677
+ key: 'contextSource',
678
+ label: 'Fill from',
679
+ hint: 'The stub reports no usage, so this has to be simulated. `auto` measures the seeded transcript with the real estimator — gate and compaction planner then agree. `manual` can disagree with the transcript on purpose; watch the readouts.',
680
+ options: [
681
+ { value: 'auto', label: 'Transcript (measured)' },
682
+ { value: 'manual', label: 'Manual %' },
683
+ { value: 'none', label: 'Unset (no usage reported)' },
684
+ ],
685
+ },
686
+ {
687
+ kind: 'range',
688
+ key: 'contextFill',
689
+ label: 'Manual fill %',
690
+ min: 0,
691
+ max: 100,
692
+ hint: 'Only used when "Fill from" is Manual.',
693
+ },
694
+ { kind: 'toggle', key: 'contextGateEnabled', label: 'chatConfig.context.enabled' },
695
+ {
696
+ kind: 'range',
697
+ key: 'reserveTokens',
698
+ label: 'Reserve (tokens)',
699
+ min: 1_000,
700
+ max: 40_000,
701
+ step: 1_000,
702
+ hint: 'Headroom kept free for one turn. Block sits one reserve below the window.',
703
+ },
704
+ {
705
+ kind: 'range',
706
+ key: 'warnMultiplier',
707
+ label: 'Warn multiplier',
708
+ min: 1,
709
+ max: 4,
710
+ step: 0.5,
711
+ hint: 'How many reserves ahead of the block the warning appears.',
712
+ },
713
+ {
714
+ kind: 'toggle',
715
+ key: 'flowInProgress',
716
+ label: 'Flow in progress',
717
+ hint: 'Sets a flow owner. A block downgrades to a warning while set, so a flow can always finish.',
718
+ },
719
+ {
720
+ kind: 'readout',
721
+ label: 'Gate',
722
+ render: () => describeGate(),
723
+ hint: 'Derived by the component, not the panel — this is the check.',
724
+ },
725
+ {
726
+ kind: 'readout',
727
+ label: 'Thresholds',
728
+ render: () => describeThresholds(),
729
+ },
730
+ {
731
+ kind: 'readout',
732
+ label: 'Transcript',
733
+ render: () => describeTranscript(),
734
+ hint: 'What the estimator actually measures. If this is far below the fill, a manual fill is claiming a conversation that is not there.',
735
+ },
736
+ {
737
+ kind: 'readout',
738
+ label: 'Compaction plan',
739
+ render: () => describePlan(),
740
+ hint: 'What Compact would actually reclaim. `none` here is why the button greys out.',
741
+ },
742
+ {
743
+ kind: 'action',
744
+ label: 'Compact now',
745
+ run: () => void assistant.compactSession(),
746
+ hint: 'Bypasses the menu. Seed `loop` or `huge` first — a small transcript is correctly not worth compacting.',
747
+ },
748
+ ],
749
+ },
396
750
  {
397
751
  title: 'Simulated state',
398
752
  controls: [
@@ -424,6 +778,9 @@ const GROUPS: ReadonlyArray<ControlGroup<SandboxState>> = [
424
778
  { value: 'short', label: 'Short' },
425
779
  { value: 'rich', label: 'Tools, markdown, agent switch' },
426
780
  { value: 'long', label: 'Long (28 turns)' },
781
+ { value: 'huge', label: 'Huge (~20k tokens)' },
782
+ { value: 'loop', label: 'Tool loop (~22k, no user turns inside)' },
783
+ { value: 'wall', label: 'Wall (~24k in one indivisible tool result)' },
427
784
  ],
428
785
  },
429
786
  {
@@ -480,10 +837,10 @@ const GROUPS: ReadonlyArray<ControlGroup<SandboxState>> = [
480
837
  kind: 'range',
481
838
  key: 'width',
482
839
  label: 'Width',
483
- min: 260,
840
+ min: MIN_STAGE_WIDTH,
484
841
  max: 1600,
485
842
  step: 10,
486
- hint: 'The composer floats free of the transcript above 1500px (a container query).',
843
+ hint: 'Floors at 400px — the narrowest supported composer, and what the blocked-state copy is sized against. The composer floats free of the transcript above 1500px (a container query).',
487
844
  },
488
845
  { kind: 'range', key: 'height', label: 'Height', min: 320, max: 1000, step: 10 },
489
846
  {
@@ -514,12 +871,32 @@ const GROUPS: ReadonlyArray<ControlGroup<SandboxState>> = [
514
871
  },
515
872
  ];
516
873
 
517
- const paint = (): void => renderControls(panel, GROUPS, state, () => apply());
874
+ /**
875
+ * Replaced by `paint()`. A no-op until then because `mountAssistant()` calls
876
+ * `apply()` before the panel exists.
877
+ */
878
+ let refreshReadouts: () => void = () => {};
879
+
880
+ const paint = (): void => {
881
+ refreshReadouts = renderControls(panel, GROUPS, state, (_s, key) => {
882
+ if (key === 'contextPreset') {
883
+ // Expand it, then let `apply()`'s refresh push the rewritten values back
884
+ // into the controls. No rebuild — that would drop focus and collapse any
885
+ // group the user had opened.
886
+ applyContextPreset(state);
887
+ } else if ((CONTEXT_PRESET_KEYS as readonly string[]).includes(key ?? '')) {
888
+ // Hand-tuned now, so stop claiming a preset. Reaching 'custom' is also what
889
+ // makes the next preset pick a real `change` event on the select.
890
+ state.contextPreset = CUSTOM_PRESET;
891
+ }
892
+ apply();
893
+ });
894
+ };
518
895
 
519
896
  // ─── Viewport presets + live size readout ────────────────────────────────────
520
897
 
521
898
  const PRESETS: ReadonlyArray<{ label: string; width: number; height: number }> = [
522
- { label: 'Bubble 360', width: 360, height: 560 },
899
+ { label: 'Bubble 400', width: MIN_STAGE_WIDTH, height: 560 },
523
900
  { label: 'Panel 480', width: 480, height: 760 },
524
901
  { label: 'Dialog 640', width: 640, height: 720 },
525
902
  { label: 'Wide 1000', width: 1000, height: 800 },