@genesislcap/ai-assistant 15.4.1 → 15.6.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 (98) hide show
  1. package/dist/ai-assistant.api.json +571 -82
  2. package/dist/ai-assistant.d.ts +336 -36
  3. package/dist/chat-driver.cjs +104 -26
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +104 -26
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +314 -36
  8. package/dist/dts/components/chat-driver/chat-driver.d.ts +12 -0
  9. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  10. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  11. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +9 -2
  12. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  13. package/dist/dts/index.d.ts +1 -0
  14. package/dist/dts/index.d.ts.map +1 -1
  15. package/dist/dts/main/cost-session-banking.test.d.ts +2 -0
  16. package/dist/dts/main/cost-session-banking.test.d.ts.map +1 -0
  17. package/dist/dts/main/main.d.ts +184 -24
  18. package/dist/dts/main/main.d.ts.map +1 -1
  19. package/dist/dts/provider/assistant-app-settings.d.ts +30 -5
  20. package/dist/dts/provider/assistant-app-settings.d.ts.map +1 -1
  21. package/dist/dts/state/ai-assistant-slice.d.ts +11 -8
  22. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  23. package/dist/dts/state/persistence/session-persistence-provider.d.ts +23 -0
  24. package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
  25. package/dist/dts/state/persistence/session-snapshot.d.ts.map +1 -1
  26. package/dist/dts/state/session-store.d.ts +1 -2
  27. package/dist/dts/state/session-store.d.ts.map +1 -1
  28. package/dist/dts/styles/settings-section.d.ts +29 -0
  29. package/dist/dts/styles/settings-section.d.ts.map +1 -0
  30. package/dist/dts/utils/condense-history.d.ts +14 -0
  31. package/dist/dts/utils/condense-history.d.ts.map +1 -1
  32. package/dist/dts/utils/cost-session-history.d.ts +103 -12
  33. package/dist/dts/utils/cost-session-history.d.ts.map +1 -1
  34. package/dist/dts/utils/resolve-cost-history-config.d.ts +9 -3
  35. package/dist/dts/utils/resolve-cost-history-config.d.ts.map +1 -1
  36. package/dist/dts/utils/sum-costs.d.ts.map +1 -1
  37. package/dist/dts/utils/sum-tokens.d.ts +8 -8
  38. package/dist/dts/utils/sum-tokens.d.ts.map +1 -1
  39. package/dist/dts/utils/sum-usage.d.ts +59 -0
  40. package/dist/dts/utils/sum-usage.d.ts.map +1 -0
  41. package/dist/dts/utils/sum-usage.test.d.ts +2 -0
  42. package/dist/dts/utils/sum-usage.test.d.ts.map +1 -0
  43. package/dist/esm/components/chat-driver/chat-driver.js +9 -1
  44. package/dist/esm/components/settings-modal/settings-modal.styles.js +237 -18
  45. package/dist/esm/components/settings-modal/settings-modal.template.js +229 -73
  46. package/dist/esm/index.js +1 -0
  47. package/dist/esm/main/cost-session-banking.test.js +308 -0
  48. package/dist/esm/main/main.js +424 -71
  49. package/dist/esm/state/ai-assistant-slice.js +11 -8
  50. package/dist/esm/state/ai-assistant-slice.test.js +12 -5
  51. package/dist/esm/state/debug-event-log.js +2 -2
  52. package/dist/esm/state/persistence/session-persistence.integration.test.js +5 -1
  53. package/dist/esm/state/persistence/session-persister.js +2 -2
  54. package/dist/esm/state/persistence/session-persister.test.js +10 -1
  55. package/dist/esm/state/persistence/session-snapshot.js +6 -2
  56. package/dist/esm/state/persistence/session-snapshot.test.js +4 -1
  57. package/dist/esm/state/persistence/stateful-restore.e2e.test.js +10 -1
  58. package/dist/esm/styles/settings-section.js +39 -0
  59. package/dist/esm/utils/condense-history.js +18 -5
  60. package/dist/esm/utils/condense-history.test.js +67 -0
  61. package/dist/esm/utils/cost-session-history.js +92 -15
  62. package/dist/esm/utils/cost-session-history.test.js +155 -13
  63. package/dist/esm/utils/resolve-cost-history-config.js +2 -1
  64. package/dist/esm/utils/sum-costs.js +2 -13
  65. package/dist/esm/utils/sum-tokens.js +10 -27
  66. package/dist/esm/utils/sum-tokens.test.js +1 -5
  67. package/dist/esm/utils/sum-usage.js +123 -0
  68. package/dist/esm/utils/sum-usage.test.js +120 -0
  69. package/dist/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +17 -17
  71. package/src/components/chat-driver/chat-driver.ts +21 -0
  72. package/src/components/settings-modal/settings-modal.styles.ts +237 -18
  73. package/src/components/settings-modal/settings-modal.template.ts +270 -81
  74. package/src/index.ts +1 -0
  75. package/src/main/cost-session-banking.test.ts +407 -0
  76. package/src/main/main.ts +433 -68
  77. package/src/provider/assistant-app-settings.ts +31 -5
  78. package/src/state/ai-assistant-slice.test.ts +12 -5
  79. package/src/state/ai-assistant-slice.ts +21 -13
  80. package/src/state/debug-event-log.ts +2 -2
  81. package/src/state/persistence/session-persistence-provider.ts +24 -0
  82. package/src/state/persistence/session-persistence.integration.test.ts +8 -1
  83. package/src/state/persistence/session-persister.test.ts +10 -1
  84. package/src/state/persistence/session-persister.ts +2 -2
  85. package/src/state/persistence/session-snapshot.test.ts +4 -1
  86. package/src/state/persistence/session-snapshot.ts +5 -1
  87. package/src/state/persistence/stateful-restore.e2e.test.ts +9 -1
  88. package/src/styles/settings-section.ts +40 -0
  89. package/src/utils/condense-history.test.ts +103 -0
  90. package/src/utils/condense-history.ts +33 -3
  91. package/src/utils/cost-session-history.test.ts +187 -16
  92. package/src/utils/cost-session-history.ts +142 -23
  93. package/src/utils/resolve-cost-history-config.ts +10 -3
  94. package/src/utils/sum-costs.ts +2 -9
  95. package/src/utils/sum-tokens.test.ts +1 -11
  96. package/src/utils/sum-tokens.ts +10 -26
  97. package/src/utils/sum-usage.test.ts +140 -0
  98. package/src/utils/sum-usage.ts +130 -0
@@ -65,6 +65,7 @@ import {
65
65
  normalizeForProvider,
66
66
  } from '../../utils/history-transform';
67
67
  import { logger } from '../../utils/logger';
68
+ import { sumUsage } from '../../utils/sum-usage';
68
69
  import { TOOL_FOLD_SYMBOL, type ToolFold } from '../../utils/tool-fold';
69
70
  import type { AiDriver, AllAgentSummary } from '../ai-driver/ai-driver';
70
71
 
@@ -217,6 +218,16 @@ export interface ChatDriverConfig {
217
218
  maxToolIterations?: number;
218
219
  /** Hard cap on fold operations. Default `5`. */
219
220
  maxFoldOperations?: number;
221
+ /**
222
+ * Collapse `condenseWhen` payloads in batches of this many model-calls rather than as
223
+ * soon as each trigger fires. A positive integer; decimals are floored and anything
224
+ * below `1` becomes `1`. Default `1` — collapse immediately, the historical behaviour.
225
+ *
226
+ * Condensation rewrites history in place, so collapsing continuously breaks the
227
+ * provider's prompt cache on nearly every call of a re-read loop. Raising this trades
228
+ * up to that many calls' worth of extra context for one cache break per batch.
229
+ */
230
+ condenseBatchCalls?: number;
220
231
  /** Ring-buffer size for per-turn snapshots. Default `400`. */
221
232
  maxTurnSnapshots?: number;
222
233
  /** Session identity used to file meta events onto the shared debug-log timeline. */
@@ -565,6 +576,8 @@ export class ChatDriver extends EventTarget implements AiDriver {
565
576
 
566
577
  /** Hard cap on tool-loop iterations. */
567
578
  private readonly maxToolIterations: number;
579
+ /** Model-calls per condensation batch; `1` collapses as soon as a trigger fires. */
580
+ private readonly condenseBatchCalls: number;
568
581
  /** Session identity used to file meta events onto the shared debug-log timeline. */
569
582
  private readonly sessionKey: string;
570
583
  /** Injected activity bus; defaults to a no-op off-browser (Node/tests/headless). */
@@ -582,11 +595,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
582
595
  primerHistory,
583
596
  maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS,
584
597
  maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS,
598
+ condenseBatchCalls = 1,
585
599
  maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS,
586
600
  sessionKey = '',
587
601
  activityBus = NOOP_ACTIVITY_BUS,
588
602
  } = config;
589
603
  this.maxToolIterations = maxToolIterations;
604
+ this.condenseBatchCalls = condenseBatchCalls;
590
605
  this.sessionKey = sessionKey;
591
606
  this.activityBus = activityBus;
592
607
  if (typeof toolHandlers === 'function') {
@@ -1299,6 +1314,11 @@ export class ChatDriver extends EventTarget implements AiDriver {
1299
1314
  tokensBefore,
1300
1315
  model: status?.model,
1301
1316
  createdAt,
1317
+ // Bank what the replaced turns spent. `toCompact` is dropped from history
1318
+ // below, taking its per-message cost/token fields with it — so a host total
1319
+ // re-derived from the transcript would otherwise fall by exactly this much
1320
+ // on every compaction. Summarising a turn does not refund what it cost.
1321
+ rolledUpUsage: sumUsage(toCompact),
1302
1322
  },
1303
1323
  };
1304
1324
 
@@ -2329,6 +2349,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
2329
2349
  // A call's phase has ended once the app declared a later phase epoch
2330
2350
  // current via `endPhase()`.
2331
2351
  phaseEnded: (phaseEpoch) => phaseEpoch < this.currentPhaseEpoch,
2352
+ batchCalls: this.condenseBatchCalls,
2332
2353
  },
2333
2354
  (detail) => recordMetaEvent(this.sessionKey, 'context.condensed', detail),
2334
2355
  );
@@ -1,7 +1,14 @@
1
1
  import { css } from '@genesislcap/web-core';
2
+ import { assistantSettingsSectionTitleStyles } from '../../styles/settings-section';
2
3
 
3
4
  /** Styles for the assistant settings modal (tabs, sections, toggles, cost summary). */
4
5
  export const settingsModalStyles = css`
6
+ /*
7
+ * Section headings come from the shared fragment the package also exports, so a host titling
8
+ * its own slotted section applies the same rule rather than a copy of it.
9
+ */
10
+ ${assistantSettingsSectionTitleStyles}
11
+
5
12
  *,
6
13
  *::before,
7
14
  *::after {
@@ -116,16 +123,13 @@ export const settingsModalStyles = css`
116
123
  border-bottom: none;
117
124
  }
118
125
 
119
- .settings-modal-section-title {
126
+ /*
127
+ * Only the spacing. Everything else about this heading comes from the exported fragment
128
+ * above, which the assistant and its hosts share; margin is deliberately not in there,
129
+ * since the gap below a heading depends on the layout around it.
130
+ */
131
+ .ai-settings-section-title {
120
132
  margin: 0 0 calc(var(--design-unit) * 3px);
121
- font-size: 11px;
122
- font-weight: 600;
123
- letter-spacing: 0.08em;
124
- text-transform: uppercase;
125
- color: var(--neutral-foreground-hint);
126
- display: flex;
127
- align-items: center;
128
- gap: calc(var(--design-unit) * 2px);
129
133
  }
130
134
 
131
135
  .settings-modal-section-note {
@@ -159,6 +163,15 @@ export const settingsModalStyles = css`
159
163
  gap: calc(var(--design-unit) * 2px);
160
164
  }
161
165
 
166
+ /*
167
+ * For rows whose control is a button rather than a switch. The base row top-aligns, which suits
168
+ * a switch (it lands level with the title), but a button is tall enough that top-aligning leaves
169
+ * it above the title-plus-description block it acts on.
170
+ */
171
+ .settings-toggle-row.is-centered {
172
+ align-items: center;
173
+ }
174
+
162
175
  .settings-animation-list {
163
176
  display: flex;
164
177
  flex-direction: column;
@@ -236,6 +249,18 @@ export const settingsModalStyles = css`
236
249
  max-width: none;
237
250
  }
238
251
 
252
+ /*
253
+ * Sub-heading over the provider toggle group. Unlike the section headings it sits INSIDE
254
+ * .settings-modal-section-body, so the parent's 4-unit flex gap already separates it from the
255
+ * first toggle — and flex does not collapse margins, so the shared rule's 3-unit margin-bottom
256
+ * would add to that gap rather than replace it, leaving the label adrift from the rows it
257
+ * names. Zero the margin and pull back 2 of the 4 gap units, expressed in the same token so it
258
+ * tracks if that gap changes.
259
+ */
260
+ .settings-app-toggles-title {
261
+ margin: 0 0 calc(var(--design-unit) * -2px);
262
+ }
263
+
239
264
  .settings-app-toggle-block {
240
265
  display: flex;
241
266
  flex-direction: column;
@@ -270,7 +295,8 @@ export const settingsModalStyles = css`
270
295
  color: var(--neutral-foreground-rest);
271
296
  }
272
297
 
273
- .settings-current-build {
298
+ .settings-current-build,
299
+ .settings-lifetime-usage {
274
300
  background: var(--neutral-layer-2);
275
301
  border: 1px solid var(--neutral-stroke-rest);
276
302
  border-radius: calc(var(--control-corner-radius) * 1.5px);
@@ -278,6 +304,28 @@ export const settingsModalStyles = css`
278
304
  margin: calc(var(--design-unit) * 3px) 0;
279
305
  }
280
306
 
307
+ /*
308
+ * Deliberately quieter than the live block: no pulsing badge and no accent, so the
309
+ * eye still lands on what is being built now. It leads only because it is the wider
310
+ * figure, not because it is the more urgent one.
311
+ */
312
+ .settings-lifetime-usage-top {
313
+ display: flex;
314
+ align-items: center;
315
+ flex-wrap: wrap;
316
+ gap: calc(var(--design-unit) * 2px);
317
+ margin-bottom: calc(var(--design-unit) * 3px);
318
+ }
319
+
320
+ .settings-lifetime-label {
321
+ font-size: 10px;
322
+ line-height: 1;
323
+ font-weight: 700;
324
+ text-transform: uppercase;
325
+ letter-spacing: 0.06em;
326
+ color: var(--neutral-foreground-hint);
327
+ }
328
+
281
329
  .settings-current-build-top {
282
330
  display: flex;
283
331
  align-items: center;
@@ -286,20 +334,44 @@ export const settingsModalStyles = css`
286
334
  margin-bottom: calc(var(--design-unit) * 3px);
287
335
  }
288
336
 
337
+ /*
338
+ * Optically centring these 10px caps against the 14px project name beside them. Flex offers
339
+ * only two alignments and neither lands it, because a box centre and a text baseline are
340
+ * different lines:
341
+ * - align-items: center sits the badge baseline about 1.2px high
342
+ * - align-items: baseline matches baselines, but the caps then read as bottom-aligned
343
+ * against the name's ascenders, so the badge looks low
344
+ * The eye wants the midpoint, so: centre the boxes, then translate down by the ~1px of
345
+ * overshoot. This is a nudge, but it corrects a fixed relationship between two font sizes
346
+ * rather than some neighbouring element's layout, so unlike a margin tuned to a sibling it
347
+ * cannot rot when that sibling changes. In em, so it tracks the badge text.
348
+ *
349
+ * display: block, not inline-flex: a flex container has no text baseline of its own and
350
+ * synthesizes one from its first child, which here is the dot, an empty span. As a block box
351
+ * the badge takes the baseline of its first line, i.e. the text.
352
+ */
289
353
  .settings-live-badge {
290
- display: inline-flex;
291
- align-items: center;
292
- gap: calc(var(--design-unit) * 1px);
354
+ display: block;
293
355
  font-size: 10px;
356
+ line-height: 1;
294
357
  font-weight: 700;
295
358
  text-transform: uppercase;
296
359
  letter-spacing: 0.06em;
297
360
  color: var(--accent-fill-rest);
361
+ white-space: nowrap;
362
+ transform: translateY(0.1em);
298
363
  }
299
364
 
300
365
  .settings-live-dot {
366
+ display: inline-block;
301
367
  width: 7px;
302
368
  height: 7px;
369
+
370
+ /* Replaces the flex gap the badge no longer has. */
371
+ margin-right: calc(var(--design-unit) * 1px);
372
+
373
+ /* Centres the dot on the text beside it without disturbing that text's baseline. */
374
+ vertical-align: middle;
303
375
  border-radius: 50%;
304
376
  background: var(--accent-fill-rest);
305
377
  animation: settings-live-pulse 1.6s ease-out infinite;
@@ -321,18 +393,30 @@ export const settingsModalStyles = css`
321
393
 
322
394
  .settings-current-build-name {
323
395
  font-size: 14px;
396
+ line-height: 1;
324
397
  font-weight: 600;
325
398
  }
326
399
 
327
- .settings-current-build-stats {
400
+ /*
401
+ * Cost and the token buckets side by side: one number in its own box, the four buckets
402
+ * sharing the box next to it. Stretch, so the two are the same height however tall the 22px
403
+ * cost value makes the left one.
404
+ */
405
+ .settings-usage-row {
328
406
  display: flex;
329
407
  flex-wrap: wrap;
408
+ align-items: stretch;
330
409
  gap: calc(var(--design-unit) * 3px);
331
410
  margin-bottom: calc(var(--design-unit) * 3px);
332
411
  }
333
412
 
413
+ .settings-usage-row-tokens {
414
+ display: flex;
415
+ flex: 1 1 auto;
416
+ min-width: 0;
417
+ }
418
+
334
419
  .settings-build-stat {
335
- flex: 1;
336
420
  min-width: 0;
337
421
  background: var(--neutral-layer-1);
338
422
  border: 1px solid var(--neutral-stroke-rest);
@@ -360,6 +444,105 @@ export const settingsModalStyles = css`
360
444
  color: var(--accent-fill-rest);
361
445
  }
362
446
 
447
+ /*
448
+ * Per-bucket token split, beneath the headline cost. Small paired label/value cells
449
+ * rather than four more stat cards: they are the detail behind the cost above, so they
450
+ * have to read as subordinate to it rather than compete with it. The four buckets ARE
451
+ * the token figure now — there is no merged total anywhere for them to break down.
452
+ */
453
+ .settings-token-breakdown {
454
+ display: flex;
455
+ flex-direction: column;
456
+
457
+ /*
458
+ * Tight against the buckets below. One design unit rather than two, because each bucket
459
+ * separates its own label from its value by only 2px — at two units the title sat four
460
+ * times further from the bucket labels than they sit from their own numbers, which read
461
+ * as a gap between unrelated blocks instead of a heading over its content.
462
+ */
463
+ gap: calc(var(--design-unit) * 1px);
464
+ margin-bottom: calc(var(--design-unit) * 3px);
465
+ padding: calc(var(--design-unit) * 2px) calc(var(--design-unit) * 3px);
466
+ background: var(--neutral-layer-1);
467
+ border: 1px solid var(--neutral-stroke-rest);
468
+ border-radius: calc(var(--control-corner-radius) * 1px);
469
+ }
470
+
471
+ /*
472
+ * Titles the box, because nothing else in it named the unit. Same styling and position as
473
+ * the cost box's label, so two boxes sitting side by side are titled the same way; the
474
+ * hierarchy against the bucket labels comes from position, not a second type size.
475
+ */
476
+ .settings-token-breakdown-label {
477
+ font-size: 10px;
478
+ line-height: 1;
479
+ font-weight: 600;
480
+ text-transform: uppercase;
481
+ letter-spacing: 0.06em;
482
+ color: var(--neutral-foreground-hint);
483
+ }
484
+
485
+ .settings-token-buckets {
486
+ display: flex;
487
+ flex-wrap: wrap;
488
+ gap: calc(var(--design-unit) * 4px);
489
+ }
490
+
491
+ .settings-token-bucket {
492
+ display: flex;
493
+ min-width: 0;
494
+ flex-direction: column;
495
+ gap: 2px;
496
+ }
497
+
498
+ .settings-token-bucket-label {
499
+ font-size: 10px;
500
+ font-weight: 600;
501
+ text-transform: uppercase;
502
+ letter-spacing: 0.06em;
503
+ color: var(--neutral-foreground-hint);
504
+ }
505
+
506
+ .settings-token-bucket-value {
507
+ font-size: 13px;
508
+ font-weight: 600;
509
+ color: var(--neutral-foreground-rest);
510
+ }
511
+
512
+ /*
513
+ * Cache reads bill at a fraction of the uncached input rate, and in an agentic
514
+ * session they are usually the largest bucket by a wide margin. Muted so the biggest
515
+ * number on the row doesn't read as the dominant cost driver when it is the cheapest.
516
+ */
517
+ .settings-token-bucket.is-discounted .settings-token-bucket-value {
518
+ font-weight: 500;
519
+ color: var(--neutral-foreground-hint);
520
+ }
521
+
522
+ /*
523
+ * Usage-row overrides. Placed after the base rules they refine rather than up beside
524
+ * .settings-usage-row itself, because stylelint's no-descending-specificity rejects a
525
+ * compound selector appearing before the plain one it overrides.
526
+ */
527
+
528
+ /* Sized by its content; the buckets take the rest, since four cells need the width. */
529
+ .settings-usage-row > .settings-build-stat {
530
+ flex: 0 1 auto;
531
+ }
532
+
533
+ .settings-usage-row .settings-token-breakdown {
534
+ flex: 1 1 auto;
535
+
536
+ /* The row owns the spacing now; this centres the column against the taller cost box. */
537
+ justify-content: center;
538
+ margin-bottom: 0;
539
+ }
540
+
541
+ /* Equal columns, so the four buckets line up as a grid rather than bunching left. */
542
+ .settings-usage-row .settings-token-bucket {
543
+ flex: 1 1 0;
544
+ }
545
+
363
546
  .settings-model-tags {
364
547
  display: flex;
365
548
  flex-wrap: wrap;
@@ -432,6 +615,32 @@ export const settingsModalStyles = css`
432
615
  gap: calc(var(--design-unit) * 2px);
433
616
  }
434
617
 
618
+ /*
619
+ * Both of these carry a ?hidden binding (showTokens), and both declare display: flex — which
620
+ * silently beat the UA [hidden] { display: none } rule, because author-origin declarations
621
+ * outrank user-agent origin whatever the specificity. Without this, showTokens: false still
622
+ * rendered the full breakdown in the lifetime block, the live block and every history row.
623
+ * Sibling elements that hide correctly do so only by declaring no display at all. Same
624
+ * treatment as .settings-modal-tab-panel[hidden] near the top of this file.
625
+ */
626
+ .settings-usage-row-tokens[hidden],
627
+ .settings-cost-history-row-content[hidden] {
628
+ display: none !important;
629
+ }
630
+
631
+ /*
632
+ * The buckets inside a history row drop the panel treatment they carry in the summary
633
+ * blocks — the row is already a bordered card, and a second box inside it reads as a
634
+ * nested panel. The adjacent-sibling rule above supplies the separator instead.
635
+ */
636
+ .settings-cost-history-row-breakdown .settings-token-breakdown {
637
+ flex: 1 1 auto;
638
+ margin-bottom: 0;
639
+ padding: 0;
640
+ background: none;
641
+ border: none;
642
+ }
643
+
435
644
  .settings-cost-history-row-content + .settings-cost-history-row-content {
436
645
  border-top: 1px solid var(--neutral-stroke-rest);
437
646
  margin-top: calc(var(--design-unit) * 2px);
@@ -446,8 +655,20 @@ export const settingsModalStyles = css`
446
655
  justify-content: space-between;
447
656
  }
448
657
 
658
+ /*
659
+ * Holds the cost and the delete button as one centred pair. The button used to be a sibling
660
+ * of the whole row body, kept in place by a -2px nudge tuned for when the metrics column had
661
+ * two lines (cost plus a merged token total). Dropping that second line left the nudge
662
+ * compensating for nothing. Centring the two against each other needs no magic number and
663
+ * cannot drift again if the metrics content changes.
664
+ *
665
+ * NB this rule previously set align-items with no display:flex, so it had no effect at all.
666
+ */
449
667
  .settings-cost-history-row-right {
450
- align-items: flex-end;
668
+ display: flex;
669
+ align-items: center;
670
+ gap: calc(var(--design-unit) * 1px);
671
+ flex: 0 0 auto;
451
672
  }
452
673
 
453
674
  .settings-cost-history-row-cost,
@@ -468,7 +689,6 @@ export const settingsModalStyles = css`
468
689
  white-space: nowrap;
469
690
  }
470
691
 
471
- .settings-cost-history-row-tokens,
472
692
  .settings-cost-history-row-date {
473
693
  font-size: 12px;
474
694
  color: var(--neutral-foreground-hint);
@@ -476,7 +696,6 @@ export const settingsModalStyles = css`
476
696
 
477
697
  .settings-cost-history-delete {
478
698
  flex: 0 0 auto;
479
- margin-top: -2px;
480
699
  color: var(--neutral-foreground-hint);
481
700
  }
482
701