@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
package/sandbox/README.md CHANGED
@@ -28,10 +28,11 @@ The panel covers, roughly in the order you'll want them:
28
28
  | **Composer** | `acceptedFiles` (the paperclip), attachment + rejected-file chips, the resize handle, `hideComposer`, `hideStopButton`, the placeholder |
29
29
  | **Agent picker & pinning** | `picker.mode`, how many agents exist, whether they opt in to manual selection, pin colours; a button to open the picker panel |
30
30
  | **Session menu (⋮)** | Whether a persistence provider is configured (the menu is gated on one) and `showSessionMenu`; a button to open the menu |
31
+ | **Context window** | **Jump to state** (the four gate states in one click), the simulated window and fill, `chatConfig.context`, a flow-in-progress toggle, live readouts of the gate / thresholds / compaction plan, and Compact |
31
32
  | **Simulated state** | Forces `idle` / busy / cancelling / blocked / compacting / restoring onto the element |
32
- | **Transcript** | Seeds an empty, short, tool-and-markdown-heavy, or 28-turn conversation |
33
+ | **Transcript** | Seeds an empty, short, tool-and-markdown-heavy, 28-turn, ~20k-token, or ~22k-token tool-loop conversation |
33
34
  | **Chrome** | Header title, popout mode, suggestions, the tool-call / reasoning / narration toggles, animations |
34
- | **Stage** | Frame size, light/dark, remount, full reset |
35
+ | **Stage** | Frame size (floored at 400px — see below), light/dark, remount, full reset |
35
36
 
36
37
  Panel state persists in `localStorage`, so a reload picks up where you left off. **Reset sandbox**
37
38
  clears it.
@@ -44,8 +45,93 @@ composer all behave as they do against a live model. It also answers the orchest
44
45
  classifier call, round-robining across the configured agents so agent switching — and the
45
46
  "Switched to X" divider — actually happens.
46
47
 
47
- It is not a model. It ignores the system prompt, never calls a tool, and `prompt()` (suggestions,
48
- summarisation) returns nothing — so **Compact** runs but produces no summary.
48
+ It is not a model. It ignores the system prompt and never calls a tool. It *does* answer the
49
+ compaction summariser, so **Compact** works end to end — the reply is canned, but the plan, the
50
+ splice, the divider and the shrunken context are all real. Suggestions still get nothing.
51
+
52
+ ## Checking the context gate (GENC-1567)
53
+
54
+ The gate warns as the window fills and blocks sending before it overflows. Two things make it
55
+ awkward to exercise honestly, and the panel works around both:
56
+
57
+ - **Nothing reports usage.** The stub returns no `inputTokens`, so `contextTokens` would never be
58
+ set and the gate would sit inert forever. **Fill from** decides how it is faked. `Transcript
59
+ (measured)` — the default — runs the component's own estimator over the seeded conversation, so
60
+ the gate and the compaction planner are looking at the same thing. `Manual %` forces a
61
+ percentage, and `Unset` leaves it undefined, which is the real state on a provider that reports
62
+ no usage and where the gate must stay quiet rather than guess.
63
+
64
+ A manual fill can **disagree with the transcript**, and that is worth understanding rather than
65
+ avoiding: claim 85% of the window while the transcript holds 250 tokens and the gate says "full"
66
+ while the planner correctly reports nothing worth compacting — so the blocked copy offers a new
67
+ chat instead of Compact. The component is right about an impossible situation. The **Transcript**
68
+ readout flags the mismatch so it is visible rather than mystifying.
69
+ - **A real window is too big to fill by hand.** At 200k the block sits at 175k, and a transcript
70
+ that large makes the page miserable to work in. **Window (tokens)** shrinks it. It is written to
71
+ the stub provider as well as the element, because the element re-resolves its limit from the
72
+ active provider and would otherwise silently revert a value poked straight onto it.
73
+
74
+ The three readouts are the actual check. Every figure in them is read back **off the element** —
75
+ `contextGate`, `contextBudget`, `compactionPlan`, `compactHint` — rather than recomputed by the
76
+ panel, which would only ever confirm the panel agrees with itself.
77
+
78
+ ### Jump to state
79
+
80
+ The fastest way in. **Jump to state** sets the whole group below to land on one of four:
81
+
82
+ | | What you get |
83
+ | --- | --- |
84
+ | **Normal** | Plenty of room. No label, no outline. |
85
+ | **Warning** | Amber pill reading "Approaching context limit", amber composer outline, sending still works. |
86
+ | **Blocked — compacting will clear it** | Red pill reading "At context limit", red outline, composer disabled, and the placeholder offers **Compact**. |
87
+ | **Blocked — compacting will NOT clear it** | The same red treatment, but the placeholder offers a **new chat** instead. |
88
+
89
+ The last two are the pair worth understanding, because the difference is not visible in the panel:
90
+ it is whether a compaction would reclaim enough to be worth offering. The compactable one seeds a
91
+ long tool loop, which has cuttable boundaries all through it. The stuck one seeds a single tool
92
+ result larger than any tail budget — a cut cannot split a message, so no boundary anywhere shrinks
93
+ it, and the component correctly stops recommending Compact. One oversized file read does this in a
94
+ real conversation.
95
+
96
+ Each preset drives the ordinary controls rather than forcing the element, so what you land on is
97
+ the component deciding for itself. Move anything by hand afterwards and the dropdown drops to
98
+ **Custom** — it is a shortcut, not a readout, and the **Gate** line is what tells you where you
99
+ actually are.
100
+
101
+ ### The rest of the panel
102
+
103
+ A run worth doing:
104
+
105
+ 1. Seed **Tool loop**. One user turn, then 24 tool-call/result pairs and no user message anywhere
106
+ inside — the shape the old compaction rule could not touch at all, because it could only cut at
107
+ a user boundary and there was none to cut at. The plan readout should show a real reclaim with
108
+ the cut landing inside the loop (around message 23 of 50, at the defaults).
109
+ 2. On the default **Transcript (measured)** fill, that seed alone lands you at the block — no
110
+ slider needed. The ⋮ pill grows an amber "Approaching context limit" label at the warning and a
111
+ red "At context limit" at the block, the composer picks up a matching outline from the warning
112
+ onwards, and at the block it disables with the full explanation in its placeholder. Seed
113
+ **Huge** for the warning state and **Tools, markdown, agent switch** to drop back to clear.
114
+ 3. Turn **`showSessionMenu`** off (or the persistence provider), still past the warning. The gate
115
+ forces ⋮ back on screen — and the menu shows **Compact only**, no Clear.
116
+ 4. Turn **Flow in progress** on at a blocking fill. The block downgrades to a warning and sending
117
+ works again: a flow always gets to finish.
118
+ 5. Hit **Compact**. Watch the transcript collapse to a divider plus the tail, and the readouts drop
119
+ back under the warning line.
120
+
121
+ **Thresholds** also shows `abort`, the point at which a *running* turn is stopped mid-loop. It sits
122
+ far above `block` on purpose: the reserve exists so an accepted turn can spend it, and a guard at
123
+ the block line would kill every turn that used the headroom it was given.
124
+
125
+ ## 400px is the narrowest supported width
126
+
127
+ The stage will not go below it — slider, drag handle and a restored panel state are all clamped —
128
+ because the assistant is not built narrower than that. The blocked-state placeholder is the case
129
+ that pins it: it carries two sentences, and the height floor that stops them being clipped is sized
130
+ from measurement at 400px. Narrower, the message wraps further and clips again.
131
+
132
+ If you need to know the real requirement at some width, measure it rather than eyeballing a
133
+ screenshot — `scrollHeight > clientHeight` on the inner `textarea`. A line that is 8px short looks
134
+ complete.
49
135
 
50
136
  ## Things worth knowing
51
137
 
@@ -59,6 +145,9 @@ summarisation) returns nothing — so **Compact** runs but produces no summary.
59
145
  seed simply re-applies. **Reset sandbox** clears both.
60
146
  - **"Simulated state" is a blunt instrument.** It writes `state` / `blocked` / `compacting` /
61
147
  `restoring` straight onto the element. A real send will move `state` again underneath it.
148
+ - **Compact is correctly a no-op on a small transcript.** Compaction is sized in tokens now, so ten
149
+ short turns are not worth a summariser call and the button stays greyed out. The plan readout says
150
+ why. Seed **Tool loop** or **Huge** to give it something to do.
62
151
  - **The panel is deliberately plain HTML.** It has to stay usable while the design-system components
63
152
  it would otherwise be built from are the ones being edited, and it sits outside the design system
64
153
  provider so a theme change on the stage can't restyle it.
@@ -15,6 +15,8 @@ export type ControlDef<S> =
15
15
  label: string;
16
16
  options: ReadonlyArray<{ value: string; label: string }>;
17
17
  hint?: string;
18
+ /** Lifts the row out of the list — for the one control a group is really about. */
19
+ featured?: boolean;
18
20
  }
19
21
  | { kind: 'text'; key: keyof S & string; label: string; placeholder?: string; hint?: string }
20
22
  | {
@@ -26,7 +28,14 @@ export type ControlDef<S> =
26
28
  step?: number;
27
29
  hint?: string;
28
30
  }
29
- | { kind: 'action'; label: string; run: () => void; hint?: string };
31
+ | { kind: 'action'; label: string; run: () => void; hint?: string }
32
+ /**
33
+ * A read-only line whose text is recomputed on demand rather than bound to
34
+ * state — for showing what the component DERIVED, which is the only way to
35
+ * check a derivation by hand. `renderControls` returns a refresh function; call
36
+ * it after applying state.
37
+ */
38
+ | { kind: 'readout'; label: string; render: () => string; hint?: string };
30
39
 
31
40
  export interface ControlGroup<S> {
32
41
  title: string;
@@ -61,16 +70,31 @@ const withHint = (row: HTMLElement, hint?: string): HTMLElement => {
61
70
 
62
71
  /**
63
72
  * Renders `groups` into `host`, reading from and writing to `state`. `onChange`
64
- * fires after every edit with the mutated state — the caller re-applies it to
65
- * the assistant.
73
+ * fires after every edit with the mutated state and the key that changed — the
74
+ * caller re-applies it to the assistant.
75
+ *
76
+ * Returns a `refresh()` that re-reads every control's value back out of `state`
77
+ * and recomputes the readouts. That is what lets one control REWRITE others (a
78
+ * preset, say) without rebuilding the panel: a rebuild drops focus mid-drag and
79
+ * collapses any group the user had open.
66
80
  */
67
81
  export function renderControls<S extends Record<string, any>>(
68
82
  host: HTMLElement,
69
83
  groups: ReadonlyArray<ControlGroup<S>>,
70
84
  state: S,
71
- onChange: (state: S) => void,
72
- ): void {
85
+ onChange: (state: S, key?: keyof S & string) => void,
86
+ ): () => void {
73
87
  host.textContent = '';
88
+ const refreshers: Array<() => void> = [];
89
+
90
+ /**
91
+ * Push `state` back into a control, unless the user is currently interacting
92
+ * with it — re-writing the value of a range being dragged fights the drag.
93
+ */
94
+ const syncing = (node: HTMLElement, write: () => void) => () => {
95
+ if (document.activeElement === node) return;
96
+ write();
97
+ };
74
98
 
75
99
  for (const group of groups) {
76
100
  const details = el('details', 'group');
@@ -91,7 +115,25 @@ export function renderControls<S extends Record<string, any>>(
91
115
  continue;
92
116
  }
93
117
 
94
- const row = el('label', `row row-${control.kind}`);
118
+ if (control.kind === 'readout') {
119
+ const row = el('div', 'row row-readout');
120
+ const name = el('span', 'label');
121
+ name.textContent = control.label;
122
+ const value = el('output', 'readout-value');
123
+ const refresh = () => {
124
+ value.textContent = control.render();
125
+ };
126
+ refresh();
127
+ refreshers.push(refresh);
128
+ row.append(name, value);
129
+ details.appendChild(withHint(row, control.hint));
130
+ continue;
131
+ }
132
+
133
+ const row = el(
134
+ 'label',
135
+ `row row-${control.kind}${'featured' in control && control.featured ? ' row-featured' : ''}`,
136
+ );
95
137
  const name = el('span', 'label');
96
138
  name.textContent = control.label;
97
139
 
@@ -101,8 +143,13 @@ export function renderControls<S extends Record<string, any>>(
101
143
  input.checked = Boolean(state[control.key]);
102
144
  input.addEventListener('change', () => {
103
145
  (state as any)[control.key] = input.checked;
104
- onChange(state);
146
+ onChange(state, control.key);
105
147
  });
148
+ refreshers.push(
149
+ syncing(input, () => {
150
+ input.checked = Boolean(state[control.key]);
151
+ }),
152
+ );
106
153
  row.append(input, name);
107
154
  } else if (control.kind === 'select') {
108
155
  const select = el('select');
@@ -115,8 +162,13 @@ export function renderControls<S extends Record<string, any>>(
115
162
  select.value = String(state[control.key]);
116
163
  select.addEventListener('change', () => {
117
164
  (state as any)[control.key] = select.value;
118
- onChange(state);
165
+ onChange(state, control.key);
119
166
  });
167
+ refreshers.push(
168
+ syncing(select, () => {
169
+ select.value = String(state[control.key]);
170
+ }),
171
+ );
120
172
  row.append(name, select);
121
173
  } else if (control.kind === 'text') {
122
174
  const input = el('input');
@@ -125,8 +177,13 @@ export function renderControls<S extends Record<string, any>>(
125
177
  input.value = String(state[control.key] ?? '');
126
178
  input.addEventListener('input', () => {
127
179
  (state as any)[control.key] = input.value;
128
- onChange(state);
180
+ onChange(state, control.key);
129
181
  });
182
+ refreshers.push(
183
+ syncing(input, () => {
184
+ input.value = String(state[control.key] ?? '');
185
+ }),
186
+ );
130
187
  row.append(name, input);
131
188
  } else {
132
189
  const input = el('input');
@@ -140,8 +197,14 @@ export function renderControls<S extends Record<string, any>>(
140
197
  input.addEventListener('input', () => {
141
198
  readout.textContent = input.value;
142
199
  (state as any)[control.key] = Number(input.value);
143
- onChange(state);
200
+ onChange(state, control.key);
144
201
  });
202
+ refreshers.push(
203
+ syncing(input, () => {
204
+ input.value = String(state[control.key] ?? control.min);
205
+ readout.textContent = input.value;
206
+ }),
207
+ );
145
208
  name.appendChild(readout);
146
209
  row.append(name, input);
147
210
  }
@@ -151,4 +214,8 @@ export function renderControls<S extends Record<string, any>>(
151
214
 
152
215
  host.appendChild(details);
153
216
  }
217
+
218
+ return () => {
219
+ for (const refresh of refreshers) refresh();
220
+ };
154
221
  }
@@ -206,11 +206,128 @@ const LONG: ChatMessage[] = stamped(
206
206
  ]).flat(),
207
207
  );
208
208
 
209
+ /**
210
+ * The same chars-per-token approximation the estimator falls back to, so a
211
+ * fixture asking for N tokens lands near N on the component's own reckoning.
212
+ */
213
+ const APPROX_CHARS_PER_TOKEN = 4;
214
+
215
+ /** Default window the stub reports, matching a mid-size production model. */
216
+ const DEFAULT_CONTEXT_LIMIT = 200_000;
217
+
218
+ /**
219
+ * A paragraph of filler sized in tokens. Prose rather than repeated characters
220
+ * so the transcript stays readable while you scroll it.
221
+ */
222
+ const bulk = (tokens: number): string => {
223
+ const sentence =
224
+ 'The position was reconciled against the custodian file and the residual break was ' +
225
+ 'allocated to the rates book before the cutoff, with the remainder carried forward. ';
226
+ return sentence.repeat(
227
+ Math.max(1, Math.ceil((tokens * APPROX_CHARS_PER_TOKEN) / sentence.length)),
228
+ );
229
+ };
230
+
231
+ /**
232
+ * A transcript large enough to actually be worth compacting (GENC-1567).
233
+ *
234
+ * `long` is 28 turns but only a few hundred tokens, and since compaction became
235
+ * token-driven that is correctly NOT worth a summariser call — so it can no
236
+ * longer demonstrate Compact. This one carries real weight per turn.
237
+ */
238
+ const HUGE_TURNS = 16;
239
+ const HUGE_TOKENS_PER_TURN = 1_200;
240
+
241
+ const HUGE: ChatMessage[] = stamped(
242
+ Array.from({ length: HUGE_TURNS }, (_, i) => [
243
+ user(`Walk me through the breaks on book ${i + 1} in detail.`),
244
+ assistant(`Book ${i + 1}. ${bulk(HUGE_TOKENS_PER_TURN)}`, 'Positions'),
245
+ ]).flat(),
246
+ );
247
+
248
+ /**
249
+ * One user turn followed by a long agentic tool loop — the shape GENC-1567 was
250
+ * written for, and the one to reach for when checking the cut.
251
+ *
252
+ * There is no `user` message anywhere inside the loop, which is exactly why the
253
+ * old cut rule could not touch it: cutting only at a user boundary meant the
254
+ * whole loop was one indivisible block, so compaction reclaimed nothing however
255
+ * large it grew. Seed this, watch the plan report a real reclaim, and Compact —
256
+ * the summary should land with the loop behind it rather than in front.
257
+ */
258
+ const LOOP_ITERATIONS = 24;
259
+ const LOOP_RESULT_TOKENS = 900;
260
+
261
+ const LOOP: ChatMessage[] = stamped([
262
+ user('Reconcile every book for yesterday and tell me what broke.'),
263
+ ...Array.from({ length: LOOP_ITERATIONS }, (_, i) => [
264
+ {
265
+ role: 'assistant' as const,
266
+ content: '',
267
+ agentName: 'Reconciliation',
268
+ toolCalls: [
269
+ {
270
+ id: `loop_${i}`,
271
+ name: 'reconcile_book',
272
+ args: { book: `BOOK-${i + 1}`, asOf: '2026-08-25' },
273
+ },
274
+ ],
275
+ },
276
+ {
277
+ role: 'tool' as const,
278
+ content: '',
279
+ agentName: 'Reconciliation',
280
+ toolResult: {
281
+ toolCallId: `loop_${i}`,
282
+ content: `BOOK-${i + 1} reconciliation detail. ${bulk(LOOP_RESULT_TOKENS)}`,
283
+ },
284
+ },
285
+ ]).flat(),
286
+ assistant('All books reconciled. Three had breaks worth reporting.', 'Reconciliation'),
287
+ ]);
288
+
289
+ /**
290
+ * Over the limit and genuinely **uncompactable** — the case where the gate has to
291
+ * admit compaction is not the answer (GENC-1567).
292
+ *
293
+ * One user turn and a single tool result far larger than any tail budget. A cut
294
+ * cannot split a message, so there is no boundary anywhere that shrinks this:
295
+ * `findCompactionCut` walks back from the end, cannot fit even the one tool
296
+ * result, and returns `null`. The blocked copy then offers a new chat instead of
297
+ * Compact, which is the honest advice.
298
+ *
299
+ * Not a contrived shape either — one oversized file read or query result does
300
+ * exactly this in a real conversation.
301
+ */
302
+ const WALL_RESULT_TOKENS = 24_000;
303
+
304
+ const WALL: ChatMessage[] = stamped([
305
+ user('Read the whole reconciliation archive and summarise it.'),
306
+ {
307
+ role: 'assistant',
308
+ content: '',
309
+ agentName: 'Reconciliation',
310
+ toolCalls: [{ id: 'wall', name: 'read_archive', args: { path: '/archive/2026-08.csv' } }],
311
+ },
312
+ {
313
+ role: 'tool',
314
+ content: '',
315
+ agentName: 'Reconciliation',
316
+ toolResult: {
317
+ toolCallId: 'wall',
318
+ content: `Reconciliation archive. ${bulk(WALL_RESULT_TOKENS)}`,
319
+ },
320
+ },
321
+ ]);
322
+
209
323
  export const TRANSCRIPTS = {
210
324
  empty: [] as ChatMessage[],
211
325
  short: SHORT,
212
326
  rich: RICH,
213
327
  long: LONG,
328
+ huge: HUGE,
329
+ loop: LOOP,
330
+ wall: WALL,
214
331
  } as const;
215
332
 
216
333
  export type TranscriptName = keyof typeof TRANSCRIPTS;
@@ -266,7 +383,22 @@ const countAgentsInPrompt = (systemPrompt?: string): number =>
266
383
  export class SandboxAIProvider implements AIProvider {
267
384
  /** Round-trip delay: long enough to see the busy states, short enough not to annoy. */
268
385
  private static readonly LATENCY_MS = 1200;
269
- private static readonly CONTEXT_LIMIT = 200_000;
386
+
387
+ /**
388
+ * The window `getStatus` reports, and so the one every context threshold is
389
+ * derived from (GENC-1567).
390
+ *
391
+ * **Mutable, and the sandbox writes it before each apply.** Setting
392
+ * `assistant.contextLimit` alone would not hold: the element re-resolves the
393
+ * limit from the provider whenever the active provider changes, and would
394
+ * overwrite a value poked onto it directly. Driving the provider instead means
395
+ * the immediate value and any later re-resolve agree.
396
+ *
397
+ * Shrinking it is the practical way to exercise the gate: at a real 200k
398
+ * window the block sits at 175k, and seeding a transcript that large would
399
+ * make the page miserable to work in.
400
+ */
401
+ contextLimit = DEFAULT_CONTEXT_LIMIT;
270
402
 
271
403
  private turn = 0;
272
404
  private route = 0;
@@ -275,7 +407,7 @@ export class SandboxAIProvider implements AIProvider {
275
407
  return {
276
408
  provider: 'none',
277
409
  model: 'sandbox-stub',
278
- contextLimit: SandboxAIProvider.CONTEXT_LIMIT,
410
+ contextLimit: this.contextLimit,
279
411
  };
280
412
  }
281
413
 
@@ -307,9 +439,34 @@ export class SandboxAIProvider implements AIProvider {
307
439
  return { role: 'assistant', content: reply };
308
440
  }
309
441
 
310
- async prompt(): Promise<string> {
311
- // Side-calls (suggestion generation, summarisation) land here. There is
312
- // nothing useful to invent, so return nothing and let the caller skip.
313
- return '';
442
+ /**
443
+ * Side-calls land here — suggestion generation and, more usefully, compaction's
444
+ * one-shot summariser.
445
+ *
446
+ * It answers the summariser for real (GENC-1567) rather than returning nothing
447
+ * as it used to. That empty string made **Compact** a dead control in the
448
+ * sandbox: the driver treats an empty summary as a failure, leaves history
449
+ * untouched and appends its apology, so the one path worth exercising by hand
450
+ * could not be. The reply is canned, but everything around it — the plan, the
451
+ * splice, the divider, the shrunken context and the gate clearing — is real.
452
+ *
453
+ * Suggestions are told apart by their own prompt convention and still get
454
+ * nothing, since there is nothing useful to invent for those.
455
+ */
456
+ async prompt(userMessage: string, options?: { systemPrompt?: string }): Promise<string> {
457
+ const isSummary = /compress a conversation transcript/i.test(options?.systemPrompt ?? '');
458
+ if (!isSummary) return '';
459
+ await sleep(SandboxAIProvider.LATENCY_MS);
460
+ // Report the size actually handed over, so a hand-check of the compaction
461
+ // plan has something concrete to compare against.
462
+ const chars = userMessage.length;
463
+ return (
464
+ 'Earlier in this conversation the user worked through positions, breaks and ' +
465
+ 'reporting with the assistant, and a daily rates break report was scheduled. ' +
466
+ `(Sandbox summary standing in for ~${Math.round(
467
+ chars / APPROX_CHARS_PER_TOKEN,
468
+ ).toLocaleString()} tokens ` +
469
+ 'of compacted transcript.)'
470
+ );
314
471
  }
315
472
  }
@@ -120,7 +120,11 @@ body {
120
120
  .viewport {
121
121
  overflow: hidden;
122
122
  resize: both;
123
- min-width: 240px;
123
+
124
+ /* 400px is the narrowest supported composer — the blocked-state copy is sized
125
+ against it — so the drag handle stops there rather than letting the stage
126
+ reach widths the component is not built for. */
127
+ min-width: 400px;
124
128
  min-height: 240px;
125
129
  border: 1px solid var(--sb-line);
126
130
  border-radius: 6px;
@@ -210,6 +214,55 @@ rapid-design-system-provider {
210
214
  padding-bottom: 8px;
211
215
  }
212
216
 
217
+ /* The one control a group is really about — lifted out of the list so it reads as
218
+ the entry point rather than the first of eight equal rows. Deliberately quiet:
219
+ a tinted panel and an accent edge, not a colour that competes with the stage. */
220
+ .row-featured {
221
+ /* Content stays on the same left edge as every other row: the shared .row pads
222
+ 14px, and the 2px accent border here eats 2 of them, so the padding makes up
223
+ the remaining 12. Getting this wrong is immediately visible as a highlighted
224
+ block whose label is indented differently from its neighbours. */
225
+ margin: 0 0 6px;
226
+ padding: 8px 14px 8px 12px;
227
+ border-left: 2px solid var(--sb-accent);
228
+ background: color-mix(in srgb, var(--sb-accent) 8%, transparent);
229
+ }
230
+
231
+ .row-featured .label {
232
+ color: var(--sb-text);
233
+ font-weight: 600;
234
+ }
235
+
236
+ /* Stacked, not inline. The shared row gives a select a fixed 140px, which clips
237
+ these option labels to "Blocked — compacting will…" — losing the clause that
238
+ distinguishes the two blocked states, which is the whole reason they are
239
+ separate entries. Full width costs nothing here and keeps them readable. */
240
+ .row-featured {
241
+ flex-direction: column;
242
+ align-items: stretch;
243
+ }
244
+
245
+ .row-featured select {
246
+ flex: 1 1 auto;
247
+ width: 100%;
248
+ }
249
+
250
+ /* A derived figure read back off the component, not a control. Monospace and
251
+ wrapping, because these are numbers meant to be compared by eye and some of
252
+ them (the compact hint) are a whole sentence. */
253
+ .row-readout {
254
+ display: flex;
255
+ flex-direction: column;
256
+ gap: 2px;
257
+ }
258
+
259
+ .readout-value {
260
+ color: var(--sb-text);
261
+ font-family: ui-monospace, sfmono-regular, menlo, monospace;
262
+ font-size: 11px;
263
+ overflow-wrap: anywhere;
264
+ }
265
+
213
266
  .action {
214
267
  width: 100%;
215
268
  padding: 5px 10px;