@genesislcap/ai-assistant 15.23.3 → 15.24.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 (46) hide show
  1. package/dist/ai-assistant.api.json +419 -50
  2. package/dist/ai-assistant.d.ts +217 -683
  3. package/dist/chat-driver.cjs.map +1 -1
  4. package/dist/chat-driver.mjs.map +1 -1
  5. package/dist/custom-elements.json +310 -609
  6. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +16 -0
  7. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  8. package/dist/dts/components/settings-modal/index.d.ts +1 -0
  9. package/dist/dts/components/settings-modal/index.d.ts.map +1 -1
  10. package/dist/dts/components/settings-modal/settings-modal.d.ts +215 -0
  11. package/dist/dts/components/settings-modal/settings-modal.d.ts.map +1 -0
  12. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  13. package/dist/dts/main/attachment-controller.d.ts +66 -0
  14. package/dist/dts/main/attachment-controller.d.ts.map +1 -0
  15. package/dist/dts/main/budget-controller.d.ts +368 -0
  16. package/dist/dts/main/budget-controller.d.ts.map +1 -0
  17. package/dist/dts/main/composer-menu-controller.d.ts +113 -0
  18. package/dist/dts/main/composer-menu-controller.d.ts.map +1 -0
  19. package/dist/dts/main/composer-resize-controller.d.ts +80 -0
  20. package/dist/dts/main/composer-resize-controller.d.ts.map +1 -0
  21. package/dist/dts/main/context-gate-controller.d.ts +197 -0
  22. package/dist/dts/main/context-gate-controller.d.ts.map +1 -0
  23. package/dist/dts/main/cost-ledger-controller.d.ts +228 -0
  24. package/dist/dts/main/cost-ledger-controller.d.ts.map +1 -0
  25. package/dist/dts/main/main.d.ts +201 -722
  26. package/dist/dts/main/main.d.ts.map +1 -1
  27. package/dist/dts/main/main.types.d.ts +25 -0
  28. package/dist/dts/main/main.types.d.ts.map +1 -1
  29. package/dist/dts/main/meta-snapshot.d.ts +50 -0
  30. package/dist/dts/main/meta-snapshot.d.ts.map +1 -0
  31. package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
  32. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +18 -0
  33. package/dist/esm/components/settings-modal/index.js +1 -0
  34. package/dist/esm/components/settings-modal/settings-modal.js +436 -0
  35. package/dist/esm/components/settings-modal/settings-modal.template.js +15 -4
  36. package/dist/esm/main/attachment-controller.js +254 -0
  37. package/dist/esm/main/budget-controller.js +745 -0
  38. package/dist/esm/main/composer-menu-controller.js +234 -0
  39. package/dist/esm/main/composer-resize-controller.js +119 -0
  40. package/dist/esm/main/context-gate-controller.js +289 -0
  41. package/dist/esm/main/cost-ledger-controller.js +498 -0
  42. package/dist/esm/main/main.js +501 -2268
  43. package/dist/esm/main/meta-snapshot.js +108 -0
  44. package/dist/esm/state/persistence/session-persister.js +5 -4
  45. package/docs/migration-GENC-1580.md +209 -0
  46. package/package.json +16 -16
@@ -22,7 +22,7 @@
22
22
  // =============================================================================
23
23
  var FoundationAiAssistant_1;
24
24
  import { __awaiter, __decorate, __rest } from "tslib";
25
- import { AIProviderRegistry, BUDGETED_VENDORS, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isObservableAIProviderRegistry, VENDOR_LABELS, vendorTypeOfLabel, } from '@genesislcap/foundation-ai';
25
+ import { AIProviderRegistry, isObservableAIProviderRegistry } from '@genesislcap/foundation-ai';
26
26
  import { showNotificationDialog } from '@genesislcap/foundation-notifications';
27
27
  import { avoidTreeShaking } from '@genesislcap/foundation-utils';
28
28
  import { customElement, DOM, html, GenesisElement, observable, volatile, attr, } from '@genesislcap/web-core';
@@ -36,14 +36,15 @@ import { AiChatInteractionWrapper } from '../components/chat-interaction-wrapper
36
36
  import { AiChatMarkdown } from '../components/chat-markdown/chat-markdown';
37
37
  import { AiFlowingWavesIndicator } from '../components/flowing-waves-indicator';
38
38
  import { AiHaloOverlay } from '../components/halo-overlay';
39
- import { OrchestratingDriver } from '../components/orchestrating-driver/orchestrating-driver';
39
+ import { asOrchestrator, OrchestratingDriver, } from '../components/orchestrating-driver/orchestrating-driver';
40
40
  import { AiPlasmaOrbIndicator } from '../components/plasma-orb-indicator';
41
+ import { SettingsModalController, } from '../components/settings-modal';
41
42
  import { AiWavesIndicator } from '../components/waves-indicator';
42
43
  import { AssistantAppSettingsProvider, } from '../provider/assistant-app-settings';
43
44
  import { recordMetaEvent, getMetaEvents, clearSession as clearMetaSession, DEBUG_LOG_README, } from '../state/debug-event-log';
44
45
  import { getOrCreateDriver, getDriver, getDriverAgentsKey, deleteDriver, } from '../state/driver-registry';
45
46
  import { buildTimelineEntries } from '../state/persistence/build-timeline-entries';
46
- import { assembleDebugLog, configDigest, withFreshMetaSnapshot, } from '../state/persistence/diagnostics';
47
+ import { assembleDebugLog, withFreshMetaSnapshot } from '../state/persistence/diagnostics';
47
48
  import { deleteDiagnosticsCursorsFor, resetDiagnosticsCursorsFor, } from '../state/persistence/diagnostics-cursors';
48
49
  import { getOrCreatePersister, getPersister, deletePersister, } from '../state/persistence/persister-registry';
49
50
  import { SessionPersister, } from '../state/persistence/session-persister';
@@ -51,25 +52,24 @@ import { lastUserMessageIndex } from '../state/persistence/session-snapshot';
51
52
  import { getSessionStore, hasSessionStore, deleteSessionStore, } from '../state/session-store';
52
53
  import { pinColourAt } from '../styles/ai-colours';
53
54
  import { ChatSuggestions } from '../suggestions/chat-suggestions';
54
- import { AnimatedPanelToggle } from '../utils/animated-panel-toggle';
55
55
  import { resolveExclusiveLoadingStyle } from '../utils/animation-exclusivity';
56
- import { deleteBankedBaseline, getBankedBaseline, setBankedBaseline, } from '../utils/banked-usage-baselines';
57
- import { collectSessionModels } from '../utils/collect-session-models';
56
+ import { deleteBankedBaseline } from '../utils/banked-usage-baselines';
58
57
  import { estimateContextTokens, isContextMeasurable, } from '../utils/context-tokens';
59
- import { clearCostSessionHistory, isCostSessionRecord, loadCostSessionHistory, resolveBankedUsage, saveCostSessionHistory, sortRecordsByRecency, upsertRecord, } from '../utils/cost-session-history';
60
- import { deriveCostSessionTitleFromMessages, resolveCostSessionTitle, } from '../utils/derive-cost-session-title';
61
- import { formatUsd } from '../utils/format-usd';
62
58
  import { logger } from '../utils/logger';
63
59
  import { filterVisibleMessages, trailingInteractionRow } from '../utils/message-partition';
64
- import { CONTEXT_ABORT_MARGIN_TOKENS, resolveContextBudget, } from '../utils/resolve-context-budget';
65
- import { resolveCostHistoryConfig, } from '../utils/resolve-cost-history-config';
66
60
  import { resolvePreferenceBaseline } from '../utils/resolve-preference-baseline';
67
61
  import { stripAgentHandlers } from '../utils/strip-agent-handlers';
68
- import { addUsage, emptyUsage, sumUsage, totalTokens } from '../utils/sum-usage';
69
- import { expandToolTree } from '../utils/tool-fold';
62
+ import { emptyUsage, sumUsage, totalTokens } from '../utils/sum-usage';
63
+ import { AttachmentController } from './attachment-controller';
64
+ import { BudgetController } from './budget-controller';
65
+ import { ComposerMenuController } from './composer-menu-controller';
66
+ import { ComposerResizeController } from './composer-resize-controller';
67
+ import { ContextGateController } from './context-gate-controller';
68
+ import { CostLedgerController } from './cost-ledger-controller';
70
69
  import { styles } from './main.styles';
71
70
  import { FoundationAiAssistantTemplate } from './main.template';
72
71
  import { AiAssistantEvent } from './main.types';
72
+ import { buildMetaSnapshot } from './meta-snapshot';
73
73
  /** Context window sizes (in tokens) for known models. */
74
74
  // ─── Avatar icons ─────────────────────────────────────────────────────────────
75
75
  // Inline SVG bundled as strings — no runtime asset dependency. They use
@@ -91,83 +91,6 @@ export const genesisIconSvg = `<svg viewBox="-4 0 30 26" fill="none" xmlns="http
91
91
  function sanitizeIconMarkup(markup) {
92
92
  return DOMPurify.sanitize(markup, { USE_PROFILES: { svg: true, svgFilters: true } });
93
93
  }
94
- /**
95
- * Duration of a composer menu's close animation. Must stay in sync with the
96
- * `composer-menu-out` keyframe duration in `main.styles.ts` — the toggle strips
97
- * the `closing` class on a timer, so a shorter value here unmounts the panel
98
- * mid-animation and a longer one leaves it sitting invisible.
99
- */
100
- const AGENT_PICKER_CLOSE_MS = 200;
101
- const SESSION_MENU_CLOSE_MS = 200;
102
- /**
103
- * Floor for an open menu's height, applied when the assistant is too short to
104
- * give it the room it wants. Below this a menu is more frustrating than a
105
- * scrollbar is, so it overhangs instead.
106
- */
107
- const COMPOSER_MENU_MIN_HEIGHT = 140;
108
- /**
109
- * How long cost-ledger writes coalesce. Comfortably longer than the gap between the turns of a
110
- * tool loop, so a multi-step run writes once rather than per turn, and short enough that a flush
111
- * has landed well before a user could close the tab. Nothing reads the stored ledger while the
112
- * assistant is running — the Usage tab renders the in-memory list — so the delay is invisible.
113
- */
114
- const LEDGER_FLUSH_DEBOUNCE_MS = 2000;
115
- /**
116
- * The image types BOTH vendors accept, and therefore the only ones sent as image blocks.
117
- *
118
- * A host's `acceptedFiles` may well say `image/*`, which covers HEIC, BMP, TIFF, AVIF and SVG —
119
- * none of which the APIs take. Sending one fails the whole turn the user is waiting on, so
120
- * anything outside this set falls through to the text branch instead: harmless for a binary, and
121
- * genuinely useful for SVG, which is text a model can read.
122
- *
123
- * `image/jpg` is absent on purpose — `normalizeImageMime` rewrites it to `image/jpeg` at the
124
- * transport, but a file arriving with that spelling is still a real JPEG, so it is accepted here.
125
- */
126
- const VENDOR_IMAGE_MIME_TYPES = new Set([
127
- 'image/jpeg',
128
- 'image/jpg',
129
- 'image/png',
130
- 'image/gif',
131
- 'image/webp',
132
- ]);
133
- /**
134
- * Ceiling on ONE image attachment, under Anthropic's ~5MB-per-image limit with room for the ~33%
135
- * base64 overhead. Refused here, with the size named, rather than at the vendor: a 400 arrives
136
- * only after the user has waited out the turn.
137
- *
138
- * A VENDOR guard, and only that. An earlier version of this comment also claimed it protected the
139
- * session's storage quota — it does not, and the arithmetic says so: one file at this ceiling is
140
- * ~4.7MB of base64, which alone can exceed a 5MB `localStorage` budget before the transcript is
141
- * counted. Attachments riding into persisted storage is a real and separate problem; it is not
142
- * this constant's to solve, and saying otherwise hid it.
143
- */
144
- const MAX_IMAGE_ATTACHMENT_BYTES = 3500000;
145
- /**
146
- * Ceiling on ALL attachments carried by one message, across every type and every successive pick.
147
- *
148
- * The per-file check above is blind to the total: the picker APPENDS (`loadSelectedFiles`), so ten
149
- * files each just under the per-image limit are ten passes and one ~34MB request. Sized against
150
- * the vendors' request ceilings — comfortably inside the smaller of them with room for the
151
- * transcript itself — so a turn that would be rejected wholesale is refused here, where the user
152
- * can still act on it, naming the file that crossed the line.
153
- *
154
- * Like its sibling, this does NOT make attachments safe to persist — 8MB of attachments is still
155
- * ~10.7MB of base64. What a persisted session should keep of its images is GENC-1531; note the
156
- * transports also REPLAY history attachments on every later call, so it is a recurring cost as
157
- * well as a storage one.
158
- */
159
- const MAX_ATTACHMENTS_TOTAL_BYTES = 8000000;
160
- /** Base64 carries 3 bytes in every 4 characters, so decoded size is 3/4 of the encoded length. */
161
- const BASE64_DECODED_RATIO = 0.75;
162
- /** A rough byte size for an already-accepted attachment, for budgeting against the total cap. */
163
- function attachmentBytes(a) {
164
- var _a, _b, _c, _d;
165
- return a.kind === 'image'
166
- ? Math.ceil(((_b = (_a = a.data) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) * BASE64_DECODED_RATIO)
167
- : ((_d = (_c = a.content) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0);
168
- }
169
- /** Bytes per megabyte, for the size the refusal message quotes back to the user. */
170
- const BYTES_PER_MB = 1000000;
171
94
  /**
172
95
  * Reporter id for this element's OWN session persister in `brokenSources` (GENC-1511).
173
96
  *
@@ -181,11 +104,6 @@ const SESSION_PERSISTENCE_SOURCE = 'session';
181
104
  * store; a host owning several should name each, so one recovering does not withdraw the others.
182
105
  */
183
106
  const DEFAULT_PERSISTENCE_SOURCE = 'host';
184
- /** Drag-to-resize bounds for the chat input (composer), in px. */
185
- const COMPOSER_MIN_HEIGHT_PX = 48;
186
- const COMPOSER_MAX_HEIGHT_PX = 400;
187
- /** Keep at least this much of the message list visible while growing the composer. */
188
- const COMPOSER_MIN_MESSAGES_PX = 80;
189
107
  /**
190
108
  * Composer placeholder while the assistant is blocked (GENC-1464). Deliberately
191
109
  * fixed and short, and NOT host-overridable — the banner directly above it
@@ -196,108 +114,13 @@ const COMPOSER_MIN_MESSAGES_PX = 80;
196
114
  */
197
115
  const BLOCKED_PLACEHOLDER = 'AI usage limit reached';
198
116
  /**
199
- * Closing sentence for a wall the user cannot route around. Singular/plural
200
- * variants because the banner names one vendor or several.
201
- */
202
- const RAISE_LIMIT_ACTION = 'Contact your administrator to raise it.';
203
- const RAISE_LIMITS_ACTION = 'Contact your administrator to raise them.';
204
- /**
205
- * Banner copy built from the figures a 402 actually reported, or `undefined`
206
- * when the proxy sent none.
207
- *
208
- * `undefined` is the meaningful return, not a fallback: paired with the
209
- * slice's "a re-latch without a reason keeps the existing explanation" rule, it
210
- * makes a figureless driver latch leave a host-supplied reason intact rather
211
- * than blanking it back to the default at the exact moment the wall is hit.
212
- *
213
- * Exported for the unit test that pins the formatting; not part of the element
214
- * API.
215
- *
216
- * @internal
217
- */
218
- export function formatBlockedReason(
219
- // `Partial`, so the store's `VendorBudgetFigures` satisfies it as well as a live
220
- // 402's payload: the banner composes from stored figures and the latch from the
221
- // fresh ones, and both carry the same dollars. One formatter for both means a
222
- // remembered wall and a just-hit one cannot word themselves differently.
223
- budget, vendor) {
224
- if (!budget || (budget.budgetUsd == null && budget.spentUsd == null))
225
- return undefined;
226
- const money = (v) => (v == null ? 'an unknown amount' : formatUsd(v));
227
- const figures = `(${money(budget.spentUsd)} of ${money(budget.budgetUsd)})`;
228
- // No "you have $0.00 left" clause here, deliberately: "limit is reached" already
229
- // says the allowance is gone, and a remainder clamped at zero CONTRADICTS the
230
- // figures beside it — spend overshoots the cap on the last allowed call, so the
231
- // words would read "$0.00 left" next to numbers showing $12.34 past it.
232
- // A per-vendor statement carries NO action clause, because the right action is
233
- // not knowable at latch time: whether "switch vendor" or "contact your
234
- // administrator" is the honest advice depends on whether any OTHER vendor still
235
- // has headroom, which only the composing getter can see. The vendor-agnostic
236
- // form keeps its historical trailing sentence — nothing composes onto it.
237
- if (!vendor)
238
- return `AI usage limit reached ${figures}. Contact your administrator to raise it.`;
239
- return `${vendorDisplayName(vendor)}'s AI usage limit is reached ${figures}.`;
240
- }
241
- /**
242
- * Human-readable name for a vendor, falling back to its raw type.
243
- *
244
- * `'none'` needs no special case: it is the "no provider configured" sentinel,
245
- * it is filtered out of {@link FoundationAiAssistant.reachableVendors}, the latch
246
- * refuses to attribute a wall to it, and
247
- * {@link FoundationAiAssistant.setVendorBlocked} rejects it at the boundary — so
248
- * nothing can put it in front of this function. The `?? vendor` fallback is for a
249
- * vendor type added to `AIProviderType` without a label, which would print the
250
- * type rather than nothing.
251
- */
252
- function vendorDisplayName(vendor) {
253
- var _a;
254
- return (_a = VENDOR_LABELS[vendor]) !== null && _a !== void 0 ? _a : vendor;
255
- }
256
- /**
257
- * `a`, `a and b`, `a, b and c`.
258
- *
259
- * The general joiner is kept rather than hardcoded to a pair because the list
260
- * is `BUDGETED_VENDORS`-shaped, and that list is a cross-repo contract owned by
261
- * the proxy (today `anthropic` and `gemini`; `openai` is refused up front with
262
- * 400 UNSUPPORTED_PROVIDER and can never be walled) — a vendor added there must
263
- * not require an edit here. Its upper bound is not an assertion in a comment — it is
264
- * the key count of `VENDOR_LABELS`, since every element of every list passed here
265
- * comes from `blockedVendors` or `reachableVendors`, both of which hold
266
- * `AIProviderType` values, and the type has five members of which `'none'` is
267
- * excluded from both.
268
- */
269
- function formatVendorList(vendors) {
270
- return joinList(vendors.map(vendorDisplayName));
271
- }
272
- /**
273
- * `"A"`, `"A and B"`, `"A, B and C"` — over already-rendered labels, so a caller
274
- * can annotate each name (the multi-vendor banner appends each vendor's figures)
275
- * without duplicating the joining rules.
117
+ * Budget formatting helpers, re-exported so they stay on this module's public
118
+ * surface (`src/index.ts` does `export * from './main/main'`). They live in
119
+ * `budget-controller.ts` with the code that composes them; moving them without
120
+ * this line would have quietly dropped two `@internal` exports from the package
121
+ * entry point, and broken the two test files that import them from here.
276
122
  */
277
- function joinList(names) {
278
- var _a;
279
- if (names.length <= 1)
280
- return (_a = names[0]) !== null && _a !== void 0 ? _a : '';
281
- return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
282
- }
283
- /**
284
- * Whole-number percentage of a budget consumed, for the meter (GENC-1464).
285
- *
286
- * Deliberately unclamped — spend can genuinely overshoot the cap (the last
287
- * allowed call may cross it), and the meter's TEXT shows the truth; only the
288
- * bar clamps, at the render site. The one special case is a `$0` cap, which the
289
- * platform treats as "no spend allowed" (not "unlimited"): it has no headroom
290
- * by definition, so the meter reads full rather than dividing by zero.
291
- *
292
- * Exported for the unit test that pins the arithmetic; not element API.
293
- *
294
- * @internal
295
- */
296
- export function budgetPercentOf(figures) {
297
- if (figures.budgetUsd <= 0)
298
- return 100;
299
- return Math.round((figures.spentUsd / figures.budgetUsd) * 100);
300
- }
123
+ export { budgetPercentOf, formatBlockedReason } from './budget-controller';
301
124
  // Register supporting components when the main component module is imported.
302
125
  avoidTreeShaking(AiChatMarkdown, AiChatInteractionWrapper, AiHaloOverlay, AiWavesIndicator, AiFlowingWavesIndicator, AiPlasmaOrbIndicator, AiChatBubble, AiActivityHalo, ChatSuggestions, AgentPicker);
303
126
  /**
@@ -328,7 +151,111 @@ avoidTreeShaking(AiChatMarkdown, AiChatInteractionWrapper, AiHaloOverlay, AiWave
328
151
  */
329
152
  let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistant extends GenesisElement {
330
153
  constructor() {
154
+ // ─── Controllers ────────────────────────────────────────────────────────────
155
+ //
156
+ // DECLARED FIRST, and that is load-bearing. FAST fires a `<field>Changed` hook from
157
+ // the field's own initializer, so `@observable chatConfig = {}` below runs
158
+ // `chatConfigChanged` — which reaches two of these — while the class body is still
159
+ // initializing. A controller declared after the observable it serves is `undefined`
160
+ // at that moment, and the hook throws inside `document.createElement`, before any
161
+ // test or host code can catch it. Field initializers run in declaration order, so
162
+ // putting them here is the fix; keep new controllers in this block.
163
+ // ─── Composer controllers ───────────────────────────────────────────────────
164
+ // Three separable concerns that share only the `.composer` DOM region: the
165
+ // drag-to-resize geometry, the two anchored menus, and file attachments. Each
166
+ // receives callbacks rather than this element, so none of them can reach back
167
+ // into element API — and none of them touches the driver.
331
168
  super(...arguments);
169
+ this.composerResize = new ComposerResizeController({
170
+ enabled: () => { var _a; return ((_a = this.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.resizableInput) !== false; },
171
+ inputEl: () => this.chatInputEl,
172
+ messagesEl: () => this.messagesEl,
173
+ });
174
+ this.composerMenus = new ComposerMenuController(this, {
175
+ agentPickerOpen: this.accessor('agentPickerOpen'),
176
+ sessionMenuOpen: this.accessor('sessionMenuOpen'),
177
+ focusInput: () => { var _a; return (_a = this.chatInputEl) === null || _a === void 0 ? void 0 : _a.focus(); },
178
+ resetSessionConfirmations: () => {
179
+ this.confirmingClear = false;
180
+ this.confirmingCompact = false;
181
+ },
182
+ logMeta: (type, detail) => this.logMeta(type, detail),
183
+ });
184
+ this.attachmentCtrl = new AttachmentController(this, {
185
+ acceptedFiles: () => { var _a; return (_a = this.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.acceptedFiles; },
186
+ logMeta: (type, detail) => this.logMeta(type, detail),
187
+ });
188
+ /**
189
+ * Pure derivation — owns no state. Reaches the driver only through the two
190
+ * callbacks below, so it stays clear of the stateful-agent surface (§11.2
191
+ * constraint 2) and of redux entirely.
192
+ */
193
+ this.contextGateCtrl = new ContextGateController({
194
+ chatConfig: () => this.chatConfig,
195
+ messages: () => this.messages,
196
+ contextTokens: () => this.contextTokens,
197
+ contextLimit: () => this.contextLimit,
198
+ gateContextTokens: () => this._gateContextTokens,
199
+ contextReserve: () => this.contextReserve,
200
+ preambleTokens: () => { var _a, _b; return (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.getPreambleTokens) === null || _b === void 0 ? void 0 : _b.call(_a); },
201
+ compactionPlanFor: (options) => { var _a, _b, _c; return (_c = (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.getCompactionPlan) === null || _b === void 0 ? void 0 : _b.call(_a, options)) !== null && _c !== void 0 ? _c : null; },
202
+ bannerVisible: () => this.bannerVisible,
203
+ effectiveBlockedReason: () => this.effectiveBlockedReason,
204
+ flowOwnerAgentName: () => this.flowOwnerAgentName,
205
+ });
206
+ /**
207
+ * Cost accounting and the persisted build-history ledger (GENC-1475). Reads the live
208
+ * session through callbacks and owns the rows, the load/write latches and the debounce
209
+ * timer — the element keeps only the names the settings modal binds.
210
+ */
211
+ this.costLedger = new CostLedgerController({
212
+ chatConfig: () => this.chatConfig,
213
+ messages: () => this.messages,
214
+ sessionUsage: () => this.sessionUsage,
215
+ providerStatuses: () => this.providerStatuses,
216
+ persistenceActive: () => this.persistenceActive,
217
+ stateKey: () => this.getStateKey(),
218
+ headerTitle: () => this.headerTitle,
219
+ provider: () => this.persistence.provider,
220
+ elementId: () => this.id,
221
+ });
222
+ /**
223
+ * The AI-spend budget: vendor walls, the derived composer lock, the banner copy
224
+ * and the settings meter (GENC-1464).
225
+ *
226
+ * Takes the session store itself rather than a callback per field — see
227
+ * `BudgetDeps` for why this one differs from its siblings.
228
+ */
229
+ this.budget = new BudgetController({
230
+ sessionRef: () => this._sessionRef,
231
+ providerStatuses: () => this.providerStatuses,
232
+ providerRegistry: () => this.providerRegistry,
233
+ chatConfig: () => this.chatConfig,
234
+ });
235
+ /**
236
+ * The settings modal (`docs/code-health.md` §3). Reaches the two design-system
237
+ * refs through callbacks, because the template writes them by name — see
238
+ * `SettingsModalDeps`.
239
+ */
240
+ this.settings = new SettingsModalController({
241
+ chatConfig: () => this.chatConfig,
242
+ contextTokens: () => this.contextTokens,
243
+ contextLimit: () => this.contextLimit,
244
+ budgetUsageVisible: () => this.budget.settingsUsageVisible,
245
+ costConfig: () => this.costLedger.config,
246
+ costHistoryLength: () => this.costLedger.history.length,
247
+ liveCostSessionTitle: () => this.costLedger.liveTitle,
248
+ providerStatusCount: () => this.providerStatuses.length,
249
+ sessionRef: () => this._sessionRef,
250
+ stateKey: () => this.getStateKey(),
251
+ popoutMode: () => this.popoutMode,
252
+ headerTitle: () => this.headerTitle,
253
+ appSettingsProvider: () => this.appSettingsProvider,
254
+ modal: () => this.settingsModal,
255
+ tabsEl: () => this.settingsTabsEl,
256
+ requestBudgetRefresh: () => this.requestBudgetRefresh(),
257
+ logMeta: (type, detail) => this.logMeta(type, detail),
258
+ });
332
259
  this.designSystemPrefix = 'rapid';
333
260
  this.headerTitle = 'Genesis Assistant';
334
261
  /** Sanitized `assistantIcon`, or the default sparkle mark; bound into the template. @internal */
@@ -348,34 +275,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
348
275
  this.persistence = {};
349
276
  /** When set, enables Redux DevTools for this instance's session store. */
350
277
  this.debugRedux = false;
351
- /**
352
- * One warning per element for garbage fed to
353
- * {@link FoundationAiAssistant.setVendorBudget}. Once, not per call, because
354
- * the documented feed re-runs after every turn — a host bug would otherwise
355
- * repeat the same warning on every send for the life of the page.
356
- */
357
- this.vendorBudgetWarningIssued = false;
358
- /**
359
- * When budget figures last arrived — from the host's {@link FoundationAiAssistant.setVendorBudget}
360
- * or the wall latch's own feed — as epoch ms; `null` until the first feed.
361
- *
362
- * ELEMENT state, not slice state, on purpose: the reducer is idempotent by
363
- * value (an unchanged re-feed must not re-render the meter rows), but "when
364
- * was this fetched" must move on every feed, changed figures or not — a
365
- * refresh that confirms the same dollars is still a refresh. Only the
366
- * toolbar label observes this, so a stamp re-renders one span, not the rows.
367
- */
368
- this.budgetMeterLastFedAt = null;
369
278
  // ---- Transient UI state (stays as @observable on the component) ----
370
279
  this._suggestionsGeneration = 0;
371
- this.attachments = [];
372
- this.attachmentErrors = [];
373
280
  /** Whether the loading spinner is currently visible. Controlled by the loading delay timer. */
374
281
  this.showLoadingIndicator = false;
375
- /** Bump to re-render app settings toggles when the host provider notifies a change. */
376
- this.appSettingsRevision = 0;
377
- /** Whether the settings modal is open. */
378
- this.settingsOpen = false;
379
282
  /**
380
283
  * True while `downloadDebugLog` is building the log (GENC-1351 §5.8). Drives the
381
284
  * download button's disabled + spinner state — the lifetime-stitch path is async
@@ -384,8 +287,6 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
384
287
  * element has its own settings modal.
385
288
  */
386
289
  this.downloadingDebugLog = false;
387
- /** Active tab inside the settings modal. */
388
- this.settingsModalTab = 'settings';
389
290
  /**
390
291
  * GENC-1511 — persistence has failed repeatedly and it is no longer saving. Drives a BLOCKING
391
292
  * modal with a single Reload action: the whole failure class this ticket exists for is one the
@@ -419,33 +320,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
419
320
  this.brokenSources = new Map();
420
321
  /** Swallows the native dialog's Escape route. Stored, not inline, so it can be removed again. */
421
322
  this.onPersistenceModalCancel = (e) => e.preventDefault();
422
- /** Light-DOM nodes assigned to settings modal slots (via `slotted()` bindings). */
423
- this.settingsModelSlotted = [];
424
- this.settingsAppSlotted = [];
425
- this.settingsCostCurrentSlotted = [];
426
- this.settingsCostHistorySlotted = [];
427
- /** Persisted finalized cost sessions shown on the Usage tab build history list. */
428
- this.costSessionHistory = [];
429
323
  /** Whether the splash overlay is currently showing (no messages and showSplash is enabled). Reflected as a boolean attribute on the host. */
430
324
  this.showingSplash = false;
431
- /** Title tracked for the in-flight cost session (used when `header-title` changes). */
432
- this._activeCostSessionTitle = '';
433
- // NOTE: the pre-transcript ("banked") spend is deliberately NOT held here. It is keyed by
434
- // session in `banked-usage-baselines` so the many element instances a single session sees —
435
- // layout re-renders, popout, docking — share one baseline. See `bankedUsageFor`.
436
- /**
437
- * Whether the row list reflects storage yet. Gates cost writes so a project's banked
438
- * spend is never derived from a ledger that simply hasn't arrived.
439
- */
440
- this._costHistoryLoaded = false;
441
- /**
442
- * Set when a provider read failed. Latches writes off for this page-load: `saveCostHistory`
443
- * replaces the whole stored list, so writing anything derived from the empty list we fell back
444
- * to would destroy rows we never managed to read.
445
- */
446
- this._ledgerUnreadable = false;
447
- /** Pending (adopted but unwritten) ledger changes — see `persistCostHistory`. */
448
- this._ledgerDirty = false;
449
325
  // No once-guard for cost-session writes: the write is an upsert keyed by the session
450
326
  // key, so repeat calls (pagehide, title change, per-turn) replace the same row and are
451
327
  // idempotent. The old flag existed only because the write appended and reset.
@@ -453,26 +329,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
453
329
  // the cost session on tab close — there is no hidden twin to defer to, so this fires
454
330
  // unconditionally (the old bubble-vs-docked gating flag is gone with the two-view model).
455
331
  this._handlePageHide = () => {
456
- this.finalizeCostSession();
332
+ this.costLedger.finalize();
457
333
  // Straight to storage: the debounce timer will never fire once the page is going away.
458
- this.flushCostHistory();
334
+ this.costLedger.flush();
459
335
  };
460
336
  this.haloStartPublished = false;
461
- /**
462
- * Explicit composer height (px) once the user has dragged the resize handle;
463
- * `null` keeps the default auto-grow behaviour. Written into the
464
- * `--composer-height` custom property via the `composerHeightStyle` getter.
465
- */
466
- this.composerHeightPx = null;
467
- /** Pointer Y and composer height captured at the start of a resize drag. */
468
- this._resizeStartY = 0;
469
- this._resizeStartHeight = 0;
470
- /** The composer's natural (un-resized) height, measured on first drag — used
471
- * as the lower bound so the box can't be dragged smaller than it started. */
472
- this._composerNaturalHeight = 0;
473
- /** Upper bound for the current drag — capped to the headroom above so growing
474
- * the composer never pushes the input row's buttons off the bottom. */
475
- this._resizeMaxHeight = COMPOSER_MAX_HEIGHT_PX;
476
337
  /**
477
338
  * True when the user's last interaction (pointer or keyboard focus) was inside
478
339
  * the assistant. Defaults to true so the assistant claims focus on first mount.
@@ -536,16 +397,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
536
397
  // one session (bubble + popped-out panel) and must share cursors, or they'd
537
398
  // double-append the stream.
538
399
  this.showHalo = 'no';
539
- this._agentPickerToggle = new AnimatedPanelToggle(this, '.agent-picker-panel', AGENT_PICKER_CLOSE_MS, () => this.agentPickerOpen, (v) => {
540
- this.agentPickerOpen = v;
541
- });
542
400
  // ── Session lifecycle menu (GENC-1351 §5.5) ─────────────────────────────────
543
401
  /** Two-step confirm for the destructive Clear action; reset whenever the menu toggles. */
544
402
  this.confirmingClear = false;
545
403
  this.confirmingCompact = false;
546
- this._sessionMenuToggle = new AnimatedPanelToggle(this, '.session-menu-panel', SESSION_MENU_CLOSE_MS, () => this.sessionMenuOpen, (v) => {
547
- this.sessionMenuOpen = v;
548
- });
549
404
  }
550
405
  assistantIconChanged() {
551
406
  this.assistantIconSafe = this.assistantIcon
@@ -838,7 +693,13 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
838
693
  set agentPickerOpen(value) {
839
694
  var _a;
840
695
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setAgentPickerOpen(value);
841
- this.syncComposerMenuDismissal();
696
+ // The sync belongs HERE, not in the controller, because this setter is the
697
+ // bottom of every write path: the controller drives the flag through an
698
+ // `Accessor` over this property, so its writes land here too — and so does a
699
+ // host's `el.agentPickerOpen = true`. Syncing in the controller instead left a
700
+ // host write opening the panel with no outside-click dismissal, no Escape and
701
+ // no reposition-on-resize (PR #2503 review).
702
+ this.composerMenus.syncDismissal();
842
703
  }
843
704
  /**
844
705
  * Whether the lifecycle (session) menu slide-out panel is open. Like the
@@ -851,7 +712,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
851
712
  set sessionMenuOpen(value) {
852
713
  var _a;
853
714
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setSessionMenuOpen(value);
854
- this.syncComposerMenuDismissal();
715
+ // See `agentPickerOpen`'s setter — same invariant, same reason.
716
+ this.composerMenus.syncDismissal();
855
717
  }
856
718
  /**
857
719
  * Whether a manual compaction is in flight (GENC-1351 §5.7). On the session
@@ -922,77 +784,23 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
922
784
  * @beta
923
785
  */
924
786
  get blocked() {
925
- var _a;
926
- const session = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant;
927
- if (!session)
928
- return false;
929
- if (session.blocked)
930
- return true;
931
- // Byte-identical to the old stored read for every host that never adopts the
932
- // per-vendor API — which is what makes this derivation non-breaking.
933
- if (session.blockedVendors.length === 0)
934
- return false;
935
- // Unknown reachability (statuses not loaded yet, or providers that report no
936
- // status) fails SAFE — any wall blocks everything, exactly today's semantics —
937
- // and needs no clause of its own: `every` on the empty set is vacuously true.
938
- return this.reachableVendors.every((v) => session.blockedVendors.includes(v));
787
+ return this.budget.blocked;
939
788
  }
940
789
  set blocked(value) {
941
- var _a;
942
- (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked: value });
790
+ this.budget.blocked = value;
943
791
  }
944
792
  /**
945
793
  * The distinct vendors the registry can currently reach, from the provider
946
794
  * statuses this element already loads on connect and refreshes on every
947
- * observable-registry change.
948
- *
949
- * This is the element's answer to "which vendor would the next turn use" — a
950
- * question that has **no** correct answer and deliberately gets no API. The
951
- * provider is resolved per turn AND per agent: `activeProviderInput` may be an
952
- * async function of the turn's context, an orchestrated turn picks its agent
953
- * with an LLM `classify()` call, and sub-agents resolve their own providers.
954
- * Any pre-turn "peek" would therefore be a guess that is wrong precisely on the
955
- * multi-agent hosts per-vendor budgets exist for.
956
- *
957
- * Asking instead which vendors are REACHABLE is answerable, synchronous, and
958
- * free — and it is enough: the composer must stay live while any reachable
959
- * vendor has headroom, and must lock when none does. It also makes vendor-switch
960
- * recovery a derivation rather than a mutation: a host that swaps its registry
961
- * to another vendor fires the observable, the statuses reload, the walled vendor
962
- * drops out of this set, and `blocked` goes false with every latch left intact.
795
+ * observable-registry change — this element's answer to "which vendor would the
796
+ * next turn use", a question that has no correct answer and deliberately gets
797
+ * no API. See `BudgetController.reachableVendors` for why reachability is
798
+ * the answerable question and what deriving from it buys.
963
799
  *
964
800
  * @beta
965
801
  */
966
802
  get reachableVendors() {
967
- var _a, _b;
968
- const seen = new Set();
969
- for (const entry of this.providerStatuses) {
970
- const provider = (_a = entry.status) === null || _a === void 0 ? void 0 : _a.provider;
971
- if (!provider || provider === 'none')
972
- continue;
973
- // Reachable means "can serve the next turn", and `chat()` is OPTIONAL on
974
- // `AIProvider` — the shipped `ChromeProvider` and `OpenAIProvider`
975
- // implement `getStatus()` only. Counting a chat-less provider here made
976
- // every consumer wrong at once: it was headroom for the `blocked`
977
- // derivation (so `otherVendorAvailable: false` failed to lock the
978
- // composer and each send burned another doomed 402), and it was a switch
979
- // target for the banner ("Switch to Chrome to keep going" toward a
980
- // provider that cannot chat).
981
- //
982
- // The gate is CAPABILITY, not vendor identity, so a host's custom
983
- // chat-capable OpenAI provider still counts — the same distinction
984
- // `validate-providers.ts` already enforces by throwing. And it drops a
985
- // vendor only on positive evidence: statuses normally come from
986
- // `providerRegistry.listStatuses()`, whose `name` always resolves, but a
987
- // host (or test) that assigns `providerStatuses` directly may have no
988
- // matching registry entry, and an unresolvable name must not silently
989
- // erase a vendor the status layer vouched for.
990
- const registered = (_b = this.providerRegistry) === null || _b === void 0 ? void 0 : _b.get(entry.name);
991
- if (registered && typeof registered.chat !== 'function')
992
- continue;
993
- seen.add(provider);
994
- }
995
- return [...seen];
803
+ return this.budget.reachableVendors;
996
804
  }
997
805
  /**
998
806
  * Vendors currently walled by the AI-spend budget, in the order they were
@@ -1000,13 +808,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1000
808
  *
1001
809
  * May include a vendor this registry cannot reach — either because the registry
1002
810
  * moved on after the wall was latched, or because a 402's
1003
- * `otherVendorAvailable: false` walls every vendor the proxy meters (which is
1004
- * what that verdict is a statement about; see
1005
- * {@link FoundationAiAssistant.latchBlockedFrom}). Neither costs anything
1006
- * internally — {@link FoundationAiAssistant.blocked} asks only about the
1007
- * reachable set, and the banner reads the reachability-filtered
1008
- * {@link FoundationAiAssistant.relevantBlockedVendors} — but a host rendering
1009
- * this list itself should filter it against its own registry.
811
+ * `otherVendorAvailable: false` walls every vendor the proxy meters. Neither
812
+ * costs anything internally — {@link FoundationAiAssistant.blocked} asks only
813
+ * about the reachable set, and the banner reads the reachability-filtered
814
+ * `BudgetController.relevantBlockedVendors` — but a host rendering this
815
+ * list itself should filter it against its own registry.
1010
816
  *
1011
817
  * Read-only on purpose: a settable array would let a host write a partial list
1012
818
  * and silently orphan the per-vendor banner copy.
@@ -1015,8 +821,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1015
821
  * @beta
1016
822
  */
1017
823
  get blockedVendors() {
1018
- var _a, _b;
1019
- return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedVendors) !== null && _b !== void 0 ? _b : [];
824
+ return this.budget.blockedVendors;
1020
825
  }
1021
826
  /**
1022
827
  * Whether this specific vendor's budget is walled — regardless of whether any
@@ -1025,7 +830,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1025
830
  * @beta
1026
831
  */
1027
832
  isVendorBlocked(vendor) {
1028
- return this.blockedVendors.includes(vendor);
833
+ return this.budget.isVendorBlocked(vendor);
1029
834
  }
1030
835
  /**
1031
836
  * Wall (or release) one vendor's budget, optionally with banner copy for it.
@@ -1037,35 +842,21 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1037
842
  * become true.
1038
843
  *
1039
844
  * `reason` is composed into the banner sentence for that vendor, **however many
1040
- * vendors are walled** — see {@link FoundationAiAssistant.effectiveBlockedReason}.
1041
- * It is per-vendor copy, not a whole-banner override; {@link FoundationAiAssistant.blockedReason}
1042
- * is the override. Omitting it keeps whatever explanation that vendor already
1043
- * carried, so a driver latch landing after a host one cannot blank it.
845
+ * vendors are walled** — see `BudgetController.effectiveBlockedReason`.
846
+ * It is per-vendor copy, not a whole-banner override;
847
+ * {@link FoundationAiAssistant.blockedReason} is the override. Omitting it keeps
848
+ * whatever explanation that vendor already carried, so a driver latch landing
849
+ * after a host one cannot blank it.
1044
850
  *
1045
- * `'none'` is rejected with a warning rather than accepted: it is the "no
1046
- * provider configured" sentinel, not a vendor. Latching it would put the raw
1047
- * sentinel in the banner ("none's AI usage limit is reached") and could never
1048
- * be undone by derivation, because `'none'` never appears in
1049
- * {@link FoundationAiAssistant.reachableVendors} — so `blocked` could never
1050
- * become derivable from it either. Use {@link FoundationAiAssistant.setBlocked}
1051
- * for a vendor-agnostic block.
851
+ * `'none'` is rejected with a warning rather than accepted — it is the "no
852
+ * provider configured" sentinel, not a vendor, and a wall on it could never be
853
+ * undone by derivation. Use {@link FoundationAiAssistant.setBlocked} for a
854
+ * vendor-agnostic block.
1052
855
  *
1053
856
  * @beta
1054
857
  */
1055
858
  setVendorBlocked(vendor, blocked, reason) {
1056
- var _a;
1057
- if (vendor === 'none') {
1058
- logger.warn("FoundationAiAssistant.setVendorBlocked: 'none' is the no-provider sentinel, not a vendor — " +
1059
- 'ignoring. Use setBlocked() for a vendor-agnostic block.');
1060
- return;
1061
- }
1062
- (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setVendorBlocked({ vendor, blocked, reason });
1063
- }
1064
- warnVendorBudgetOnce(message) {
1065
- if (this.vendorBudgetWarningIssued)
1066
- return;
1067
- this.vendorBudgetWarningIssued = true;
1068
- logger.warn(`FoundationAiAssistant.setVendorBudget: ${message}`);
859
+ this.budget.setVendorBlocked(vendor, blocked, reason);
1069
860
  }
1070
861
  /**
1071
862
  * Feed (or clear) one vendor's AI-spend figures for the settings-modal budget
@@ -1125,45 +916,28 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1125
916
  * @beta
1126
917
  */
1127
918
  setVendorBudget(vendor, figures) {
1128
- if (!BUDGETED_VENDORS.includes(vendor)) {
1129
- this.warnVendorBudgetOnce(`'${vendor}' is not a budgeted vendor — ignoring. Only the proxy-metered vendors ` +
1130
- `(${BUDGETED_VENDORS.join(', ')}) can carry budget figures.`);
1131
- return false;
1132
- }
1133
- // Nullish, not `!== null`: an untyped host's missing lookup arrives as
1134
- // `undefined`, and dereferencing it here would throw at a boundary whose
1135
- // whole contract is to return false instead. `undefined` clears, like null.
1136
- if (figures != null) {
1137
- const validUsd = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
1138
- if (!validUsd(figures.budgetUsd) || !validUsd(figures.spentUsd)) {
1139
- this.warnVendorBudgetOnce(`invalid figures for '${vendor}' — ignoring. budgetUsd and spentUsd must both be ` +
1140
- `finite numbers >= 0 (got budgetUsd: ${figures.budgetUsd}, spentUsd: ${figures.spentUsd}).`);
1141
- return false;
1142
- }
1143
- }
1144
- const session = this._sessionRef;
1145
- // No store yet (fed before the first append, or mid pop-out remount): there
1146
- // is nowhere to write, so say so rather than returning an accepted the
1147
- // caller would never retry. Not warned — this is a lifecycle window, not
1148
- // bad input, and the documented per-turn re-feed lands the figures as soon
1149
- // as the store exists.
1150
- if (!session)
1151
- return false;
1152
- session.actions.aiAssistant.setVendorBudget({
1153
- vendor: vendor,
1154
- // A fresh literal, never the caller's object — see the reducer, which
1155
- // must not adopt (and potentially freeze) state the host still owns.
1156
- figures: figures == null ? null : { budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd },
1157
- });
1158
- if (figures != null)
1159
- this.budgetMeterLastFedAt = Date.now();
1160
- return true;
919
+ return this.budget.setVendorBudget(vendor, figures);
920
+ }
921
+ /**
922
+ * When budget figures last arrived — from the host's
923
+ * {@link FoundationAiAssistant.setVendorBudget} or the wall latch's own feed —
924
+ * as epoch ms; `null` until the first feed.
925
+ *
926
+ * ELEMENT state, not slice state, on purpose: the reducer is idempotent by
927
+ * value (an unchanged re-feed must not re-render the meter rows), but "when
928
+ * was this fetched" must move on every feed, changed figures or not — a
929
+ * refresh that confirms the same dollars is still a refresh. Only the
930
+ * toolbar label observes this, so a stamp re-renders one span, not the rows.
931
+ */
932
+ get budgetMeterLastFedAt() {
933
+ return this.budget.meterLastFedAt;
934
+ }
935
+ set budgetMeterLastFedAt(value) {
936
+ this.budget.meterLastFedAt = value;
1161
937
  }
1162
938
  /** "Updated HH:MM:SS" for the meter toolbar, or `undefined` before any feed. */
1163
939
  get settingsBudgetUpdatedLabel() {
1164
- if (this.budgetMeterLastFedAt == null)
1165
- return undefined;
1166
- return `Updated ${new Date(this.budgetMeterLastFedAt).toLocaleTimeString()}`;
940
+ return this.budget.updatedLabel;
1167
941
  }
1168
942
  /**
1169
943
  * Ask the host to re-fetch budget figures now (GENC-1464 refresh affordance).
@@ -1185,113 +959,27 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1185
959
  // gc-assistant's), and a delegation-based host must still see the request.
1186
960
  this.dispatchEvent(new CustomEvent('budget-refresh-requested', { bubbles: true, composed: true }));
1187
961
  }
1188
- /** Whether this vendor's wall came from the sweep alone — see the slice's `sweptVendors`. */
1189
- isVendorSwept(vendor) {
1190
- var _a, _b;
1191
- return ((_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.sweptVendors) !== null && _b !== void 0 ? _b : []).includes(vendor);
1192
- }
1193
- /**
1194
- * The walled vendors the user can still be routed to — i.e.
1195
- * {@link FoundationAiAssistant.blockedVendors} narrowed to the reachable set,
1196
- * which is the only set the banner may name.
1197
- *
1198
- * A wall the registry can no longer reach is not news: it cannot be hit, and
1199
- * naming it puts a vendor in front of the user that is not theirs. The concrete
1200
- * failure this exists to stop: a host ships Anthropic-only, Anthropic walls, the
1201
- * host swaps its registry to Gemini, Gemini walls — and the banner reads "AI
1202
- * usage limits are reached for Anthropic and Gemini" to a user who has never
1203
- * had an Anthropic key. The spurious second name also flips the copy onto the
1204
- * plural branch, so even the closing sentence is wrong.
1205
- *
1206
- * When nothing is reachable YET (the statuses are still loading — the exact
1207
- * window the pre-flight and the cross-tab bus latch into), the fallback keeps
1208
- * every wall for LOCKING (`blocked` still derives true) but names only the
1209
- * vendors walled by a REFUSAL, dropping the ones the `otherVendorAvailable`
1210
- * sweep added. A refusal is a fact about a vendor this user just used; a sweep
1211
- * entry is the proxy's headroom verdict about a vendor the host may not even
1212
- * ship — naming it here reproduced the exact failure above from the other
1213
- * direction ("Gemini's AI usage limit is reached" to an Anthropic-only user),
1214
- * and flipped the copy onto the plural branch with it. Self-corrects when the
1215
- * statuses land: from then on reachability, not provenance, decides.
1216
- *
1217
- * @internal
1218
- */
1219
- get relevantBlockedVendors() {
1220
- var _a, _b;
1221
- const walled = this.blockedVendors;
1222
- const reachable = this.reachableVendors;
1223
- if (reachable.length === 0) {
1224
- const swept = (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.sweptVendors) !== null && _b !== void 0 ? _b : [];
1225
- return walled.filter((v) => !swept.includes(v));
1226
- }
1227
- return walled.filter((v) => reachable.includes(v));
1228
- }
1229
962
  /**
1230
963
  * Whether the blocked banner has anything to say — a vendor-agnostic block, or
1231
- * a partial wall that the NEXT send would actually hit.
1232
- *
1233
- * Partial exhaustion is announced only while the ACTIVE vendor (the registry
1234
- * default the next send resolves to) is among the walled ones. A user who has
1235
- * switched to a vendor with headroom has already acted on the advice —
1236
- * keeping the banner up past that point nagged about a vendor they just left
1237
- * (GENC-1464 tester feedback), while switching BACK to the walled vendor
1238
- * brings the banner straight back. Two conservative fallbacks keep it
1239
- * fail-informative: while the statuses are still loading the active vendor is
1240
- * unknowable, so any reachable-and-walled vendor still announces; and a
1241
- * vendor-agnostic block announces always, exactly as before.
1242
- *
1243
- * Reads the reachability-filtered list for the same reason the copy does — a
1244
- * host that has swapped its registry away from the walled vendor has nothing
1245
- * left to announce, and would otherwise get a banner that falls through to the
1246
- * generic default copy over a perfectly usable composer.
964
+ * a partial wall that the NEXT send would actually hit. See
965
+ * `BudgetController.bannerVisible` for why partial exhaustion is
966
+ * announced only while the active vendor is among the walled ones.
1247
967
  *
1248
968
  * @internal
1249
969
  */
1250
970
  get bannerVisible() {
1251
- if (this.blocked)
1252
- return true;
1253
- const walled = this.relevantBlockedVendors;
1254
- if (walled.length === 0)
1255
- return false;
1256
- const active = this.activeVendor;
1257
- if (!active)
1258
- return true;
1259
- return walled.includes(active);
971
+ return this.budget.bannerVisible;
1260
972
  }
1261
973
  /**
1262
- * Whether a suggestions fetch would hit a wall.
1263
- *
1264
- * Unlike a chat turn, this one CAN be resolved exactly: both suggestion paths
1265
- * go to the registry **default** and never to a per-agent override
1266
- * (`ChatDriver.getSuggestions` calls `providerRegistry.default()`, and
1267
- * `OrchestratingDriver.getSuggestions` just delegates to it). So the vendor a
1268
- * suggestions call would use is knowable, and this is the one place in the
1269
- * feature where that is true.
1270
- *
1271
- * Same rationale as the guard it replaces: a doomed suggestions call burns a
1272
- * `suggestions.failed` meta event and parks a raw transport error in
1273
- * `suggestionsState` for hosts to find.
974
+ * Whether a suggestions fetch would hit a wall — the one place in this feature
975
+ * where the vendor a call would use is exactly knowable, because both
976
+ * suggestion paths go to the registry default. See
977
+ * `BudgetController.suggestionsBlocked`.
1274
978
  *
1275
979
  * @internal
1276
980
  */
1277
981
  get suggestionsBlocked() {
1278
- if (this.blocked)
1279
- return true;
1280
- const active = this.activeVendor;
1281
- return !!active && this.isVendorBlocked(active);
1282
- }
1283
- /**
1284
- * The vendor the NEXT send resolves to — the registry default's provider, as
1285
- * reported through `providerStatuses`. `undefined` while the statuses are
1286
- * still loading (or when the default reports the `'none'` sentinel), which
1287
- * every consumer treats conservatively.
1288
- *
1289
- * @internal
1290
- */
1291
- get activeVendor() {
1292
- var _a, _b;
1293
- const vendor = (_b = (_a = this.providerStatuses.find((e) => e.isDefault)) === null || _a === void 0 ? void 0 : _a.status) === null || _b === void 0 ? void 0 : _b.provider;
1294
- return vendor && vendor !== 'none' ? vendor : undefined;
982
+ return this.budget.suggestionsBlocked;
1295
983
  }
1296
984
  /**
1297
985
  * Explanation shown in the blocked banner, or `null` for the element's default
@@ -1308,185 +996,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1308
996
  * @beta
1309
997
  */
1310
998
  get blockedReason() {
1311
- var _a, _b;
1312
- return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedReason) !== null && _b !== void 0 ? _b : null;
999
+ return this.budget.blockedReason;
1313
1000
  }
1314
1001
  set blockedReason(value) {
1315
- var _a;
1316
- // Deliberately NOT `setBlocked({ blocked: this.blocked, reason })`: `blocked`
1317
- // is derived now, so re-latching with it would pass `false` for a partially
1318
- // walled session and clear every per-vendor latch as a side effect of setting
1319
- // a string.
1320
- (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlockedReason(value);
1321
- }
1322
- /**
1323
- * Latch the backend block off a turn outcome. The single decision point for
1324
- * both latch sites (the `tool-loop-end` bus subscription and `send()`'s
1325
- * return-value check), so the rule cannot diverge between them.
1326
- *
1327
- * Idempotent and one-way **per vendor**: it never unblocks, and it never
1328
- * re-writes an existing wall. That guard is load-bearing, not just an
1329
- * optimisation — the bus fires from the driver's `finally`, i.e. BEFORE
1330
- * `sendMessage()` resolves, so a host subscribed to `tool-loop-end` (what the
1331
- * migration guide's Option C recommends) sets its own detailed reason and the
1332
- * return-value latch would otherwise land a moment later and blank it.
1333
- *
1334
- * The guard being per-vendor rather than global is the whole difference. A
1335
- * single global "already blocked, do nothing" would swallow a second vendor's
1336
- * wall — so a session walled on Anthropic could never record that Gemini went
1337
- * too, and under a mixed registry an Anthropic-only wall would lock a composer
1338
- * that Gemini could still serve.
1339
- *
1340
- * **Which field is authoritative**, in order:
1341
- *
1342
- * 1. `budget.vendorLabel` — stamped by the transport that was actually refused,
1343
- * so it can never be stale. But it is a static per-transport string, so a
1344
- * white-labelled or multiplexing gateway fronting several upstreams leaves
1345
- * it unclaimed by any vendor.
1346
- * 2. `budget.vendor` — which the driver resolved from the label where it could,
1347
- * and otherwise from the proxy's own `vendor` field on the 402. It is
1348
- * therefore NOT simply `vendorLabel` normalised, and the two can disagree;
1349
- * that fallback is the only attribution on offer for the gateway case above.
1350
- * 3. `vendorHint` — the bus detail's top-level vendor, i.e. the driver's
1351
- * last-resolved provider. Last resort because it CAN be stale: an
1352
- * orchestrated turn classifies against the registry default, a provider the
1353
- * chat driver may never have resolved, so its last-resolved provider there is
1354
- * the previous turn's vendor or nothing at all.
1355
- *
1356
- * A wall that names no recognised vendor falls back to the vendor-agnostic
1357
- * block — fail-safe, and identical to the pre-per-vendor behaviour.
1358
- *
1359
- * **`otherVendorAvailable: false` is server-known truth and outranks every
1360
- * inference made here.** The proxy meters the pots, so only it can say whether
1361
- * anything else has headroom; this element can only observe which vendors the
1362
- * registry can REACH, which says nothing about their remaining spend. So when
1363
- * the proxy says no, every other *budgeted* vendor is walled in the same pass.
1364
- * That is what makes `blocked` derive true — locking the composer on the first
1365
- * response instead of after a second doomed turn — and what empties the "free"
1366
- * set the banner would otherwise have advised switching to.
1367
- *
1368
- * The sweep runs over `BUDGETED_VENDORS`, deliberately **not** over
1369
- * {@link FoundationAiAssistant.reachableVendors}. Reachability is loaded
1370
- * asynchronously (`activateSession` kicks off `loadProviderStatuses()` and does
1371
- * not await it), so a wall landing before the statuses resolve would have swept
1372
- * an empty set — silently discarding the one fact on the 402 the client cannot
1373
- * re-derive, with nothing to re-run it when the statuses arrived. Worse, it let
1374
- * the verdict effectively un-latch: the single wall derived `blocked` only
1375
- * while the reachable set was empty, so the moment the statuses landed the
1376
- * composer came back to life against a budget the proxy had already said was
1377
- * gone. Sweeping the metered vendor list instead makes the verdict independent
1378
- * of load order and of registry membership, which is exactly what it is: a
1379
- * statement about the proxy's pots, not about this user's registry.
1380
- *
1381
- * Walling a vendor the registry cannot reach costs nothing: `blocked` only asks
1382
- * whether every REACHABLE vendor is walled, and the banner reads
1383
- * {@link FoundationAiAssistant.relevantBlockedVendors}, which filters the walls
1384
- * back down to the reachable set before naming any of them.
1385
- *
1386
- * One boundary remains on the sweep: it walls only vendors the proxy meters,
1387
- * enforced structurally by iterating `BUDGETED_VENDORS` itself rather than
1388
- * filtering a wider set through a predicate. Chrome runs on-device with no pot
1389
- * to exhaust, so "no other vendor has budget" is not a statement about it, and
1390
- * "Switch to Chrome to keep going." stays honest advice.
1391
- *
1392
- * The sweep runs last but sits OUTSIDE the per-vendor idempotence guard, and
1393
- * both halves of that matter. Last, so the vendor that actually refused this
1394
- * turn heads the wall order the multi-vendor banner reads. Outside the guard,
1395
- * because the verdict can arrive on a LATER 402 for a vendor that is already
1396
- * walled — turn 1 walls Anthropic while Gemini still has headroom, turn 2 walls
1397
- * it again and reports that Gemini has since gone too — and an early return
1398
- * would drop exactly the news the second turn was there to deliver.
1399
- *
1400
- * @param reason - the turn's failure reason; anything but `'budget-exhausted'` is ignored.
1401
- * @param ref - session store to write through; defaults to the live one. `send()`
1402
- * passes the ref it captured before its awaits, since a lifecycle event during the
1403
- * turn may already have cleared `_sessionRef`.
1404
- * @param budget - what the 402 reported: figures when the proxy sent any, the
1405
- * refusing vendor, and its `otherVendorAvailable` verdict.
1406
- * @param vendorHint - the `tool-loop-end` detail's top-level vendor, used only
1407
- * when the budget payload names none.
1408
- *
1409
- * @internal
1410
- */
1411
- latchBlockedFrom(reason, ref = this._sessionRef, budget, vendorHint) {
1412
- var _a, _b;
1413
- if (reason !== 'budget-exhausted')
1414
- return;
1415
- const vendor = (_b = (_a = vendorTypeOfLabel(budget === null || budget === void 0 ? void 0 : budget.vendorLabel)) !== null && _a !== void 0 ? _a : budget === null || budget === void 0 ? void 0 : budget.vendor) !== null && _b !== void 0 ? _b : vendorHint;
1416
- // Meter feed (GENC-1464): snap this vendor's meter to the figures the 402
1417
- // itself reported, the moment the wall lands. Hosts refresh the meter on
1418
- // `tool-loop-end`, and a walled turn may never reach one — the user then
1419
- // stares at the pre-turn figures next to a banner saying the budget is gone
1420
- // until something else ends a loop (observed live: the meter only caught up
1421
- // when the user cancelled the stuck interaction). Deliberately NOT under the
1422
- // per-vendor idempotence guard below, for the same reason as the sweep: a
1423
- // later 402 for an already-walled vendor carries fresher spend. Both figures
1424
- // are required (`VendorBudgetFigures` is total, and half a meter is a lie),
1425
- // and the write goes through the captured `ref` like every other write here,
1426
- // so a teardown race cannot land it in a new session's store.
1427
- let metered = false;
1428
- if (vendor && vendor !== 'none' && (budget === null || budget === void 0 ? void 0 : budget.budgetUsd) != null && (budget === null || budget === void 0 ? void 0 : budget.spentUsd) != null) {
1429
- ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBudget({
1430
- vendor,
1431
- figures: { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd },
1432
- });
1433
- this.budgetMeterLastFedAt = Date.now();
1434
- metered = true;
1435
- }
1436
- if (!vendor || vendor === 'none') {
1437
- if (!this.blocked) {
1438
- ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setBlocked({ blocked: true, reason: formatBlockedReason(budget) });
1439
- }
1440
- }
1441
- else if (!this.isVendorBlocked(vendor) || this.isVendorSwept(vendor)) {
1442
- // The idempotence guard protects a host-supplied reason from being
1443
- // overwritten by this latch's boilerplate — but a SWEPT wall cannot carry
1444
- // one (the sweep writes no reason, and any host call clears the swept
1445
- // mark), so upgrading it on a first-hand refusal clobbers nothing and
1446
- // gains the vendor its own figures plus a place in the early-window
1447
- // banner (see `relevantBlockedVendors`).
1448
- if (this.reachableVendors.length === 0) {
1449
- // Not fatal — `blocked` still derives to true, so the UI is correct — but
1450
- // the host gets no per-vendor benefit and would otherwise have no signal.
1451
- logger.warn(`FoundationAiAssistant: ${vendor} hit its AI budget, but no registered provider counts ` +
1452
- 'as reachable, so per-vendor blocking degrades to blocking everything. Two causes: no ' +
1453
- 'provider reports a status (implement AIProvider.getStatus()), or every provider that ' +
1454
- 'does is chat-less and so cannot serve a turn (implement chat() on at least one).');
1455
- }
1456
- ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({
1457
- vendor,
1458
- blocked: true,
1459
- // No reason when the meter snap above already stored these figures: the
1460
- // banner composes the same sentence from them, so carrying a copy here
1461
- // would duplicate the dollars in two places that can drift apart. It would
1462
- // also make this latch INDISTINGUISHABLE from host copy, which the banner
1463
- // must preserve verbatim — and preserving boilerplate is what forced it to
1464
- // repeat a sentence per walled vendor instead of folding them into one.
1465
- // The partial-figures case still needs copy carried here: it has no meter
1466
- // entry to compose from, and "an unknown amount of $50.00" is worth saying.
1467
- reason: metered ? undefined : formatBlockedReason(budget, vendor),
1468
- });
1469
- }
1470
- // Last, so the vendor that actually refused this turn heads the wall order the
1471
- // multi-vendor banner reads — and unconditionally, NOT under the per-vendor
1472
- // idempotence guard above, because the verdict can arrive on a later 402 for a
1473
- // vendor that is already walled.
1474
- if ((budget === null || budget === void 0 ? void 0 : budget.otherVendorAvailable) !== false)
1475
- return;
1476
- // Over the metered vendor list, NOT the reachable set: the statuses load
1477
- // asynchronously, so a reachability-scoped sweep dropped the verdict entirely
1478
- // when the wall beat them home. `relevantBlockedVendors` decides what the
1479
- // banner may NAME — reachability-filtered when the statuses are in, and
1480
- // refusals-only while they are not, which is what `swept: true` records.
1481
- for (const other of BUDGETED_VENDORS) {
1482
- if (other === vendor || this.isVendorBlocked(other))
1483
- continue;
1484
- // No reason: the slice's "an omitted reason keeps the existing explanation"
1485
- // rule applies, and there are no figures for this vendor — only the proxy's
1486
- // word that it has nothing left. `swept: true` records exactly that
1487
- // provenance, so the unknown-reachability banner can decline to name it.
1488
- ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({ vendor: other, blocked: true, swept: true });
1489
- }
1002
+ this.budget.blockedReason = value;
1490
1003
  }
1491
1004
  /**
1492
1005
  * Block or unblock the assistant, optionally with custom banner copy.
@@ -1502,119 +1015,26 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1502
1015
  * @beta
1503
1016
  */
1504
1017
  setBlocked(blocked, reason) {
1505
- var _a;
1506
- (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked, reason });
1018
+ this.budget.setBlocked(blocked, reason);
1507
1019
  }
1508
1020
  /**
1509
- * Copy shown in the blocked banner, in ascending specificity:
1510
- *
1511
- * 1. the host's `blockedReason` — a whole-banner override, unchanged;
1512
- * 2. copy composed from the walled vendors (their per-vendor reasons when set,
1513
- * otherwise their names) plus the action that is actually available;
1514
- * 3. the default budget-exhaustion message.
1515
- *
1516
- * The action clause is computed here rather than stored, because whether
1517
- * "switch vendor" or "contact your administrator" is honest depends on whether
1518
- * another reachable vendor still has headroom — which changes when the registry
1519
- * changes, long after the wall was latched. Telling a user to switch to a
1520
- * vendor that is also exhausted is worse than saying nothing.
1521
- *
1522
- * The `free` set is a client-side inference — "reachable and not known to be
1523
- * walled" — and it is only ever allowed to be one. Where the proxy has told us
1524
- * otherwise (`otherVendorAvailable: false`) the latch has already walled those
1525
- * vendors, so they are not in `free` here and no "switch to X" clause can be
1526
- * composed from them. Both lists are filtered by reachability, so the copy
1527
- * names only vendors this user's registry can actually route to.
1528
- *
1529
- * **Per-vendor copy is composed however many vendors are walled**, which is
1530
- * what {@link FoundationAiAssistant.setVendorBlocked} and
1531
- * `docs/migration-GENC-1464.md` both promise. It previously reached only the
1532
- * single-vendor branch, so a host that had carefully set copy for each of its
1533
- * vendors watched all of it vanish the moment a second one walled — replaced by
1534
- * generic copy, at the exact moment the situation got worse.
1535
- *
1536
- * The compact list is the DEFAULT shape for several walls, now carrying each
1537
- * vendor's figures: "AI usage limits are reached for Gemini ($62.34 of $50.00)
1538
- * and Anthropic ($31.10 of $30.00)." A driver latch stores no copy of its own
1539
- * (it stores the figures, which this composes from), so that shape is what the
1540
- * real budget flow produces. As soon as any walled vendor carries HOST copy,
1541
- * which cannot be folded into a list item, the branch lists one statement per
1542
- * vendor instead — falling back to composed copy for the ones that have none, so
1543
- * the sentence set stays complete rather than silently naming a subset.
1021
+ * Copy shown in the blocked banner — the host's override, else copy composed
1022
+ * from the walled vendors, else the default. See
1023
+ * `BudgetController.effectiveBlockedReason`, which explains why the
1024
+ * action clause is composed on read rather than stored at latch time.
1544
1025
  *
1545
1026
  * @internal
1546
1027
  */
1547
1028
  get effectiveBlockedReason() {
1548
- var _a, _b;
1549
- if (this.blockedReason)
1550
- return this.blockedReason;
1551
- const walled = this.relevantBlockedVendors;
1552
- if (walled.length === 0)
1553
- return DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
1554
- const free = this.reachableVendors.filter((v) => !walled.includes(v));
1555
- const action = free.length
1556
- ? `Switch to ${formatVendorList(free)} to keep going.`
1557
- : walled.length > 1
1558
- ? RAISE_LIMITS_ACTION
1559
- : RAISE_LIMIT_ACTION;
1560
- const reasons = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedVendorReasons;
1561
- const budgets = (_b = this._sessionRef) === null || _b === void 0 ? void 0 : _b.store.aiAssistant.vendorBudgets;
1562
- // The wall's own figures, read from the entry the METER renders, so the two
1563
- // cannot state different dollars for the same vendor. A vendor with no entry
1564
- // (a swept wall — the proxy's word, no figures of its own) contributes a bare
1565
- // name rather than a gap where numbers should be.
1566
- const statementFor = (v) => {
1567
- var _a, _b;
1568
- return (_b = (_a = reasons === null || reasons === void 0 ? void 0 : reasons[v]) !== null && _a !== void 0 ? _a : formatBlockedReason(budgets === null || budgets === void 0 ? void 0 : budgets[v], v)) !== null && _b !== void 0 ? _b : `${vendorDisplayName(v)}'s AI usage limit is reached.`;
1569
- };
1570
- const nameWithFigures = (v) => {
1571
- const figures = budgets === null || budgets === void 0 ? void 0 : budgets[v];
1572
- return figures
1573
- ? `${vendorDisplayName(v)} (${formatUsd(figures.spentUsd)} of ${formatUsd(figures.budgetUsd)})`
1574
- : vendorDisplayName(v);
1575
- };
1576
- if (walled.length === 1)
1577
- return `${statementFor(walled[0])} ${action}`;
1578
- // Several walled vendors: fold them into ONE sentence carrying each vendor's
1579
- // figures, rather than repeating "X's AI usage limit is reached (…)." per
1580
- // vendor — the same clause twice reads like a stutter, and the second copy of
1581
- // the boilerplate adds nothing the first did not already say.
1582
- //
1583
- // Host copy is not foldable: `setVendorBlocked` promises it survives verbatim,
1584
- // and it cannot be rewritten into a list item. So the moment any walled vendor
1585
- // carries some, the branch below lists one statement per vendor instead —
1586
- // falling back to composed copy for the ones that have none, so the sentence
1587
- // set stays complete rather than silently naming a subset.
1588
- if (!walled.some((v) => reasons === null || reasons === void 0 ? void 0 : reasons[v])) {
1589
- return `AI usage limits are reached for ${joinList(walled.map(nameWithFigures))}. ${action}`;
1590
- }
1591
- return `${walled.map(statementFor).join(' ')} ${action}`;
1029
+ return this.budget.effectiveBlockedReason;
1592
1030
  }
1593
1031
  /**
1594
1032
  * Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall,
1595
- * or `undefined` to let the driver compose it per wall.
1596
- *
1597
- * A set `blockedReason` still wins — a white-labelled host must not read its
1598
- * own explanation in the banner and shipped copy directly below it, and the
1599
- * vendor-agnostic latch's `formatBlockedReason(budget)` form stays advice-free
1600
- * exactly as before.
1601
- *
1602
- * `undefined` (the usual case) is NOT the old `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`
1603
- * fallback: it hands composition to the driver, which builds the bubble from
1604
- * EACH wall's own 402 — naming the exhausted vendor and advising a switch only
1605
- * when that refusal's `otherVendorAvailable` positively vouched for headroom
1606
- * elsewhere. That is immune to the staleness this getter used to guard against
1607
- * by freezing the copy: construction-time advice could outlive its truth
1608
- * (`getOrCreateDriver` re-runs on an agents swap and would have baked the
1609
- * sentence into every later bubble), but per-wall advice is derived from the
1610
- * refusal it annotates and dies with the turn. Forcing the default here was
1611
- * also what made the driver's composed bubble unreachable in every real
1612
- * mount — the banner advised switching while the bubble under it never did
1613
- * (the GENC-1464 tester finding, root cause).
1033
+ * or `undefined` to let the driver compose it per wall — see
1034
+ * `BudgetController.transcriptMessage`.
1614
1035
  */
1615
1036
  get transcriptBudgetExhaustedMessage() {
1616
- var _a;
1617
- return (_a = this.blockedReason) !== null && _a !== void 0 ? _a : undefined;
1037
+ return this.budget.transcriptMessage;
1618
1038
  }
1619
1039
  /**
1620
1040
  * Name of the agent the user has pinned via the agent picker. `null` means
@@ -1631,12 +1051,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1631
1051
  return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.pinnedAgentName) !== null && _b !== void 0 ? _b : null;
1632
1052
  }
1633
1053
  set pinnedAgentName(value) {
1634
- var _a, _b, _c;
1054
+ var _a, _b, _c, _d;
1635
1055
  const previous = (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.pinnedAgentName) !== null && _b !== void 0 ? _b : null;
1636
1056
  (_c = this._sessionRef) === null || _c === void 0 ? void 0 : _c.actions.aiAssistant.setPinnedAgentName(value);
1637
- if (this.driver instanceof OrchestratingDriver) {
1638
- this.driver.setPinnedAgent(value);
1639
- }
1057
+ (_d = this.orchestrator) === null || _d === void 0 ? void 0 : _d.setPinnedAgent(value);
1640
1058
  // Suggestions are scoped to the active routing — resetting forces a fresh
1641
1059
  // fetch when the user pins/unpins so the prompts match the new agent.
1642
1060
  if (previous !== value) {
@@ -1659,11 +1077,9 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1659
1077
  return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.flowOwnerAgentName) !== null && _b !== void 0 ? _b : null;
1660
1078
  }
1661
1079
  set flowOwnerAgentName(value) {
1662
- var _a;
1080
+ var _a, _b;
1663
1081
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setFlowOwnerAgentName(value);
1664
- if (this.driver instanceof OrchestratingDriver) {
1665
- this.driver.setFlowOwner(value);
1666
- }
1082
+ (_b = this.orchestrator) === null || _b === void 0 ? void 0 : _b.setFlowOwner(value);
1667
1083
  }
1668
1084
  get liveSubAgentTrace() {
1669
1085
  var _a, _b;
@@ -1744,83 +1160,47 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1744
1160
  return this.sessionUsage.costUsd;
1745
1161
  }
1746
1162
  /**
1747
- * Distinct models the registry currently resolves to — the live session's model chips.
1748
- *
1749
- * `providerStatuses` carries one entry per registry SLOT, so a vendor configured across several
1750
- * tiers that happen to resolve to the same model reports it once per tier, and the chips
1751
- * rendered it once per tier too. Which tier a model came from is not what these chips are for.
1163
+ * The cost ledger's derived surface (GENC-1475). Every member below delegates to
1164
+ * `CostLedgerController` — the names are this element's public API and the
1165
+ * settings modal's bindings, so they stay put while the accounting moves.
1752
1166
  *
1753
- * Deduped on the label actually displayed, not on `status.model`: a slot whose provider reports
1754
- * no model falls back to the slot's own name, and those are distinct per slot, so keying on the
1755
- * label keeps them from collapsing into one another.
1167
+ * None carries `@volatile`: the decorators live on the controller's getters, and they
1168
+ * reach the binding observer from there — see `docs/code-health.md` §11.4.
1756
1169
  */
1757
1170
  get uniqueProviderModels() {
1758
- var _a, _b, _c, _d;
1759
- const seen = new Map();
1760
- for (const entry of this.providerStatuses) {
1761
- const model = (_b = (_a = entry.status) === null || _a === void 0 ? void 0 : _a.model) !== null && _b !== void 0 ? _b : entry.name;
1762
- if (!model)
1763
- continue;
1764
- const existing = seen.get(model);
1765
- if (!existing) {
1766
- seen.set(model, { model, provider: (_c = entry.status) === null || _c === void 0 ? void 0 : _c.provider });
1767
- continue;
1768
- }
1769
- // Keep whichever tier knew the vendor, so the chip still gets its colour when one slot
1770
- // reports a provider and another does not.
1771
- if (existing.provider === undefined && ((_d = entry.status) === null || _d === void 0 ? void 0 : _d.provider) !== undefined) {
1772
- existing.provider = entry.status.provider;
1773
- }
1774
- }
1775
- return [...seen.values()];
1171
+ return this.costLedger.uniqueProviderModels;
1776
1172
  }
1777
- /**
1778
- * Build history for display: most recently worked on first.
1779
- *
1780
- * The stored order is insertion order (see `upsertRecord`), which leaves a project returned to
1781
- * sitting wherever it was first seen — so ordering is applied here, on read, where it also
1782
- * covers rows a host provider supplied.
1783
- */
1173
+ /** Build history for display: most recently worked on first. */
1784
1174
  get sortedCostSessionHistory() {
1785
- return sortRecordsByRecency(this.costSessionHistory);
1175
+ return this.costLedger.sortedHistory;
1786
1176
  }
1787
- /**
1788
- * Lifetime usage across every project in the history scope — cost and all four token
1789
- * buckets. What the Usage tab's lifetime section renders.
1790
- *
1791
- * Rows ONLY — deliberately not rows + live session. Each row holds a project's lifetime
1792
- * usage and the active project's row is kept current on every transcript change, so
1793
- * adding the live total would count this project's spend twice. That double-count is what
1794
- * made the figure grow on every refresh: a page-hide banked a row while the restored
1795
- * session still carried the same spend.
1796
- */
1177
+ /** Lifetime usage across every project in the history scope — rows only, never plus live. */
1797
1178
  get cumulativeUsage() {
1798
- void this.costSessionHistory;
1799
- void this.chatConfig;
1800
- // With history off there are no rows to sum, so the live session IS all we know.
1801
- if (!this.resolvedCostHistory.enabled)
1802
- return this.sessionUsage;
1803
- return this.costSessionHistory.reduce((total, record) => addUsage(total, record.usage), emptyUsage());
1179
+ return this.costLedger.cumulativeUsage;
1804
1180
  }
1805
1181
  /** Lifetime USD across every project in the history scope. */
1806
1182
  get cumulativeCostUsd() {
1807
- return this.cumulativeUsage.costUsd;
1183
+ return this.costLedger.cumulativeCostUsd;
1808
1184
  }
1809
- /**
1810
- * Whether the live session figures amount to this project's whole history, which is true
1811
- * exactly when the transcript is restored on load. Drives the Usage tab's live labels
1812
- * ("Project cost" vs "Session cost") so they state the scope rather than imply one.
1813
- *
1814
- * Note this is NOT what makes the *rows* lifetime — those bank their pre-transcript spend
1815
- * (see `CostSessionRecord.banked`) and are lifetime either way.
1816
- */
1185
+ /** Whether the live session figures amount to this project's whole history. */
1817
1186
  get liveUsageIsProjectLifetime() {
1818
- return this.persistenceActive;
1187
+ return this.costLedger.liveUsageIsProjectLifetime;
1819
1188
  }
1820
1189
  /** Resolved cost tab and build-history settings from `chatConfig.costHistory`. */
1821
1190
  get resolvedCostHistory() {
1822
- void this.chatConfig;
1823
- return resolveCostHistoryConfig(this.chatConfig);
1191
+ return this.costLedger.config;
1192
+ }
1193
+ /**
1194
+ * Persisted finalized cost sessions shown on the Usage tab build history list.
1195
+ *
1196
+ * A get/set pair rather than a read-only projection: the banking tests seed rows to
1197
+ * model a page load that found some, and a host provider's rows arrive the same way.
1198
+ */
1199
+ get costSessionHistory() {
1200
+ return this.costLedger.history;
1201
+ }
1202
+ set costSessionHistory(value) {
1203
+ this.costLedger.history = value;
1824
1204
  }
1825
1205
  /**
1826
1206
  * Total tokens across every chat turn in this session — all four buckets added up.
@@ -1831,157 +1211,26 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1831
1211
  }
1832
1212
  /** Label shown on the Usage tab for the in-flight session (app title or first user phrase). */
1833
1213
  get liveCostSessionTitle() {
1834
- var _a, _b;
1835
- void this.messages;
1836
- void this.chatConfig;
1837
- const appTitle = ((_a = this.resolvedCostHistory.title) === null || _a === void 0 ? void 0 : _a.trim()) || ((_b = this._activeCostSessionTitle) === null || _b === void 0 ? void 0 : _b.trim());
1838
- if (appTitle)
1839
- return appTitle;
1840
- return deriveCostSessionTitleFromMessages(this.messages);
1214
+ return this.costLedger.liveTitle;
1841
1215
  }
1842
1216
  /**
1843
- * Write this session's usage into persisted build history (`localStorage`) as a single
1844
- * row keyed by the session key, replacing any existing row for that key.
1845
- *
1846
- * **Idempotent** — safe to call per turn, on tab close, and on a title change alike.
1847
- * The name is historical: it does NOT end the session or reset the running totals.
1848
- * Resetting them is what used to double-count spend, since the row was banked while the
1849
- * persisted transcript still carried the same usage, so a reload restored it and the
1850
- * Usage tab counted both.
1217
+ * Write this session's usage into persisted build history as a single row keyed by the
1218
+ * session key, replacing any existing row for that key.
1851
1219
  *
1852
- * No-op unless `chatConfig.costHistory.enabled` is `true`, when the live session has no
1853
- * usage yet, or when the session has no key to upsert against. Gating on the *live*
1854
- * figures (not the banked total) means a reload that restores nothing rewrites nothing.
1220
+ * **Idempotent** — safe to call per turn, on tab close, and on a title change alike. The
1221
+ * name is historical: it does NOT end the session or reset the running totals. See
1222
+ * `CostLedgerController.finalize` for the full contract and the no-op cases.
1855
1223
  */
1856
1224
  finalizeCostSession(options) {
1857
- if (!this.resolvedCostHistory.enabled)
1858
- return;
1859
- // Wait for the rows before writing: a provider read is async, and banking from a
1860
- // not-yet-loaded ledger would read as "nothing came before" and rewrite this project's
1861
- // row down to the current transcript. Skipping is free — this is called on every
1862
- // transcript change, so the next one writes.
1863
- if (!this._costHistoryLoaded)
1864
- return;
1865
- const live = this.sessionUsage;
1866
- const tokensConsumed = totalTokens(live);
1867
- if (live.costUsd <= 0 && tokensConsumed <= 0)
1868
- return;
1869
- // The session key identifies the work — a project id, for hosts that key sessions
1870
- // per project. Without one there is nothing stable to upsert against, so skip
1871
- // rather than write a row that can never be matched again.
1872
- const projectKey = this.getStateKey();
1873
- if (!projectKey)
1874
- return;
1875
- const title = resolveCostSessionTitle({
1876
- explicit: options === null || options === void 0 ? void 0 : options.title,
1877
- appTitle: this.resolvedCostHistory.title,
1878
- messages: this.messages,
1879
- headerTitle: this.headerTitle,
1880
- });
1881
- // Lifetime = what came before this transcript + what this transcript proves. Both
1882
- // terms are derived from state, never incremented, so the two element instances that
1883
- // share a session write the same row rather than each adding their own contribution.
1884
- const banked = this.bankedUsageFor(projectKey);
1885
- const record = {
1886
- // Stable per project so repeat upserts replace the row instead of stacking, and
1887
- // so the delete action keeps targeting the same row across reloads.
1888
- id: projectKey,
1889
- projectKey,
1890
- title,
1891
- updatedAt: new Date().toISOString(),
1892
- usage: addUsage(banked, live),
1893
- banked,
1894
- models: collectSessionModels(this.messages, this.providerStatuses),
1895
- };
1896
- this.persistCostHistory(upsertRecord(this.costSessionHistory, record));
1897
- // NOTE: the live counters are deliberately NOT reset here. Resetting was what
1898
- // double-counted spend — the row was banked while the persisted transcript still
1899
- // carried the same usage, so a reload restored it and the Usage tab added both.
1900
- // The row is a projection of the session totals, not a transfer out of them.
1901
- }
1902
- /**
1903
- * Spend for `projectKey` that predates the current transcript, resolved once per SESSION and
1904
- * shared by every element instance wired to it (see `banked-usage-baselines`), so it cannot
1905
- * drift as the row it came from is rewritten by our own upserts.
1906
- *
1907
- * The two cases turn on whether the transcript is authoritative for this project:
1908
- *
1909
- * - **Transcript restored** (`persistenceActive`): it already accounts for everything after
1910
- * the previous row's own banked figure, so carry that figure forward unchanged. Re-banking
1911
- * `usage` here would add the restored history to itself on every reload.
1912
- * - **Transcript starts empty**: nothing on screen accounts for the previous total, so the
1913
- * whole of it becomes banked. Without this the next turn would rewrite the row downwards
1914
- * to just that turn, losing the project's earlier spend.
1915
- *
1916
- * The baseline is deliberately NOT element state. This element is remounted repeatedly inside
1917
- * a single session (the host re-rendering its layout, popping out, docking) and a fresh
1918
- * instance re-deriving would take the second branch against rows that already hold the live
1919
- * transcript — banking the session as its own baseline. Keying on the session instead means a
1920
- * remount inherits the baseline that transcript was paired with.
1921
- */
1922
- bankedUsageFor(projectKey) {
1923
- const resolved = getBankedBaseline(projectKey);
1924
- if (resolved)
1925
- return resolved;
1926
- const existing = this.costSessionHistory.find((r) => r.projectKey === projectKey);
1927
- // `persistenceActive` is what decides whether the transcript speaks for the project's
1928
- // history: it is on only when a provider is wired AND the user is remembering the chat.
1929
- const banked = resolveBankedUsage(existing, this.persistenceActive);
1930
- setBankedBaseline(projectKey, banked);
1931
- return banked;
1225
+ this.costLedger.finalize(options);
1932
1226
  }
1933
1227
  /**
1934
- * Re-baseline this session against an emptied transcript: what came before, plus what the
1935
- * transcript being discarded proved. `discarded` must be read BEFORE the live figures are
1936
- * reset, since that is the only record of the latter.
1937
- *
1938
- * Not the same as dropping the baseline. Dropping it re-derives through `persistenceActive`,
1939
- * and with persistence ON that carries the row's *old* banked figure forward — discarding
1940
- * every turn between the last baseline and the clear. The transcript is gone either way, so
1941
- * the branch that reads it as authoritative no longer applies.
1942
- *
1943
- * Derived from the memoised baseline and the live figure rather than from the project's row,
1944
- * because a row is only as current as the last `finalizeCostSession` — which skips its write
1945
- * while the ledger is unloaded, and while the session has no usage yet.
1228
+ * Re-baseline this session against an emptied transcript — see
1229
+ * `CostLedgerController.rebaselineOnClearedTranscript`. `discarded` must be read
1230
+ * BEFORE the live figures are reset, since that is the only record of the latter.
1946
1231
  */
1947
1232
  rebaselineOnClearedTranscript(key, discarded) {
1948
- // Already resolved for this session? Then the new baseline is fully determined without
1949
- // consulting the ledger at all — `bankedUsageFor` would short-circuit on this same value — so
1950
- // answer before the guard below, which would otherwise discard a figure that is both correct
1951
- // and independent of the rows. Losing it costs real spend: with a row at `usage: 120,
1952
- // banked: 90` and 30 on screen, a clear during a reload would drop the memoised 90, and the
1953
- // next turn would re-derive `row.banked` and rewrite the row BACKWARDS from 120 to 95.
1954
- const resolved = getBankedBaseline(key);
1955
- if (resolved) {
1956
- setBankedBaseline(key, addUsage(resolved, discarded));
1957
- return;
1958
- }
1959
- // Nothing memoised, so a baseline can only come from the rows — and a stamped one is STICKY
1960
- // (`bankedUsageFor` short-circuits on any stored entry, and a zero usage is a *resolved* value
1961
- // there, not "unresolved"). So unless the rows are genuinely known, do not guess:
1962
- //
1963
- // - `enabled` false: `reloadCostSessionHistory` sets `_costHistoryLoaded` TRUE over a
1964
- // deliberately empty list, meaning "nothing will be loaded" rather than "rows are known".
1965
- // The flag alone therefore is not enough. Reachable because `enabled` is
1966
- // `cost?.enabled === true` — false for an unbound `chatConfig`, and for any host resolving
1967
- // it from an async setting, exactly as `chatConfigChanged` already expects.
1968
- // - Provider read in flight: the list is emptied and the flag cleared synchronously, on
1969
- // connect and again on every `chatConfig` rebind.
1970
- // - Unreadable: the rows we hold are a fallback, not the ledger.
1971
- //
1972
- // In all three, pinning would bury this project's real total the moment it arrived — a later
1973
- // load does NOT invalidate baselines (deliberately, see `reloadCostSessionHistory`), so the
1974
- // next write would replace the row off the pinned figure and lose everything before it.
1975
- //
1976
- // Dropping instead leaves the next write to re-derive from rows that actually arrived. It
1977
- // under-banks `discarded` in that window — a bounded loss, and what every clear did before
1978
- // this method existed. Nothing is written while disabled, so there is no banked value to
1979
- // preserve in that case either.
1980
- if (!this.resolvedCostHistory.enabled || !this._costHistoryLoaded || this._ledgerUnreadable) {
1981
- deleteBankedBaseline(key);
1982
- return;
1983
- }
1984
- setBankedBaseline(key, addUsage(this.bankedUsageFor(key), discarded));
1233
+ this.costLedger.rebaselineOnClearedTranscript(key, discarded);
1985
1234
  }
1986
1235
  showConfirmationDialog(confirmationMessage, modalParent, onConfirm, onCancel) {
1987
1236
  showNotificationDialog({
@@ -1995,174 +1244,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1995
1244
  }
1996
1245
  /** Remove one entry from persisted build history. */
1997
1246
  removeCostSessionFromHistory(id) {
1998
- // Deleting a row discards its banked spend by intent, so drop the baseline too —
1999
- // otherwise the next turn would rewrite the row we just removed, restoring the total the
2000
- // user asked to be rid of.
2001
- //
2002
- // Resolve the row first: `id` is a row id while baselines are keyed by projectKey. They
2003
- // match only for rows this element wrote (`finalizeCostSession` sets `id: projectKey`); a
2004
- // row from a host provider can carry any id, and deleting by the wrong namespace would
2005
- // silently leave the baseline in place.
2006
- const removed = this.costSessionHistory.find((record) => record.id === id);
2007
- if (removed)
2008
- deleteBankedBaseline(removed.projectKey);
2009
- this.persistCostHistory(this.costSessionHistory.filter((record) => record.id !== id));
1247
+ this.costLedger.remove(id);
2010
1248
  }
2011
1249
  /** Clear all persisted build history for this assistant scope. */
2012
1250
  clearCostSessionHistory() {
2013
- // Every row is gone, so nothing is banked any more — without this the next turn would
2014
- // resurrect the pre-clear total. Dropped per row rather than for the active session alone,
2015
- // so a project whose row was cleared while another session was displaying it does not keep
2016
- // a baseline pointing at spend that no longer exists anywhere.
2017
- for (const record of this.costSessionHistory)
2018
- deleteBankedBaseline(record.projectKey);
2019
- // Provider-backed: write an empty ledger through. The `localStorage` path instead
2020
- // removes its key outright rather than leaving an empty array behind.
2021
- if (this.costHistoryProvider()) {
2022
- this.persistCostHistory([]);
2023
- return;
2024
- }
2025
- clearCostSessionHistory(this.getCostHistoryScope());
2026
- this.costSessionHistory = [];
2027
- }
2028
- getCostHistoryScope() {
2029
- const scope = this.resolvedCostHistory.scope;
2030
- if (scope)
2031
- return scope;
2032
- if (this.id)
2033
- return this.id;
2034
- return 'default';
2035
- }
2036
- reloadCostSessionHistory() {
2037
- // NOTE: deliberately does NOT invalidate the banked figure. This runs again whenever
2038
- // `chatConfig` rebinds — hosts commonly build it in a template expression, so a new
2039
- // object arrives mid-session — and by then the rows include our own upserts, whose
2040
- // `usage` already contains the live transcript. Re-deriving from those would add the
2041
- // current session to itself. `bankedUsageFor` keys its memo on the project instead,
2042
- // which handles a genuine session switch without trusting reload timing.
2043
- this._costHistoryLoaded = false;
2044
- this._ledgerUnreadable = false;
2045
- if (!this.resolvedCostHistory.enabled) {
2046
- this.costSessionHistory = [];
2047
- this._costHistoryLoaded = true;
2048
- return;
2049
- }
2050
- const scope = this.getCostHistoryScope();
2051
- const provider = this.costHistoryProvider();
2052
- if (!provider) {
2053
- this.costSessionHistory = loadCostSessionHistory(scope);
2054
- this._costHistoryLoaded = true;
2055
- return;
2056
- }
2057
- // Provider reads are async, so the Usage tab renders an empty list for a tick before
2058
- // this lands. Seeded empty rather than left stale so a scope change can't briefly show
2059
- // the previous scope's rows.
2060
- this.costSessionHistory = [];
2061
- void provider.loadCostHistory(scope)
2062
- .then((records) => {
2063
- // Guard against a scope change while the load was in flight — a late result must
2064
- // not overwrite the rows of whatever scope is current now.
2065
- if (this.getCostHistoryScope() === scope) {
2066
- // Filtered exactly as the localStorage path is. A host's rows are as capable of
2067
- // being malformed as a hand-edited storage entry, and an unchecked one poisons
2068
- // every total with NaN or throws in the sort during render.
2069
- this.costSessionHistory = (records !== null && records !== void 0 ? records : []).filter(isCostSessionRecord);
2070
- this._costHistoryLoaded = true;
2071
- }
2072
- })
2073
- .catch((e) => {
2074
- logger.warn('Cost history unreadable — recording locally only this page-load', e);
2075
- // Writes stay blocked, and `persistCostHistory` will not save even if something else
2076
- // calls it. `saveCostHistory` REPLACES the stored list, so writing an upsert against
2077
- // the empty list we fell back to would delete every other project's row and reset
2078
- // this project's banked total — destroying data we simply failed to read. The row
2079
- // rebuilds from the transcript on the next successful load.
2080
- this._ledgerUnreadable = true;
2081
- });
2082
- }
2083
- /**
2084
- * The persistence provider, but only when it implements BOTH cost-history methods.
2085
- *
2086
- * Both or neither: a provider that can save but not load would silently accumulate a
2087
- * ledger nobody ever reads back, so a partial implementation is treated as absent and
2088
- * falls back to `localStorage`.
2089
- */
2090
- costHistoryProvider() {
2091
- const provider = this.persistence.provider;
2092
- return (provider === null || provider === void 0 ? void 0 : provider.loadCostHistory) && provider.saveCostHistory ? provider : undefined;
2093
- }
2094
- /**
2095
- * Adopt `records` as the live list, and schedule a write to whichever backend owns cost
2096
- * history — the provider when the host implements it, else `localStorage`.
2097
- *
2098
- * The list is adopted synchronously because that is what the Usage tab renders; only the
2099
- * *write* is deferred. This runs on every usage change (about once per LLM call, plus once
2100
- * per sub-agent turn) and each write serialises the whole ledger, so coalescing cuts a
2101
- * per-turn `localStorage.setItem` or network round-trip down to one per quiet period. Safe
2102
- * because the write is a full replace of idempotent state: the newest call wins and no
2103
- * intermediate state is meaningful. Flushed on `pagehide` and disconnect so nothing is lost.
2104
- */
2105
- persistCostHistory(records) {
2106
- this.costSessionHistory = records;
2107
- // A ledger we could not read must never be written back — see reloadCostSessionHistory.
2108
- // Guarded here rather than only at the finalize gate so it also covers the row-removal and
2109
- // clear paths, which write directly.
2110
- if (this._ledgerUnreadable)
2111
- return;
2112
- this._ledgerDirty = true;
2113
- if (this._ledgerFlushTimer !== undefined)
2114
- clearTimeout(this._ledgerFlushTimer);
2115
- this._ledgerFlushTimer = setTimeout(() => this.flushCostHistory(), LEDGER_FLUSH_DEBOUNCE_MS);
2116
- }
2117
- /**
2118
- * Write the pending ledger now. Safe to call when nothing is pending.
2119
- *
2120
- * Provider writes are fire-and-forget and best-effort: the ledger is accounting, not
2121
- * conversation content, so a failed write is logged and never surfaced into the chat. On
2122
- * `pagehide` the `localStorage` path completes synchronously; a provider write is subject to
2123
- * the same unload race as every other network save the assistant makes.
2124
- */
2125
- flushCostHistory() {
2126
- if (this._ledgerFlushTimer !== undefined) {
2127
- clearTimeout(this._ledgerFlushTimer);
2128
- this._ledgerFlushTimer = undefined;
2129
- }
2130
- if (!this._ledgerDirty || this._ledgerUnreadable)
2131
- return;
2132
- this._ledgerDirty = false;
2133
- const records = this.costSessionHistory;
2134
- const scope = this.getCostHistoryScope();
2135
- const provider = this.costHistoryProvider();
2136
- if (!provider) {
2137
- saveCostSessionHistory(scope, records);
2138
- return;
2139
- }
2140
- void provider.saveCostHistory(scope, records).catch((e) => {
2141
- logger.warn('Cost history save failed', e);
2142
- });
2143
- }
2144
- /**
2145
- * Finalize the previous session when the host-provided title changes — a rename should land on
2146
- * the row under its old name rather than silently relabelling accrued spend.
2147
- *
2148
- * Only when the session key still matches the one that title accrued under. Hosts change
2149
- * `costHistory.title` on project SWITCH too, and if the key has already flipped, this would
2150
- * upsert the outgoing project's live usage onto the incoming project's row as
2151
- * `banked + old live`, inflating it. Under the old append model a mis-keyed write left a stray
2152
- * row; with the upsert it overwrites a real one.
2153
- */
2154
- syncActiveCostSessionTitle() {
2155
- var _a, _b;
2156
- const previous = this._activeCostSessionTitle;
2157
- const next = (_b = (_a = this.resolvedCostHistory.title) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '';
2158
- const key = this.getStateKey();
2159
- if (previous && previous !== next && this._activeCostSessionKey === key) {
2160
- this.finalizeCostSession({ title: previous });
2161
- }
2162
- if (previous !== next) {
2163
- this._activeCostSessionTitle = next;
2164
- this._activeCostSessionKey = key;
2165
- }
1251
+ this.costLedger.clear();
2166
1252
  }
2167
1253
  /** Active model id resolved from the AIProvider, if exposed. */
2168
1254
  get activeModel() {
@@ -2199,6 +1285,41 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2199
1285
  var _a;
2200
1286
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setInputValue(value);
2201
1287
  }
1288
+ /** Attachments pending on the next message. */
1289
+ get attachments() {
1290
+ return this.attachmentCtrl.attachments;
1291
+ }
1292
+ set attachments(value) {
1293
+ this.attachmentCtrl.attachments = value;
1294
+ }
1295
+ /** Rejection messages for files the last pick refused. */
1296
+ get attachmentErrors() {
1297
+ return this.attachmentCtrl.errors;
1298
+ }
1299
+ set attachmentErrors(value) {
1300
+ this.attachmentCtrl.errors = value;
1301
+ }
1302
+ /** Bump to re-render app settings toggles when the host provider notifies a change. */
1303
+ get appSettingsRevision() {
1304
+ return this.settings.appSettingsRevision;
1305
+ }
1306
+ set appSettingsRevision(value) {
1307
+ this.settings.appSettingsRevision = value;
1308
+ }
1309
+ /** Whether the settings modal is open. */
1310
+ get settingsOpen() {
1311
+ return this.settings.open;
1312
+ }
1313
+ set settingsOpen(value) {
1314
+ this.settings.open = value;
1315
+ }
1316
+ /** Active tab inside the settings modal. */
1317
+ get settingsModalTab() {
1318
+ return this.settings.tab;
1319
+ }
1320
+ set settingsModalTab(value) {
1321
+ this.settings.tab = value;
1322
+ }
2202
1323
  /**
2203
1324
  * Showing and hiding the modal hangs off the PROPERTY, so `persistenceBroken` is the single
2204
1325
  * entry point — for this element's own persister callback and for a host reporting a failure in
@@ -2263,6 +1384,69 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2263
1384
  (_a = this.persistenceEscapeGuardTarget) === null || _a === void 0 ? void 0 : _a.removeEventListener('cancel', this.onPersistenceModalCancel);
2264
1385
  this.persistenceEscapeGuardTarget = undefined;
2265
1386
  }
1387
+ /**
1388
+ * Light-DOM nodes assigned to settings modal slots. The template's `slotted()`
1389
+ * bindings write these by property NAME, so each needs a real setter here even
1390
+ * though the controller owns the arrays.
1391
+ */
1392
+ get settingsModelSlotted() {
1393
+ return this.settings.modelSlotted;
1394
+ }
1395
+ set settingsModelSlotted(value) {
1396
+ this.settings.modelSlotted = value;
1397
+ }
1398
+ get settingsAppSlotted() {
1399
+ return this.settings.appSlotted;
1400
+ }
1401
+ set settingsAppSlotted(value) {
1402
+ this.settings.appSlotted = value;
1403
+ }
1404
+ get settingsCostCurrentSlotted() {
1405
+ return this.settings.costCurrentSlotted;
1406
+ }
1407
+ set settingsCostCurrentSlotted(value) {
1408
+ this.settings.costCurrentSlotted = value;
1409
+ }
1410
+ get settingsCostHistorySlotted() {
1411
+ return this.settings.costHistorySlotted;
1412
+ }
1413
+ set settingsCostHistorySlotted(value) {
1414
+ this.settings.costHistorySlotted = value;
1415
+ }
1416
+ /**
1417
+ * This element's driver as an orchestrator, or `undefined` when the session is
1418
+ * running a bare `ChatDriver`.
1419
+ *
1420
+ * The five call sites that used to test the type themselves now read this
1421
+ * (§11.2 constraint 3 of `docs/code-health.md`): the agent pin, the flow-owner
1422
+ * lock, the wire-up in `wireDriver`, and the two `driverKind` diagnostics. The
1423
+ * type test itself lives in `asOrchestrator`, shared with the session
1424
+ * persister.
1425
+ */
1426
+ get orchestrator() {
1427
+ return asOrchestrator(this.driver);
1428
+ }
1429
+ /**
1430
+ * A get/set pair over one of this element's own properties, for a controller
1431
+ * that drives state it does not own. Written as a helper rather than two inline
1432
+ * closures per call: a pair of same-typed closures is easy to transpose by
1433
+ * accident, and a single object cannot be.
1434
+ */
1435
+ accessor(key) {
1436
+ return {
1437
+ get: () => this[key],
1438
+ set: (value) => {
1439
+ this[key] = value;
1440
+ },
1441
+ };
1442
+ }
1443
+ /** Explicit composer height once dragged; `null` keeps the default auto-grow. */
1444
+ get composerHeightPx() {
1445
+ return this.composerResize.heightPx;
1446
+ }
1447
+ set composerHeightPx(value) {
1448
+ this.composerResize.heightPx = value;
1449
+ }
2266
1450
  syncShowHalo() {
2267
1451
  if (!this.busy) {
2268
1452
  this.showHalo = 'no';
@@ -2410,7 +1594,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2410
1594
  this.driver.loadHistory([...history]);
2411
1595
  this.logMeta('driver.created', {
2412
1596
  reason: 'agents-changed',
2413
- driverKind: this.driver instanceof OrchestratingDriver ? 'orchestrating' : 'chat',
1597
+ driverKind: this.orchestrator ? 'orchestrating' : 'chat',
2414
1598
  });
2415
1599
  }
2416
1600
  /** Returns a stable fingerprint for an agents array based on agent names and tool handler keys. */
@@ -2515,6 +1699,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2515
1699
  const { driver } = this;
2516
1700
  if (!driver)
2517
1701
  return;
1702
+ // Captured, not re-read: the cleanup closures below must remove their listeners
1703
+ // from the object they were added to, even if `this.driver` has been reassigned
1704
+ // by the time `unwireDriver` runs. Same reason `driver` itself is destructured,
1705
+ // and reading both from `this` here means they cannot disagree.
1706
+ const orchestrator = this.orchestrator;
2518
1707
  // Idempotent — unwire previous listeners on this component before re-wiring.
2519
1708
  this.unwireDriver();
2520
1709
  const onHistoryUpdated = (e) => {
@@ -2635,13 +1824,13 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2635
1824
  () => driver.removeEventListener('interaction-stop', onInteractionStop),
2636
1825
  () => driver.removeEventListener('provider-changed', onProviderChanged),
2637
1826
  ];
2638
- if (driver instanceof OrchestratingDriver) {
1827
+ if (orchestrator) {
2639
1828
  // Restore the user pin and flow-owner lock from the session store onto
2640
1829
  // the freshly built driver, so pop-in/pop-out and agents-array
2641
1830
  // reassignment preserve both.
2642
- driver.setPinnedAgent(this.pinnedAgentName);
2643
- driver.setFlowOwner(this.flowOwnerAgentName);
2644
- driver.setContextGuard(this.contextGuardPolicy);
1831
+ orchestrator.setPinnedAgent(this.pinnedAgentName);
1832
+ orchestrator.setFlowOwner(this.flowOwnerAgentName);
1833
+ orchestrator.setContextGuard(this.contextGateCtrl.guardPolicy);
2645
1834
  const onOrchStart = () => {
2646
1835
  this.showHalo = 'orchestrating';
2647
1836
  };
@@ -2694,12 +1883,12 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2694
1883
  (_h = this._sessionRef) === null || _h === void 0 ? void 0 : _h.actions.aiAssistant.setFlowActivationPrompt(null);
2695
1884
  }
2696
1885
  };
2697
- driver.addEventListener('orchestrating-start', onOrchStart);
2698
- driver.addEventListener('orchestrating-stop', onOrchStop);
2699
- driver.addEventListener('agent-changed', onAgentChanged);
2700
- driver.addEventListener('pinned-changed', onPinnedChanged);
2701
- driver.addEventListener('flow-owner-changed', onFlowOwnerChanged);
2702
- cleanups.push(() => driver.removeEventListener('orchestrating-start', onOrchStart), () => driver.removeEventListener('orchestrating-stop', onOrchStop), () => driver.removeEventListener('agent-changed', onAgentChanged), () => driver.removeEventListener('pinned-changed', onPinnedChanged), () => driver.removeEventListener('flow-owner-changed', onFlowOwnerChanged));
1886
+ orchestrator.addEventListener('orchestrating-start', onOrchStart);
1887
+ orchestrator.addEventListener('orchestrating-stop', onOrchStop);
1888
+ orchestrator.addEventListener('agent-changed', onAgentChanged);
1889
+ orchestrator.addEventListener('pinned-changed', onPinnedChanged);
1890
+ orchestrator.addEventListener('flow-owner-changed', onFlowOwnerChanged);
1891
+ cleanups.push(() => orchestrator.removeEventListener('orchestrating-start', onOrchStart), () => orchestrator.removeEventListener('orchestrating-stop', onOrchStop), () => orchestrator.removeEventListener('agent-changed', onAgentChanged), () => orchestrator.removeEventListener('pinned-changed', onPinnedChanged), () => orchestrator.removeEventListener('flow-owner-changed', onFlowOwnerChanged));
2703
1892
  }
2704
1893
  this.driverCleanup = () => {
2705
1894
  for (const fn of cleanups)
@@ -2820,13 +2009,13 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2820
2009
  // where state is loading and the input may be hidden or disabled).
2821
2010
  if (!this.busy)
2822
2011
  this.maybeAutoFocusChatInput();
2823
- this.reloadCostSessionHistory();
2824
- this.syncActiveCostSessionTitle();
2012
+ this.costLedger.reload();
2013
+ this.costLedger.syncActiveTitle();
2825
2014
  this.logMeta('assistant.connected', {
2826
2015
  store: isNewStore ? 'created' : 'restored',
2827
2016
  restoredMessages: this.messages.length,
2828
2017
  driver: driverExisted ? 'reused' : 'created',
2829
- driverKind: this.driver instanceof OrchestratingDriver ? 'orchestrating' : 'chat',
2018
+ driverKind: this.orchestrator ? 'orchestrating' : 'chat',
2830
2019
  driverBusy: (_f = (_e = this.driver) === null || _e === void 0 ? void 0 : _e.isBusy()) !== null && _f !== void 0 ? _f : false,
2831
2020
  });
2832
2021
  }
@@ -2868,7 +2057,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2868
2057
  void this.loadProviderStatuses();
2869
2058
  });
2870
2059
  }
2871
- this.wireAppSettingsProvider();
2060
+ this.settings.wireAppSettingsProvider();
2872
2061
  // Latch the backend block off the activity bus (GENC-1464). This is the primary
2873
2062
  // seam: it fires for a turn this element did not start, for a sub-agent's
2874
2063
  // wall (the child inherits the parent's bus and publishes its own
@@ -2880,7 +2069,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2880
2069
  // Pass the figures through: this seam wins the latch for an in-loop wall (the
2881
2070
  // common case), so dropping them here would render the generic banner copy in
2882
2071
  // exactly the path `ChatDriverResult.budget` was widened to serve.
2883
- this.latchBlockedFrom(d === null || d === void 0 ? void 0 : d.failureReason, undefined, d === null || d === void 0 ? void 0 : d.budget, d === null || d === void 0 ? void 0 : d.vendor));
2072
+ this.budget.latchFrom(d === null || d === void 0 ? void 0 : d.failureReason, undefined, d === null || d === void 0 ? void 0 : d.budget, d === null || d === void 0 ? void 0 : d.vendor));
2884
2073
  if (this.messagesEl) {
2885
2074
  this._scrollListener = () => {
2886
2075
  this._userScrolledAway =
@@ -2976,13 +2165,14 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2976
2165
  disconnectedCallback() {
2977
2166
  var _a, _b, _c, _d, _e, _f, _g;
2978
2167
  window.removeEventListener('pagehide', this._handlePageHide);
2979
- // Both menus close below via the toggles' `finalize()`, but that writes to
2980
- // the store and never reaches the setters — drop the document listeners
2981
- // directly so they cannot outlive the element.
2982
- this.teardownComposerMenuDismissal();
2168
+ // Drops the menus' document listeners and finalises any in-flight close
2169
+ // animation. Ordering matters and is satisfied here: `finalize()` writes the
2170
+ // closed state through the store, so it has to run while `_sessionRef` is still
2171
+ // set (cleared at the end of this method) or the panel re-mounts open.
2172
+ this.composerMenus.dispose();
2983
2173
  // Write out anything the debounce is still holding — the timer would otherwise fire against
2984
2174
  // a torn-down element, or not at all.
2985
- this.flushCostHistory();
2175
+ this.costLedger.flush();
2986
2176
  super.disconnectedCallback();
2987
2177
  this._sessionActive = false;
2988
2178
  this.stopLoadingTimer();
@@ -3000,7 +2190,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3000
2190
  this._unsubBudgetLatch = undefined;
3001
2191
  (_c = this.unsubProviderRegistry) === null || _c === void 0 ? void 0 : _c.call(this);
3002
2192
  this.unsubProviderRegistry = undefined;
3003
- this.unwireAppSettingsProvider();
2193
+ this.settings.unwireAppSettingsProvider();
3004
2194
  (_d = this._executionCompletionUnsub) === null || _d === void 0 ? void 0 : _d.call(this);
3005
2195
  this._executionCompletionUnsub = undefined;
3006
2196
  // Note: the restore-core autosave AND diagnostics-append timers live on the
@@ -3014,15 +2204,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3014
2204
  this.removeEventListener('request-scroll-to-bottom', this._handleRequestScrollToBottom);
3015
2205
  document.removeEventListener('pointerdown', this._handleGlobalInteraction, true);
3016
2206
  document.removeEventListener('focusin', this._handleGlobalInteraction, true);
3017
- // Finalise any in-flight panel-close animations before clearing
3018
- // _sessionRef, otherwise the closed state never makes it to the store and
3019
- // the panel re-mounts open.
3020
- this._agentPickerToggle.finalize();
3021
2207
  if (this.settingsOpen) {
3022
2208
  (_e = this.settingsModal) === null || _e === void 0 ? void 0 : _e.close();
3023
2209
  this.settingsOpen = false;
3024
2210
  }
3025
- this._sessionMenuToggle.finalize();
3026
2211
  // Capture before clearing — `wasBusy` reads the driver, which is dropped below.
3027
2212
  this.logMeta('assistant.disconnected', { wasBusy: (_g = (_f = this.driver) === null || _f === void 0 ? void 0 : _f.isBusy()) !== null && _g !== void 0 ? _g : false });
3028
2213
  // Clear local references only — driver and store stay in their registries.
@@ -3066,27 +2251,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3066
2251
  */
3067
2252
  syncContextGuard() {
3068
2253
  var _a, _b;
3069
- (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.setContextGuard) === null || _b === void 0 ? void 0 : _b.call(_a, this.contextGuardPolicy);
3070
- }
3071
- /**
3072
- * Guard policy pushed to the driver, or `undefined` when the host has switched
3073
- * the gate off — so `enabled: false` really does mean no local blocking of any
3074
- * kind, UI or mid-loop.
3075
- *
3076
- * A MARGIN, not a threshold: the driver resolves the window of the provider
3077
- * each call actually goes to. The fallback window rides along because the
3078
- * driver cannot learn it any other way — without it, a host that knows its
3079
- * window while its provider does not report one had `fallbackContextLimit`
3080
- * protecting the composer and nothing else.
3081
- */
3082
- get contextGuardPolicy() {
3083
- var _a;
3084
- if (!this.contextBudget.enabled)
3085
- return undefined;
3086
- return {
3087
- marginTokens: CONTEXT_ABORT_MARGIN_TOKENS,
3088
- fallbackLimit: (_a = this.chatConfig.context) === null || _a === void 0 ? void 0 : _a.fallbackContextLimit,
3089
- };
2254
+ (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.setContextGuard) === null || _b === void 0 ? void 0 : _b.call(_a, this.contextGateCtrl.guardPolicy);
3090
2255
  }
3091
2256
  loadProviderStatuses() {
3092
2257
  return __awaiter(this, void 0, void 0, function* () {
@@ -3104,8 +2269,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3104
2269
  // the store is fresh and the user hasn't already toggled something.
3105
2270
  this.applyPreferenceBaseline();
3106
2271
  this.syncShowingSplash();
3107
- this.syncActiveCostSessionTitle();
3108
- this.reloadCostSessionHistory();
2272
+ this.costLedger.syncActiveTitle();
2273
+ this.costLedger.reload();
3109
2274
  // `chatConfig.context` decides whether the mid-loop guard runs at all, so a
3110
2275
  // late or changed binding has to reach the driver — otherwise switching the
3111
2276
  // gate off leaves the guard armed from whatever the config said at connect.
@@ -3380,130 +2545,19 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3380
2545
  : 'standalone';
3381
2546
  recordMetaEvent(key, type, Object.assign({ placement }, detail));
3382
2547
  }
3383
- closeOpenComposerMenus() {
3384
- if (this.agentPickerOpen)
3385
- this.toggleAgentPicker();
3386
- if (this.sessionMenuOpen)
3387
- this.toggleSessionMenu();
3388
- }
3389
2548
  /**
3390
- * Places an open composer menu directly above the control that opened it.
3391
- *
3392
- * CSS alone cannot: the menu is a child of `.composer` — which is what lets its
3393
- * width be capped against the composer rather than against a button — while its
3394
- * anchor is a button several levels down the bar, and no selector can read a
3395
- * sibling's offset. So the left edge is measured on open and written as a
3396
- * custom property.
2549
+ * Document-level dismissal for whichever composer menu is open. Bound only
2550
+ * while one IS open — a permanently-attached listener on a component that can
2551
+ * be mounted twice (bubble + popout panel) is a cost every click on the page
2552
+ * pays for a menu that is usually closed.
3397
2553
  *
3398
- * Clamped to the composer, so a menu opened from a control near the right edge
3399
- * of a narrow panel slides back inside instead of overhanging. The animation
3400
- * origin is written separately and keeps pointing at the button's centre even
3401
- * after such a shift — the menu still grows out of the thing that opened it,
3402
- * which is the whole point of anchoring it there.
2554
+ * Uses `composedPath()` rather than `event.target`: the click lands on a node
2555
+ * inside this element's shadow root, which retargets to the host, so a plain
2556
+ * `contains()` check cannot tell "clicked the menu" from "clicked the
2557
+ * transcript". The path crosses the boundary and carries both.
3403
2558
  */
3404
- positionComposerMenu(panelSelector, triggerSelector) {
3405
- const root = this.shadowRoot;
3406
- const composer = root === null || root === void 0 ? void 0 : root.querySelector('.composer');
3407
- const panel = root === null || root === void 0 ? void 0 : root.querySelector(panelSelector);
3408
- const trigger = root === null || root === void 0 ? void 0 : root.querySelector(triggerSelector);
3409
- if (!composer || !panel || !trigger)
3410
- return;
3411
- const box = composer.getBoundingClientRect();
3412
- const anchor = trigger.getBoundingClientRect();
3413
- // An absolutely-positioned child is placed against its containing block's
3414
- // PADDING box, while getBoundingClientRect reports the border box — so every
3415
- // offset below is taken from the padding edge. Skipping this is a 1px drift
3416
- // that reads as the menu being very slightly out of line with its button.
3417
- const style = getComputedStyle(composer);
3418
- const originLeft = box.left + (parseFloat(style.borderLeftWidth) || 0);
3419
- const originBottom = box.bottom - (parseFloat(style.borderBottomWidth) || 0);
3420
- // A menu wider than the composer clamps to 0 rather than a negative offset:
3421
- // Math.min picks the negative headroom, Math.max floors it at the left edge.
3422
- const left = Math.max(0, Math.min(anchor.left - originLeft, composer.clientWidth - panel.offsetWidth));
3423
- panel.style.setProperty('--composer-menu-left', `${left}px`);
3424
- panel.style.setProperty('--composer-menu-origin', `${anchor.left + anchor.width / 2 - originLeft - left}px`);
3425
- // Sit on the button rather than on top of the whole composer. The gap stays
3426
- // in CSS so it can be expressed in design units; this is the raw distance
3427
- // from the padding edge up to the button's top.
3428
- panel.style.setProperty('--composer-menu-bottom', `${originBottom - anchor.top}px`);
3429
- // Starting on the button rather than on the composer means less room above,
3430
- // and the assistant can be short enough that a full-height menu would be
3431
- // clipped by the wrapper's overflow. Cap it at what is actually available;
3432
- // the menu scrolls inside whatever it gets.
3433
- const wrapper = root === null || root === void 0 ? void 0 : root.querySelector('.chat-wrapper');
3434
- if (wrapper) {
3435
- const available = anchor.top -
3436
- wrapper.getBoundingClientRect().top -
3437
- FoundationAiAssistant_1.MENU_HEADROOM_RESERVE;
3438
- panel.style.setProperty('--composer-menu-max-height', `${Math.max(COMPOSER_MENU_MIN_HEIGHT, available)}px`);
3439
- }
3440
- }
3441
- /** Re-place whichever menu is open. Cheap enough to run from a resize. */
3442
- repositionOpenComposerMenu() {
3443
- if (this.agentPickerOpen) {
3444
- this.positionComposerMenu('.agent-picker-panel', '.agent-toggle-button');
3445
- }
3446
- if (this.sessionMenuOpen) {
3447
- this.positionComposerMenu('.session-menu-panel', '.session-menu-button');
3448
- }
3449
- }
3450
- syncComposerMenuDismissal() {
3451
- const shouldListen = this.agentPickerOpen || this.sessionMenuOpen;
3452
- if (shouldListen === !!this._dismissPointerHandler)
3453
- return;
3454
- if (!shouldListen) {
3455
- this.teardownComposerMenuDismissal();
3456
- return;
3457
- }
3458
- this._dismissPointerHandler = (e) => {
3459
- var _a;
3460
- // The composer owns both the menus and the buttons that open them, so a
3461
- // path that reaches it is an inside click — including the toggle itself,
3462
- // whose own handler closes the menu. Closing here too would immediately
3463
- // reopen-then-close and the menu would never appear.
3464
- const composer = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.composer');
3465
- if (composer && e.composedPath().includes(composer))
3466
- return;
3467
- this.closeOpenComposerMenus();
3468
- };
3469
- this._dismissKeyHandler = (e) => {
3470
- var _a;
3471
- if (e.key !== 'Escape')
3472
- return;
3473
- this.closeOpenComposerMenus();
3474
- // Return focus to the input rather than leaving it on a button that has
3475
- // just disappeared from under the user.
3476
- (_a = this.chatInputEl) === null || _a === void 0 ? void 0 : _a.focus();
3477
- };
3478
- this._dismissResizeHandler = () => this.repositionOpenComposerMenu();
3479
- document.addEventListener('pointerdown', this._dismissPointerHandler, true);
3480
- document.addEventListener('keydown', this._dismissKeyHandler, true);
3481
- window.addEventListener('resize', this._dismissResizeHandler);
3482
- }
3483
- teardownComposerMenuDismissal() {
3484
- if (this._dismissPointerHandler) {
3485
- document.removeEventListener('pointerdown', this._dismissPointerHandler, true);
3486
- this._dismissPointerHandler = undefined;
3487
- }
3488
- if (this._dismissKeyHandler) {
3489
- document.removeEventListener('keydown', this._dismissKeyHandler, true);
3490
- this._dismissKeyHandler = undefined;
3491
- }
3492
- if (this._dismissResizeHandler) {
3493
- window.removeEventListener('resize', this._dismissResizeHandler);
3494
- this._dismissResizeHandler = undefined;
3495
- }
3496
- }
3497
2559
  toggleAgentPicker() {
3498
- // Mutually exclusive with the session menu — both anchor to the composer bar.
3499
- if (!this.agentPickerOpen && this.sessionMenuOpen)
3500
- this.toggleSessionMenu();
3501
- this.logMeta('panel.toggled', { panel: 'agent-picker', open: !this.agentPickerOpen });
3502
- this._agentPickerToggle.toggle();
3503
- // After the render that mounts the panel — it has no box to measure before that.
3504
- if (this.agentPickerOpen) {
3505
- void DOM.nextUpdate().then(() => this.positionComposerMenu('.agent-picker-panel', '.agent-toggle-button'));
3506
- }
2560
+ this.composerMenus.toggleAgentPicker();
3507
2561
  }
3508
2562
  /**
3509
2563
  * Whether the lifecycle-menu button shows. It occupies the stop-button slot,
@@ -3571,35 +2625,21 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3571
2625
  return !!this.persistence.enabled && !!this.persistence.provider;
3572
2626
  }
3573
2627
  /**
3574
- * Context-headroom thresholds for the active model (GENC-1567) — the warning
3575
- * and block lines, plus the tail budget compaction aims at.
2628
+ * The context gate's derived surface (GENC-1567). Every member below delegates to
2629
+ * `ContextGateController` — the names are this element's public API and the
2630
+ * template's bindings, so they stay put while the derivation moves.
3576
2631
  *
3577
2632
  * The per-agent `contextReserve` IS folded in, via the driver (GENC-1582). It
3578
2633
  * used to be described here as a separate, later check made when an agent took
3579
2634
  * ownership of a flow — but there is no such check, and by flow entry the send
3580
- * has already been allowed. Resolving it here, before the send, is what makes it
3581
- * a gate rather than a label.
2635
+ * has already been allowed. Resolving it before the send is what makes it a gate
2636
+ * rather than a label.
3582
2637
  *
3583
- * Overhead is read off `messages` (the Redux mirror) rather than the driver's
3584
- * own history, which the compaction plan uses. That is safe rather than sloppy:
3585
- * `estimateSystemOverhead` only looks at messages BEFORE the first usage-bearing
3586
- * one, and the two lists agree completely that early — the drift between mirror
3587
- * and driver history is only ever at the tail.
2638
+ * None carries `@volatile`: the decorators live on the controller's getters, and
2639
+ * they reach the binding observer from there — see `docs/code-health.md` §11.4.
3588
2640
  */
3589
2641
  get contextBudget() {
3590
- // Resolved ONCE. `contextReserve` re-reads live routing state on every access,
3591
- // so two reads can land on different winners and pair one agent's
3592
- // `warnAheadTokens` with another's `tokens` — the exact pairing
3593
- // `ContextReserveResolution` warns against, and the reason this is a local
3594
- // rather than two property reads (reviewer catch on #2504). It also stops the
3595
- // orchestrator walking every specialist plus the fallback twice per evaluation.
3596
- const reserve = this.contextReserve;
3597
- return resolveContextBudget({
3598
- chatConfig: this.chatConfig,
3599
- contextLimit: this.contextLimit,
3600
- agentReserveTokens: reserve === null || reserve === void 0 ? void 0 : reserve.tokens,
3601
- agentWarnAheadTokens: reserve === null || reserve === void 0 ? void 0 : reserve.warnAheadTokens,
3602
- });
2642
+ return this.contextGateCtrl.budget;
3603
2643
  }
3604
2644
  /**
3605
2645
  * Headroom the next turn needs, resolved against how routing is currently
@@ -3619,6 +2659,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3619
2659
  * `contextReserve` is a plain number by design — see `ContextReserveInput` for
3620
2660
  * why a resolver form was dropped rather than accepted and ignored.
3621
2661
  *
2662
+ * Stays on the element rather than moving into `ContextGateController` because it
2663
+ * reaches the driver, which no extracted controller may do (`docs/code-health.md`
2664
+ * §11.2 constraint 2). The controller receives the resolved value as a callback.
2665
+ *
3622
2666
  * @internal
3623
2667
  */
3624
2668
  get contextReserve() {
@@ -3645,250 +2689,46 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3645
2689
  basis: 'locked',
3646
2690
  };
3647
2691
  }
3648
- /**
3649
- * What compacting right now would reclaim, or `null` when it would not be
3650
- * worth it (GENC-1567). Sized against {@link FoundationAiAssistant.contextBudget},
3651
- * so the plan aims at this model's window rather than a fixed default.
3652
- *
3653
- * Reads `messages` to stay reactive as the conversation grows: the driver's
3654
- * history is not a tracked observable, so every consumer of this keys its
3655
- * re-evaluation off the Redux mirror.
3656
- */
3657
2692
  get compactionPlan() {
3658
- var _a, _b, _c, _d, _e;
3659
- if (this.messages.length === 0)
3660
- return null;
3661
- const budget = this.contextBudget;
3662
- return ((_e = (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.getCompactionPlan) === null || _b === void 0 ? void 0 : _b.call(_a, {
3663
- preambleTokens: (_d = (_c = this.driver) === null || _c === void 0 ? void 0 : _c.getPreambleTokens) === null || _d === void 0 ? void 0 : _d.call(_c),
3664
- tailTokenBudget: budget.tailTokenBudget,
3665
- minReclaimTokens: budget.minReclaimTokens,
3666
- })) !== null && _e !== void 0 ? _e : null);
2693
+ return this.contextGateCtrl.plan;
3667
2694
  }
3668
- /**
3669
- * Tooltip and hint for the Compact row — why it is or is not available.
3670
- *
3671
- * Replaces a fixed "Not enough conversation yet to be worth compacting", which
3672
- * since GENC-1567 could be flatly wrong: a long conversation whose tail is one
3673
- * indivisible tool result has plenty of conversation and still cannot be
3674
- * compacted. Reading the plan means the hint states the actual reason.
3675
- *
3676
- * @internal
3677
- */
3678
- get compactHint() {
3679
- const plan = this.compactionPlan;
3680
- if (plan)
3681
- return `Summarize older messages to free up context`;
3682
- return this.messages.length === 0
3683
- ? 'Nothing to compact yet'
3684
- : 'There is nothing left to summarize here — the recent messages are too large to shrink further';
3685
- }
3686
- /**
3687
- * How close this conversation is to the model's context window (GENC-1567).
3688
- *
3689
- * **Derived, never latched** — the opposite of {@link FoundationAiAssistant.blocked}.
3690
- * A budget wall stays set because nothing the user does fixes it; this clears
3691
- * itself the moment the conversation shrinks, which is precisely what the user
3692
- * is being asked to do. The two must not be conflated: `blocked` is also
3693
- * deliberately preserved across `resetSession`, and a context gate obviously
3694
- * must not survive starting a new chat.
3695
- *
3696
- * **A flow in progress is never blocked, only warned.** Machine state lives
3697
- * outside the transcript, so a stateful flow is not desynced by compaction —
3698
- * but the summary can still drop detail the flow's own prompt leans on, and
3699
- * blocking mid-journey would strand the user between a flow they cannot finish
3700
- * and a compaction that is a poor idea right there. Letting the flow complete
3701
- * and gating on the way out is the lesser harm; the headroom reserve exists so
3702
- * this is rare rather than routine.
3703
- */
3704
2695
  /**
3705
2696
  * WHY the composer is blocked, when it is (GENC-1582). `undefined` unless
3706
- * {@link FoundationAiAssistant.contextGate} is `'blocked'`.
3707
- *
3708
- * A cause rather than a fourth gate level: the severity is identical — sends are
3709
- * refused either way — and every consumer switches on the three levels. What
3710
- * differs is what the user can do about it, which is worth splitting because the
3711
- * two answers have no remedy in common.
3712
- *
3713
- * - `'transcript'` — the conversation has grown into the reserve. Compacting or
3714
- * clearing helps, and the copy says so.
3715
- * - `'model'` — the active agent's reserve meets or exceeds the whole window, so
3716
- * `blockAt` is 0 and EVERY message is refused, including the first of an empty
3717
- * chat. Neither compacting nor clearing can help: the constraint is the model,
3718
- * not the transcript. Before this existed the user was told to clear the chat,
3719
- * did, and hit the identical block with no way to understand why.
3720
- *
3721
- * Keyed off `blockAt === 0`, deliberately NOT off whether a compaction plan
3722
- * exists. A null plan has two unrelated causes — nothing left worth summarizing,
3723
- * versus a window that cannot host the agent at all — and conflating them is what
3724
- * produced the misleading copy.
3725
- *
3726
- * Note the block itself is correct in both cases and must stay: an agent that
3727
- * measurably cannot complete a turn in this window would otherwise start one and
3728
- * die mid-loop on a provider context error, with partial writes and no clean
3729
- * exit. Refusing up front is the better failure.
2697
+ * {@link FoundationAiAssistant.contextGate} is `'blocked'`. See
2698
+ * `ContextGateController.blockCause` for the `'transcript'` / `'model'` split
2699
+ * and why it is a cause rather than a fourth gate level.
3730
2700
  *
3731
2701
  * @internal
3732
2702
  */
3733
2703
  get contextBlockCause() {
3734
- if (this.contextGate !== 'blocked')
3735
- return undefined;
3736
- return this.contextBudget.blockAt === 0 ? 'model' : 'transcript';
2704
+ return this.contextGateCtrl.blockCause;
3737
2705
  }
3738
2706
  get contextGate() {
3739
- var _a;
3740
- const budget = this.contextBudget;
3741
- if (!budget.enabled)
3742
- return 'ok';
3743
- // The figure from the last moment nothing was running, not the live one — see
3744
- // `_gateContextTokens` for why the gauge and the gate differ here.
3745
- const tokens = (_a = this._gateContextTokens) !== null && _a !== void 0 ? _a : this.contextTokens;
3746
- if (tokens == null || budget.blockAt == null || budget.warnAt == null)
3747
- return 'ok';
3748
- if (tokens >= budget.blockAt)
3749
- return this.flowOwnerAgentName !== null ? 'warn' : 'blocked';
3750
- return tokens >= budget.warnAt ? 'warn' : 'ok';
2707
+ return this.contextGateCtrl.gate;
3751
2708
  }
3752
- /**
3753
- * What the composer notice says about the context gate.
3754
- *
3755
- * The copy is a function of whether compaction can actually clear the gate,
3756
- * which is why it reads {@link FoundationAiAssistant.compactionPlan} rather
3757
- * than assuming. Telling someone to compact when the projection says it would
3758
- * not free enough — a tail that is one indivisible tool result, say — sends
3759
- * them into a summarizer call that leaves them exactly where they were.
3760
- *
3761
- * @internal
3762
- */
3763
2709
  get contextGateReason() {
3764
- var _a, _b;
3765
- const level = this.contextGate;
3766
- const canHelp = this.compactionPlan != null;
3767
- if (level === 'blocked' && this.contextBlockCause === 'model') {
3768
- // The window cannot host this agent, so neither remedy the other branches
3769
- // offer can help. Name the agent and both figures instead: the numbers are
3770
- // what make "switch model" an instruction rather than a shrug, and they are
3771
- // already to hand.
3772
- const reserve = this.contextReserve;
3773
- const need = reserve ? `${reserve.tokens.toLocaleString()} tokens` : 'more headroom';
3774
- const have = (_a = this.contextLimit) === null || _a === void 0 ? void 0 : _a.toLocaleString();
3775
- const who = (_b = reserve === null || reserve === void 0 ? void 0 : reserve.agentName) !== null && _b !== void 0 ? _b : 'This task';
3776
- return (`${who} needs about ${need} of context headroom to complete a turn, and this model has ` +
3777
- `${have !== null && have !== void 0 ? have : 'less than that'}. Switch to a model with a larger context window, or pick a ` +
3778
- `different agent — compacting or clearing this conversation will not help.`);
3779
- }
3780
- if (level === 'blocked') {
3781
- // Name the agent whose headroom is binding when routing is automatic
3782
- // (GENC-1582). The reserve is then the hungriest agent the turn could land
3783
- // on, so the composer can close while there is plenty of room for the short
3784
- // question the user was about to type — and a message that only said "not
3785
- // enough room for more work" would read as a bug rather than a reason.
3786
- const reserve = this.contextReserve;
3787
- const cause = (reserve === null || reserve === void 0 ? void 0 : reserve.basis) === 'reachable-max'
3788
- ? `There is not enough room in the model context to start another task — ${reserve.agentName} needs the most of any I could pick up next.`
3789
- : 'There is not enough room in the model context for more work.';
3790
- const remedy = canHelp
3791
- ? 'Click the error pill and compact the context to continue working.'
3792
- : 'Compacting the context will not free enough room, click the error pill to clear and start a new conversation.';
3793
- return `${cause} ${remedy}`;
3794
- }
3795
- if (this.flowOwnerAgentName !== null) {
3796
- return 'This conversation is nearly full. The task in progress will finish, but compact before starting another.';
3797
- }
3798
- return canHelp
3799
- ? 'This conversation is getting long. Compact it to free up room.'
3800
- : 'This conversation is getting long and is close to the limit.';
2710
+ return this.contextGateCtrl.gateReason;
3801
2711
  }
3802
- /**
3803
- * Short label the session-menu pill wears while the gate is active — the
3804
- * primary visible signal, in place of a banner.
3805
- *
3806
- * Borrowed from the agent pin, which is the established way this composer says
3807
- * "a mode is in force": a pill that grows a label rather than a strip of prose
3808
- * above the input. It states the CONDITION only; the remedy lives in the
3809
- * placeholder once the composer is actually blocked, which is the moment the
3810
- * user needs to be told what to do rather than merely warned.
3811
- *
3812
- * @internal
3813
- */
3814
2712
  get contextGateLabel() {
3815
- switch (this.contextGate) {
3816
- case 'blocked':
3817
- return 'At context limit';
3818
- case 'warn':
3819
- return 'Approaching context limit';
3820
- default:
3821
- return '';
3822
- }
2713
+ return this.contextGateCtrl.gateLabel;
2714
+ }
2715
+ get compactHint() {
2716
+ return this.contextGateCtrl.compactHint;
2717
+ }
2718
+ get compactable() {
2719
+ return this.contextGateCtrl.compactable;
3823
2720
  }
3824
- /**
3825
- * Whether the composer notice has anything to show — a budget wall or the
3826
- * context gate. One region for both, so the live region stays single and
3827
- * registered (see the banner template's accessibility note).
3828
- *
3829
- * @internal
3830
- */
3831
2721
  get composerNoticeVisible() {
3832
- return this.bannerVisible || this.contextGate !== 'ok';
2722
+ return this.contextGateCtrl.noticeVisible;
3833
2723
  }
3834
- /**
3835
- * Text for that region. A budget wall outranks the context gate: it is the
3836
- * condition the user cannot clear, so it must not be hidden behind advice
3837
- * about compacting that would not restore sending anyway.
3838
- *
3839
- * For the context gate the region is present and announced but not drawn — see
3840
- * {@link FoundationAiAssistant.composerNoticeSrOnly}.
3841
- *
3842
- * @internal
3843
- */
3844
2724
  get composerNoticeText() {
3845
- if (this.bannerVisible)
3846
- return this.effectiveBlockedReason;
3847
- return this.contextGate === 'ok' ? '' : this.contextGateReason;
2725
+ return this.contextGateCtrl.noticeText;
3848
2726
  }
3849
- /**
3850
- * Whether the notice region should be announced but not drawn.
3851
- *
3852
- * The context gate shows itself visually through the pill's label, the
3853
- * composer outline and the placeholder — a strip of prose on top of those
3854
- * would be a fourth statement of the same thing. But dropping the region
3855
- * outright would be an accessibility regression, and a documented one: a
3856
- * `role="status"` region announces MUTATIONS to a region already being
3857
- * observed, and it is the only announcement available here, because the
3858
- * composer flips to `disabled` at the same moment and a disabled control is out
3859
- * of the tab order — so neither the placeholder nor an `aria-label` on it is
3860
- * ever voiced.
3861
- *
3862
- * So the region stays mounted, keeps its text and keeps announcing; only the
3863
- * visual treatment is withheld. A budget wall still draws its banner, because
3864
- * that condition has no other visible expression.
3865
- *
3866
- * @internal
3867
- */
3868
2727
  get composerNoticeSrOnly() {
3869
- return !this.bannerVisible && this.contextGate !== 'ok';
3870
- }
3871
- /**
3872
- * Whether the menu's "Compact" action would do anything (GENC-1351 §5.7,
3873
- * GENC-1567). Delegates to the driver's plan so the gate uses the exact same
3874
- * history `compact()` acts on, judged against the same budget — one source of
3875
- * truth for both whether a cut is legal and whether it reclaims enough.
3876
- */
3877
- get compactable() {
3878
- return this.compactionPlan != null;
2728
+ return this.contextGateCtrl.noticeSrOnly;
3879
2729
  }
3880
2730
  toggleSessionMenu() {
3881
- // Mutually exclusive with the agent picker; always reopen showing the menu
3882
- // (not a leftover Clear-confirm prompt).
3883
- if (!this.sessionMenuOpen && this.agentPickerOpen)
3884
- this.toggleAgentPicker();
3885
- this.confirmingClear = false;
3886
- this.confirmingCompact = false;
3887
- this.logMeta('panel.toggled', { panel: 'session-menu', open: !this.sessionMenuOpen });
3888
- this._sessionMenuToggle.toggle();
3889
- if (this.sessionMenuOpen) {
3890
- void DOM.nextUpdate().then(() => this.positionComposerMenu('.session-menu-panel', '.session-menu-button'));
3891
- }
2731
+ this.composerMenus.toggleSessionMenu();
3892
2732
  }
3893
2733
  /**
3894
2734
  * Set persistence on/off for this session (the menu switch). Reassigns the
@@ -4009,7 +2849,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
4009
2849
  if (!(driver === null || driver === void 0 ? void 0 : driver.compact) || driver.isBusy() || this.compacting)
4010
2850
  return;
4011
2851
  this.confirmingCompact = false;
4012
- this.sessionMenuOpen = false;
2852
+ this.composerMenus.closeImmediate();
4013
2853
  // Swap the transcript for a "Compacting…" indicator while the async summary
4014
2854
  // runs. Purely visual: the driver only replaces history on success, so a
4015
2855
  // failure just restores the untouched messages when the flag clears — and
@@ -4108,7 +2948,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
4108
2948
  // append-only and handled separately below.
4109
2949
  yield ((_h = this.persister()) === null || _h === void 0 ? void 0 : _h.clearRestoreCore());
4110
2950
  this.confirmingClear = false;
4111
- this.sessionMenuOpen = false;
2951
+ this.composerMenus.closeImmediate();
4112
2952
  this.logMeta('session.cleared', { agentName });
4113
2953
  // Diagnostics is append-only, so Clear never wipes the prior log — the
4114
2954
  // provider.clear above only drops the restore-core snapshot. Now restart the
@@ -4158,81 +2998,61 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
4158
2998
  return false;
4159
2999
  return ((_c = this.agents) !== null && _c !== void 0 ? _c : []).some((a) => { var _a; return (_a = a.manualSelection) === null || _a === void 0 ? void 0 : _a.enabled; });
4160
3000
  }
4161
- /** Whether the settings modal AI Model section has slotted app content, context usage or budget figures. */
3001
+ /**
3002
+ * The settings modal's derived surface. Every member below delegates to
3003
+ * `SettingsModalController` — the names are this element's public API and the
3004
+ * 943-line template's bindings, so they stay put while the logic moves into the
3005
+ * `settings-modal.ts` that folder never had (`docs/code-health.md` §3).
3006
+ *
3007
+ * None carries `@volatile`: the decorators live on the controller's getters, and
3008
+ * they reach the binding observer from there — see `docs/code-health.md` §11.4.
3009
+ */
4162
3010
  get settingsModelSectionVisible() {
4163
- return (this.settingsModelSlotted.length > 0 ||
4164
- this.settingsContextUsageVisible ||
4165
- this.settingsBudgetUsageVisible);
3011
+ return this.settings.modelSectionVisible;
4166
3012
  }
4167
3013
  /** Built-in context window indicator (generic platform concern). */
4168
3014
  get settingsContextUsageVisible() {
4169
- const ui = this.chatConfig.ui;
4170
- return ((ui === null || ui === void 0 ? void 0 : ui.showContextUsage) !== false && this.contextTokens != null && this.contextLimit != null);
3015
+ return this.settings.contextUsageVisible;
3016
+ }
3017
+ /**
3018
+ * The context indicator's figures, pre-formatted, or `undefined` when there is
3019
+ * nothing measurable to show — see {@link SettingsContextUsage}.
3020
+ *
3021
+ * @internal
3022
+ */
3023
+ get settingsContextUsage() {
3024
+ return this.settings.contextUsage;
4171
3025
  }
4172
3026
  /**
4173
3027
  * Built-in per-vendor budget meter (GENC-1464) — the money sibling of
4174
3028
  * {@link FoundationAiAssistant.settingsContextUsageVisible}, with the same
4175
- * shape of gate: not configured off, and there is data to show. "Data" here
4176
- * is "at least one vendor has figures", so a host that feeds nothing (or only
4177
- * unlimited vendors) gets no meter and no empty container — exactly the
4178
- * context indicator's absent case.
4179
- *
4180
- * Answered from the RAW slice state rather than from
4181
- * {@link FoundationAiAssistant.settingsBudgetRows}, whose `.length` would
4182
- * format every row (a `formatUsd` pair and a `budgetPercentOf` each) only to
4183
- * ask whether any exist. This is read several times per render pass — the
4184
- * getter, the section gate, the template's `when` — so it stays as cheap as
4185
- * the context sibling's `!= null`.
3029
+ * shape of gate: not configured off, and there is data to show. See
3030
+ * `BudgetController.settingsUsageVisible` for why it reads the raw slice
3031
+ * rather than counting the rows.
4186
3032
  *
4187
3033
  * @internal
4188
3034
  */
4189
3035
  get settingsBudgetUsageVisible() {
4190
- var _a, _b;
4191
- const budgets = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.vendorBudgets;
4192
- return (((_b = this.chatConfig.ui) === null || _b === void 0 ? void 0 : _b.showBudgetUsage) !== false &&
4193
- budgets != null &&
4194
- // Keyed off BUDGETED_VENDORS, exactly as the rows are, so a key outside
4195
- // that list could never surface a meter with no row to show.
4196
- BUDGETED_VENDORS.some((vendor) => budgets[vendor] != null));
3036
+ return this.budget.settingsUsageVisible;
4197
3037
  }
4198
3038
  /**
4199
3039
  * The meter's rows — one per vendor that currently has figures, in
4200
- * `BUDGETED_VENDORS` order (the proxy's own metered list) so the rendering is
4201
- * stable however the host ordered its feed calls. Pre-formatted; see
4202
- * {@link SettingsBudgetRow} for why the text is unclamped while the bar is.
3040
+ * `BUDGETED_VENDORS` order so the rendering is stable however the host ordered
3041
+ * its feed calls. Pre-formatted; see {@link SettingsBudgetRow} for why the text
3042
+ * is unclamped while the bar is.
4203
3043
  *
4204
3044
  * @internal
4205
3045
  */
4206
3046
  get settingsBudgetRows() {
4207
- var _a;
4208
- const budgets = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.vendorBudgets;
4209
- if (!budgets)
4210
- return [];
4211
- const rows = [];
4212
- for (const vendor of BUDGETED_VENDORS) {
4213
- const figures = budgets[vendor];
4214
- if (!figures)
4215
- continue;
4216
- const percent = budgetPercentOf(figures);
4217
- rows.push({
4218
- vendor,
4219
- label: vendorDisplayName(vendor),
4220
- figures: `${formatUsd(figures.spentUsd)} / ${formatUsd(figures.budgetUsd)} (${percent}%)`,
4221
- // The same clamp the context indicator applies to its bar. No lower
4222
- // clamp needed: the boundary refuses negative figures.
4223
- barValue: Math.min(100, percent),
4224
- });
4225
- }
4226
- return rows;
3047
+ return this.budget.settingsRows;
4227
3048
  }
4228
3049
  /** Whether the settings modal UI Builder section should render. */
4229
3050
  get settingsAppSectionVisible() {
4230
- return this.settingsAppSlotted.length > 0 || this.visibleAppSettingsToggles.length > 0;
3051
+ return this.settings.appSectionVisible;
4231
3052
  }
4232
3053
  /** App toggles currently visible per each entry's `visible()` predicate. */
4233
3054
  get visibleAppSettingsToggles() {
4234
- void this.appSettingsRevision;
4235
- return this.appSettingsProvider.toggles.filter((toggle) => { var _a; return ((_a = toggle.visible) === null || _a === void 0 ? void 0 : _a.call(toggle)) !== false; });
3055
+ return this.settings.visibleAppSettingsToggles;
4236
3056
  }
4237
3057
  /**
4238
3058
  * Host-supplied heading for the toggle group, or `undefined` for none — the assistant has no
@@ -4240,237 +3060,72 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
4240
3060
  * the last one takes its heading with it rather than leaving a label over nothing.
4241
3061
  */
4242
3062
  get appSettingsTogglesHeading() {
4243
- void this.appSettingsRevision;
4244
- if (this.visibleAppSettingsToggles.length === 0)
4245
- return undefined;
4246
- return this.appSettingsProvider.togglesHeading;
3063
+ return this.settings.appSettingsTogglesHeading;
4247
3064
  }
4248
3065
  getAppSettingValue(id) {
4249
- void this.appSettingsRevision;
4250
- return this.appSettingsProvider.getValue(id);
3066
+ return this.settings.getAppSettingValue(id);
4251
3067
  }
4252
3068
  setAppSettingValue(id, value) {
4253
- this.appSettingsProvider.setValue(id, value);
4254
- }
4255
- wireAppSettingsProvider() {
4256
- this.unwireAppSettingsProvider();
4257
- if (!this.appSettingsProvider.subscribe)
4258
- return;
4259
- this.unsubAppSettingsProvider = this.appSettingsProvider.subscribe(() => {
4260
- this.appSettingsRevision += 1;
4261
- });
4262
- }
4263
- unwireAppSettingsProvider() {
4264
- var _a;
4265
- (_a = this.unsubAppSettingsProvider) === null || _a === void 0 ? void 0 : _a.call(this);
4266
- this.unsubAppSettingsProvider = undefined;
3069
+ this.settings.setAppSettingValue(id, value);
4267
3070
  }
4268
3071
  get settingsCostCurrentSectionVisible() {
4269
- return this.settingsCostCurrentSlotted.length > 0;
3072
+ return this.settings.costCurrentSectionVisible;
4270
3073
  }
4271
3074
  /**
4272
3075
  * Whether the build-history section has anything in it — the built-in row list, or a host's own
4273
- * markup in the `settings-cost-history` slot.
4274
- *
4275
- * Slotted content is checked FIRST, ahead of `enabled`. That flag governs the *built-in* ledger:
4276
- * it gates the row writes, the reload, and the lifetime sum — and it defaults to `false`
4277
- * (`cost?.enabled === true`), while the Usage tab itself shows for `showCost`/`showTokens`, which
4278
- * default true. So a host can legitimately render its own build history into the slot and never
4279
- * opt into our `localStorage` ledger. Short-circuiting on `enabled` above the slot check hid that
4280
- * host's markup outright, since the slot is the only thing in the container.
3076
+ * markup in the `settings-cost-history` slot. See
3077
+ * `SettingsModalController.costHistorySectionVisible` for why slotted content is
3078
+ * checked ahead of `enabled`.
4281
3079
  */
4282
3080
  get settingsCostHistorySectionVisible() {
4283
- if (this.settingsCostHistorySlotted.length > 0)
4284
- return true;
4285
- if (!this.resolvedCostHistory.enabled)
4286
- return false;
4287
- return this.costSessionHistory.length > 0;
3081
+ return this.settings.costHistorySectionVisible;
4288
3082
  }
4289
3083
  /**
4290
- * Whether the "Build history" heading has anything under it.
4291
- *
4292
- * Broader than {@link FoundationAiAssistant.settingsCostHistorySectionVisible} because the
4293
- * heading also covers the lifetime total, which renders before the first row exists (rows are
4294
- * written as spend accrues, the lifetime section only needs history switched on). Gating the
4295
- * heading on the rows alone left that total sitting under nothing.
3084
+ * Whether the "Build history" heading has anything under it — broader than
3085
+ * {@link FoundationAiAssistant.settingsCostHistorySectionVisible}, because the
3086
+ * heading also covers the lifetime total, which renders before the first row exists.
4296
3087
  */
4297
3088
  get settingsCostHistoryHeadingVisible() {
4298
- return this.settingsCostHistorySectionVisible || this.settingsCostLifetimeVisible;
3089
+ return this.settings.costHistoryHeadingVisible;
4299
3090
  }
4300
3091
  /** Whether the built-in Usage tab summary block has anything to show. */
4301
3092
  get settingsCostSummaryVisible() {
4302
- var _a;
4303
- void this.chatConfig;
4304
- const cost = this.resolvedCostHistory;
4305
- if (cost.showCost)
4306
- return true;
4307
- if (cost.showTokens)
4308
- return true;
4309
- if (((_a = this.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showActiveModel) !== false && this.providerStatuses.length > 0) {
4310
- return true;
4311
- }
4312
- return !!this.liveCostSessionTitle;
3093
+ return this.settings.costSummaryVisible;
4313
3094
  }
4314
3095
  /**
4315
- * Whether the lifetime-usage section has anything to say.
4316
- *
4317
- * Requires build history: with it off there are no rows, so `cumulativeUsage` falls back to
4318
- * the live session and the section would restate the block directly below it under a
4319
- * "lifetime" heading it hasn't earned.
3096
+ * Whether the lifetime-usage section has anything to say. Requires build history
3097
+ * AND a resolvable state key — see `SettingsModalController.costLifetimeVisible`.
4320
3098
  */
4321
3099
  get settingsCostLifetimeVisible() {
4322
- void this.chatConfig;
4323
- void this._sessionRef;
4324
- const cost = this.resolvedCostHistory;
4325
- if (!cost.enabled || !(cost.showCost || cost.showTokens))
4326
- return false;
4327
- // Also requires a state key, because `finalizeCostSession` skips the write without one and the
4328
- // lifetime figures sum rows only — so the section would show a permanent $0.00 directly above
4329
- // a live block reporting real spend.
4330
- //
4331
- // Narrow in practice: `getStateKey` falls back to `id::headerTitle`, and `headerTitle` has a
4332
- // non-empty default, so a key is almost always derivable even with no `session-key`. This
4333
- // catches the remaining case — a host that supplies no session key, no element id, AND blanks
4334
- // the header title. Cheap, and the alternative is a figure that is simply wrong.
4335
- return !!this.getStateKey();
3100
+ return this.settings.costLifetimeVisible;
4336
3101
  }
4337
3102
  /** Whether the Usage tab should appear in the settings modal. */
4338
3103
  get settingsCostTabVisible() {
4339
- void this.chatConfig;
4340
- const cost = this.resolvedCostHistory;
4341
- return (cost.showCost ||
4342
- cost.showTokens ||
4343
- this.settingsCostCurrentSectionVisible ||
4344
- this.settingsCostHistorySectionVisible);
3104
+ return this.settings.costTabVisible;
4345
3105
  }
4346
3106
  /** Whether session token usage should render on the Usage tab. */
4347
3107
  get settingsSessionTokensVisible() {
4348
- void this.chatConfig;
4349
- return this.resolvedCostHistory.showTokens;
3108
+ return this.settings.sessionTokensVisible;
4350
3109
  }
4351
3110
  /** Whether the chat header (and settings cog) should render. */
4352
3111
  get settingsCogVisible() {
4353
- void this.chatConfig;
4354
- const ui = this.chatConfig.ui;
4355
- const cost = this.resolvedCostHistory;
4356
- return (!!this.popoutMode ||
4357
- !!this.headerTitle ||
4358
- (ui === null || ui === void 0 ? void 0 : ui.showToolCalls) != null ||
4359
- (ui === null || ui === void 0 ? void 0 : ui.showThinkingSteps) != null ||
4360
- (ui === null || ui === void 0 ? void 0 : ui.showNarration) != null ||
4361
- (ui === null || ui === void 0 ? void 0 : ui.showAgentSwitchIndicator) != null ||
4362
- (ui === null || ui === void 0 ? void 0 : ui.allowDebugDownload) === true ||
4363
- (ui === null || ui === void 0 ? void 0 : ui.animations) != null ||
4364
- cost.showCost ||
4365
- cost.showTokens ||
4366
- (ui === null || ui === void 0 ? void 0 : ui.showContextUsage) !== false ||
4367
- (ui === null || ui === void 0 ? void 0 : ui.showActiveModel) !== false ||
4368
- this.visibleAppSettingsToggles.length > 0);
3112
+ return this.settings.cogVisible;
4369
3113
  }
4370
3114
  openSettingsModal() {
4371
- this.logMeta('panel.toggled', { panel: 'settings', open: true });
4372
- // The meter refreshes per turn, which a user staring at the modal cannot
4373
- // see — ask the host for fresh figures at the moment they start looking.
4374
- this.requestBudgetRefresh();
4375
- this.settingsModalTab = 'settings';
4376
- this.settingsOpen = true;
4377
- const show = () => {
4378
- if (!this.settingsOpen)
4379
- return;
4380
- if (this.settingsModal) {
4381
- if (this.settingsModal.open)
4382
- return;
4383
- this.settingsModal.show();
4384
- requestAnimationFrame(() => this.resetSettingsModalTabsOnOpen());
4385
- }
4386
- else {
4387
- DOM.queueUpdate(show);
4388
- }
4389
- };
4390
- DOM.queueUpdate(show);
3115
+ this.settings.openModal();
4391
3116
  }
4392
3117
  closeSettingsModal() {
4393
- var _a;
4394
- this.logMeta('panel.toggled', { panel: 'settings', open: false });
4395
- if ((_a = this.settingsModal) === null || _a === void 0 ? void 0 : _a.open) {
4396
- this.settingsModal.close();
4397
- return;
4398
- }
4399
- this.settingsModalTab = 'settings';
4400
- this.settingsOpen = false;
3118
+ this.settings.closeModal();
4401
3119
  }
4402
3120
  onSettingsModalClosed() {
4403
- this.settingsModalTab = 'settings';
4404
- this.settingsOpen = false;
4405
- requestAnimationFrame(() => this.resetSettingsModalTabsOnClose());
3121
+ this.settings.onClosed();
4406
3122
  }
4407
3123
  setSettingsModalTab(tab) {
4408
- if (tab === 'cost' && !this.settingsCostTabVisible)
4409
- return;
4410
- this.settingsModalTab = tab;
3124
+ this.settings.setTab(tab);
4411
3125
  }
4412
3126
  /** Sync the active settings modal tab when the design-system tab bar changes. */
4413
3127
  onSettingsModalTabsChange(event) {
4414
- var _a;
4415
- const id = (_a = event.detail) === null || _a === void 0 ? void 0 : _a.id;
4416
- if (id === 'settings' || id === 'cost') {
4417
- this.setSettingsModalTab(id);
4418
- }
4419
- }
4420
- /** Ensure the settings tab is selected when the modal opens. */
4421
- resetSettingsModalTabsOnOpen() {
4422
- this.settingsModalTab = 'settings';
4423
- const tabs = this.settingsTabsEl;
4424
- if (!tabs)
4425
- return;
4426
- const settingsTab = tabs.querySelector('[id="settings"]');
4427
- const costTab = tabs.querySelector('[id="cost"]');
4428
- if (costTab instanceof HTMLElement && costTab.getAttribute('aria-selected') === 'true') {
4429
- settingsTab instanceof HTMLElement && settingsTab.click();
4430
- return;
4431
- }
4432
- if (settingsTab instanceof HTMLElement &&
4433
- settingsTab.getAttribute('aria-selected') !== 'true') {
4434
- settingsTab.click();
4435
- return;
4436
- }
4437
- this.snapSettingsModalTabIndicator();
4438
- }
4439
- /** Return to the settings tab when the modal closes on Cost. */
4440
- resetSettingsModalTabsOnClose() {
4441
- const tabs = this.settingsTabsEl;
4442
- if (!tabs)
4443
- return;
4444
- const settingsTab = tabs.querySelector('[id="settings"]');
4445
- const costTab = tabs.querySelector('[id="cost"]');
4446
- if (costTab instanceof HTMLElement && costTab.getAttribute('aria-selected') === 'true') {
4447
- settingsTab instanceof HTMLElement && settingsTab.click();
4448
- }
4449
- this.snapSettingsModalTabIndicator();
4450
- }
4451
- /**
4452
- * Snap the tab underline to the settings tab without animating from Cost.
4453
- * Used after hide/show when aria state is correct but the indicator drifted.
4454
- */
4455
- snapSettingsModalTabIndicator(retry = true) {
4456
- var _a;
4457
- const tabs = this.settingsTabsEl;
4458
- if (!tabs)
4459
- return;
4460
- const settingsIndex = 0;
4461
- const indicator = (_a = tabs.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.activeIndicator');
4462
- if (!indicator) {
4463
- if (retry)
4464
- DOM.queueUpdate(() => this.snapSettingsModalTabIndicator(false));
4465
- return;
4466
- }
4467
- const internal = tabs;
4468
- indicator.style.gridColumn = `${settingsIndex + 1}`;
4469
- indicator.style.transform = 'translateX(0px)';
4470
- indicator.classList.remove('activeIndicatorTransition');
4471
- internal.ticking = false;
4472
- internal.prevActiveTabIndex = settingsIndex;
4473
- internal.activeTabIndex = settingsIndex;
3128
+ this.settings.onTabsChange(event);
4474
3129
  }
4475
3130
  /** Hint text for the currently pinned agent, if any. Used in the toggle button tooltip. */
4476
3131
  get pinnedAgentHint() {
@@ -4608,112 +3263,30 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
4608
3263
  ];
4609
3264
  }
4610
3265
  /**
4611
- * The single `meta-snapshot` entry for right now — the export-time `meta` block
4612
- * (agent summary, active prompt/state, context + cost) plus the `dedupSignature`
4613
- * the forward-capture delta keys on.
3266
+ * The `meta-snapshot` entry for right now. The assembly is a pure transformation
3267
+ * in `buildMetaSnapshot`; this gathers what it reports.
4614
3268
  *
4615
- * Separate from `buildDiagnosticEntries` because the download path needs a
4616
- * FRESH one on its own: the persisted stream only re-appends this block when the
4617
- * near-static config changes, so the newest stored snapshot's volatile half — the
4618
- * `context` figures especially — is typically frozen at the session's first flush.
3269
+ * The live agent comes from the DRIVER, not `this.activeAgent` (the redux-stripped
3270
+ * projection, no functions) and not a lookup in `this.agents` (which may mint
3271
+ * fresh, never-activated configs) — only the driver holds the instance whose
3272
+ * `onActivate` ran, so only its `getDebugSnapshot` closure holds state.
4619
3273
  */
4620
3274
  buildMetaSnapshot() {
4621
- var _a, _b, _c, _d, _e, _f, _g, _h;
4622
- const timestamp = new Date().toISOString().replace(/:/g, '-');
4623
- // Snapshot the live active agent from the DRIVER — the instance whose
4624
- // `onActivate` ran, so its `getDebugSnapshot` closure holds state.
4625
- // `this.activeAgent` is the redux-stripped projection (no functions), and
4626
- // `this.agents` may mint fresh, never-activated configs (e.g. the showcase).
4627
- const liveActiveAgent = (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.getActiveAgent) === null || _b === void 0 ? void 0 : _b.call(_a);
4628
- let activeDebugSnapshot;
4629
- try {
4630
- activeDebugSnapshot = (_c = liveActiveAgent === null || liveActiveAgent === void 0 ? void 0 : liveActiveAgent.getDebugSnapshot) === null || _c === void 0 ? void 0 : _c.call(liveActiveAgent);
4631
- }
4632
- catch (e) {
4633
- activeDebugSnapshot = `<getDebugSnapshot threw: ${e instanceof Error ? e.message : String(e)}>`;
4634
- }
4635
- const contextUsagePercent = this.contextTokens != null && this.contextLimit != null && this.contextLimit > 0
4636
- ? Math.round((this.contextTokens / this.contextLimit) * 100)
4637
- : undefined;
4638
- // The export-time `meta` block, carried on a `meta-snapshot` entry so it lives
4639
- // in the same forward stream (the latest one wins on reassembly, and the
4640
- // accumulated history exposes config/state evolution across the lifetime).
4641
- const metaSnapshot = {
4642
- kind: 'meta-snapshot',
4643
- timestamp: new Date().toISOString(),
4644
- meta: {
4645
- timestamp,
4646
- host: window.location.host,
4647
- // stripAgentHandlers drops every function-valued field (handlers, lifecycle
4648
- // hooks, onUnresolvedTool, function-form resolvers) AND object handler bags
4649
- // like an object-form `toolHandlers`, and recurses subAgents — no manual
4650
- // exclusion list to keep in sync. We only override toolDefinitions
4651
- // afterwards to expand the fold tree for the log.
4652
- agentSummary: (_d = this.agents) === null || _d === void 0 ? void 0 : _d.map((a) => {
4653
- var _a;
4654
- return (Object.assign(Object.assign({}, stripAgentHandlers(a)), { toolDefinitions: Array.isArray(a.toolDefinitions)
4655
- ? typeof a.toolHandlers === 'function'
4656
- ? // Static defs + dynamic handlers — can't walk fold tree
4657
- // because the handler map isn't materialized at log time.
4658
- a.toolDefinitions
4659
- : expandToolTree(a.toolDefinitions, (_a = a.toolHandlers) !== null && _a !== void 0 ? _a : {})
4660
- : typeof a.toolDefinitions === 'function'
4661
- ? '<dynamic — resolved per turn>'
4662
- : [] }));
4663
- }),
4664
- activeSystemPrompt: typeof ((_e = this.activeAgent) === null || _e === void 0 ? void 0 : _e.systemPrompt) === 'function'
4665
- ? '<dynamic — resolved per turn>'
4666
- : (_f = this.activeAgent) === null || _f === void 0 ? void 0 : _f.systemPrompt,
4667
- activePrimerHistory: (_g = this.activeAgent) === null || _g === void 0 ? void 0 : _g.primerHistory,
4668
- activeFoldStack: this.driver instanceof ChatDriver ? this.driver.getActiveFoldNames() : undefined,
4669
- // Context window + cost snapshot. `sessionCostUsd` is the chat-scoped
4670
- // total (per-message `cost` summed); the transport's lifetime cost is
4671
- // not included here because it pools other call sites (suggestions,
4672
- // criteria interpretation, etc.) and would be misleading next to the
4673
- // chat-only token counts.
4674
- context: {
4675
- model: this.activeModel,
4676
- contextTokens: this.contextTokens,
4677
- contextLimit: this.contextLimit,
4678
- contextUsagePercent,
4679
- sessionCostUsd: this.sessionCostUsd,
4680
- sessionTokensConsumed: this.sessionTokensConsumed,
4681
- // The per-bucket split alongside the total. Without it a log shows a large
4682
- // token count next to a small cost with no way to tell why — the answer is
4683
- // almost always that most of the prompt was a cache hit.
4684
- sessionUsage: this.sessionUsage,
4685
- },
4686
- // Snapshot captured fresh at log-export time — reflects state NOW, which
4687
- // may have transitioned since the last LLM call.
4688
- activeDebugSnapshot,
4689
- debug: (_h = this.debugStateFactory) === null || _h === void 0 ? void 0 : _h.call(this),
4690
- },
4691
- };
4692
- // Stable dedup signature for the forward-capture stream (GENC-1351 §5.8 / #12):
4693
- // the near-static config only. Excludes the volatile fields — the export
4694
- // `timestamp`, the per-turn `context` (tokens/cost), and the live
4695
- // `activeDebugSnapshot` — whose churn otherwise made the whole-`meta` dedup key
4696
- // differ on every throttled flush, so a bulky snapshot was re-appended each time
4697
- // instead of "only when the block actually changed". The volatile evolution is
4698
- // already in the timeline (turn snapshots + `context.updated` events), so the
4699
- // persister only needs a fresh meta-snapshot when the config/prompt changes.
4700
- // Consequence for the download path: the newest STORED snapshot's context/cost
4701
- // figures are stale, which is why `buildDownloadLog` appends a fresh one.
4702
- //
4703
- // DIGESTED rather than stored verbatim (GENC-1566). The projection is right; keeping a
4704
- // full copy of it was not. It is only ever compared for equality, and `agentSummary`
4705
- // alone was 159 KB in the session that prompted this — so each snapshot carried the
4706
- // block plus a stringified copy of most of it, and both went up the wire on every
4707
- // flush. See {@link configDigest} for the hash and why a collision is harmless here.
4708
- const m = metaSnapshot.meta;
4709
- metaSnapshot.dedupSignature = configDigest(JSON.stringify({
4710
- host: m.host,
4711
- agentSummary: m.agentSummary,
4712
- activeSystemPrompt: m.activeSystemPrompt,
4713
- activePrimerHistory: m.activePrimerHistory,
4714
- activeFoldStack: m.activeFoldStack,
4715
- }));
4716
- return metaSnapshot;
3275
+ var _a, _b, _c;
3276
+ return buildMetaSnapshot({
3277
+ agents: this.agents,
3278
+ activeAgent: this.activeAgent,
3279
+ liveActiveAgent: (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.getActiveAgent) === null || _b === void 0 ? void 0 : _b.call(_a),
3280
+ activeFoldStack: this.driver instanceof ChatDriver ? this.driver.getActiveFoldNames() : undefined,
3281
+ activeModel: this.activeModel,
3282
+ contextTokens: this.contextTokens,
3283
+ contextLimit: this.contextLimit,
3284
+ sessionCostUsd: this.sessionCostUsd,
3285
+ sessionTokensConsumed: this.sessionTokensConsumed,
3286
+ sessionUsage: this.sessionUsage,
3287
+ debugState: (_c = this.debugStateFactory) === null || _c === void 0 ? void 0 : _c.call(this),
3288
+ host: window.location.host,
3289
+ });
4717
3290
  }
4718
3291
  downloadDebugLog() {
4719
3292
  return __awaiter(this, void 0, void 0, function* () {
@@ -4946,146 +3519,16 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
4946
3519
  });
4947
3520
  }
4948
3521
  triggerFileInput() {
4949
- var _a, _b;
4950
- (_b = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.file-input')) === null || _b === void 0 ? void 0 : _b.click();
3522
+ this.attachmentCtrl.triggerFileInput();
4951
3523
  }
4952
3524
  handleFileSelect(e) {
4953
- void this.loadSelectedFiles(e);
3525
+ this.attachmentCtrl.handleFileSelect(e);
4954
3526
  }
4955
3527
  removeAttachment(attachment) {
4956
- this.attachments = this.attachments.filter((a) => a !== attachment);
3528
+ this.attachmentCtrl.remove(attachment);
4957
3529
  }
4958
3530
  removeAttachmentError(message) {
4959
- const idx = this.attachmentErrors.indexOf(message);
4960
- if (idx !== -1) {
4961
- this.attachmentErrors = [
4962
- ...this.attachmentErrors.slice(0, idx),
4963
- ...this.attachmentErrors.slice(idx + 1),
4964
- ];
4965
- }
4966
- }
4967
- isAcceptedFile(file) {
4968
- var _a, _b, _c;
4969
- const accepted = (_a = this.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.acceptedFiles;
4970
- if (!accepted)
4971
- return false;
4972
- const acceptedList = accepted.split(',').map((s) => s.trim().toLowerCase());
4973
- const ext = `.${(_c = (_b = file.name.split('.').pop()) === null || _b === void 0 ? void 0 : _b.toLowerCase()) !== null && _c !== void 0 ? _c : ''}`;
4974
- return acceptedList.some((a) => a === ext ||
4975
- a === file.type ||
4976
- (a.endsWith('/*') && file.type.startsWith(a.replace('/*', '/'))));
4977
- }
4978
- // Blob's own promise APIs, not FileReader: UTF-8 only (a `charset` parameter on the blob's
4979
- // type is ignored, where FileReader would honour it), and they exist in the node test runtime
4980
- // too — FileReader does not, so neither reader was testable before.
4981
- readFileAsText(file) {
4982
- return file.text();
4983
- }
4984
- /** The base64 payload of a file, without any `data:` prefix — the raw form
4985
- * `ChatImageAttachment.data` specifies (transports add their own framing). */
4986
- readFileAsBase64(file) {
4987
- return __awaiter(this, void 0, void 0, function* () {
4988
- const bytes = new Uint8Array(yield file.arrayBuffer());
4989
- let binary = '';
4990
- const CHUNK = 0x8000;
4991
- for (let i = 0; i < bytes.length; i += CHUNK) {
4992
- binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
4993
- }
4994
- return btoa(binary);
4995
- });
4996
- }
4997
- processFiles(files_1) {
4998
- return __awaiter(this, arguments, void 0, function* (files, existingBytes = 0) {
4999
- // Budget the batch BEFORE reading any of it. `file.size` needs no I/O, so the verdict is
5000
- // deterministic and in the user's own pick order; deciding inside the parallel map below would
5001
- // hand the last slot to whichever read happened to finish first. `existingBytes` is what the
5002
- // caller has already committed to — the picker appends to a running list, so its files have to
5003
- // count against what is already attached rather than starting from zero each time.
5004
- let running = existingBytes;
5005
- const overBudget = new Map();
5006
- for (const file of files) {
5007
- if (running + file.size > MAX_ATTACHMENTS_TOTAL_BYTES) {
5008
- overBudget.set(file, `"${file.name}" would take this message past the ` +
5009
- `${MAX_ATTACHMENTS_TOTAL_BYTES / BYTES_PER_MB}MB attachment limit ` +
5010
- `(${(running / BYTES_PER_MB).toFixed(1)}MB already attached).`);
5011
- continue;
5012
- }
5013
- running += file.size;
5014
- }
5015
- const results = yield Promise.all(files.map((file) => __awaiter(this, void 0, void 0, function* () {
5016
- if (!this.isAcceptedFile(file)) {
5017
- return { ok: false, message: `"${file.name}" is not an accepted file type.` };
5018
- }
5019
- const tooMuch = overBudget.get(file);
5020
- if (tooMuch)
5021
- return { ok: false, message: tooMuch };
5022
- try {
5023
- // An image file becomes a real image attachment — the transports render those as
5024
- // image blocks the model SEES. Reading it as text (the only path that existed)
5025
- // produced mojibake the model "read" as prose: an accepted .png was worse than a
5026
- // rejected one. Text stays the default for everything else.
5027
- //
5028
- // Only the types the vendors actually accept take this branch. `image/*` covers far
5029
- // more than that, and sending one an API rejects fails the whole turn the user is
5030
- // waiting on — where the text branch merely degrades. It also keeps SVG working:
5031
- // SVG *is* text, and reading it as such gives the model something it can genuinely
5032
- // read, which a base64 image block of it would not.
5033
- if (VENDOR_IMAGE_MIME_TYPES.has(file.type.toLowerCase())) {
5034
- if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) {
5035
- return {
5036
- ok: false,
5037
- message: `"${file.name}" is ${Math.ceil(file.size / BYTES_PER_MB)}MB; images are limited to ` +
5038
- `${MAX_IMAGE_ATTACHMENT_BYTES / BYTES_PER_MB}MB.`,
5039
- };
5040
- }
5041
- const data = yield this.readFileAsBase64(file);
5042
- return {
5043
- ok: true,
5044
- attachment: { kind: 'image', name: file.name, mimeType: file.type, data },
5045
- };
5046
- }
5047
- const content = yield this.readFileAsText(file);
5048
- return {
5049
- ok: true,
5050
- attachment: { name: file.name, content, mimeType: file.type || 'text/plain' },
5051
- };
5052
- }
5053
- catch (readError) {
5054
- logger.error('Failed to read file:', readError);
5055
- this.logMeta('file.read-failed', {
5056
- file: file.name,
5057
- message: readError instanceof Error ? readError.message : String(readError),
5058
- });
5059
- return { ok: false, message: `Failed to read "${file.name}".` };
5060
- }
5061
- })));
5062
- return {
5063
- attachments: results
5064
- .filter((r) => r.ok)
5065
- .map((r) => r.attachment),
5066
- errors: results
5067
- .filter((r) => !r.ok)
5068
- .map((r) => r.message),
5069
- };
5070
- });
5071
- }
5072
- loadSelectedFiles(e) {
5073
- return __awaiter(this, void 0, void 0, function* () {
5074
- var _a;
5075
- const input = e.target;
5076
- const files = Array.from((_a = input.files) !== null && _a !== void 0 ? _a : []);
5077
- input.value = '';
5078
- const { attachments, errors } = yield this.processFiles(files, this.attachments.reduce((n, a) => n + attachmentBytes(a), 0));
5079
- if (attachments.length) {
5080
- this.attachments = [...this.attachments, ...attachments];
5081
- this.logMeta('attachment.added', {
5082
- count: attachments.length,
5083
- names: attachments.map((a) => a.name),
5084
- });
5085
- }
5086
- if (errors.length)
5087
- this.attachmentErrors = [...this.attachmentErrors, ...errors];
5088
- });
3531
+ this.attachmentCtrl.dismissError(message);
5089
3532
  }
5090
3533
  handleSendClick() {
5091
3534
  this.send();
@@ -5141,7 +3584,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
5141
3584
  }
5142
3585
  let nextAttachments = [];
5143
3586
  if ((_a = input.files) === null || _a === void 0 ? void 0 : _a.length) {
5144
- const { attachments, errors } = yield this.processFiles(input.files);
3587
+ const { attachments, errors } = yield this.attachmentCtrl.process(input.files);
5145
3588
  // State may have flipped to 'loading' during the async file processing
5146
3589
  // (e.g. a concurrent send via the composer). Re-check before committing
5147
3590
  // any composer state. Beyond this point execution is synchronous up to
@@ -5260,10 +3703,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
5260
3703
  // which in turn triggers the _executionCompletionUnsub subscription on whichever
5261
3704
  // element is currently active (e.g. the collapse-mode element after docking).
5262
3705
  const capturedSessionRef = this._sessionRef;
5263
- const pendingAttachments = this.attachments.length ? [...this.attachments] : undefined;
3706
+ const pendingAttachments = this.attachmentCtrl.take();
5264
3707
  this.inputValue = '';
5265
- this.attachments = [];
5266
- this.attachmentErrors = [];
5267
3708
  this.suggestionsState = { status: 'idle' };
5268
3709
  this._userScrolledAway = false;
5269
3710
  // Close the picker on send — the toggle button is disabled during loading,
@@ -5283,10 +3724,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
5283
3724
  // locks without waiting on the host's next pre-flight. The bus subscription
5284
3725
  // in `connectedCallback` covers most walls; this covers the one it cannot —
5285
3726
  // a wall hit during multi-agent classification, where no tool loop ran and
5286
- // so no `tool-loop-end` was published. `latchBlockedFrom` is idempotent, so
3727
+ // so no `tool-loop-end` was published. `latchFrom` is idempotent, so
5287
3728
  // the two seams overlapping is harmless.
5288
3729
  if ((result === null || result === void 0 ? void 0 : result.reason) === 'done') {
5289
- this.latchBlockedFrom(result.failureReason, capturedSessionRef, result.budget);
3730
+ this.budget.latchFrom(result.failureReason, capturedSessionRef, result.budget);
5290
3731
  }
5291
3732
  }
5292
3733
  finally {
@@ -5392,58 +3833,22 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
5392
3833
  get sendButtonVisible() {
5393
3834
  return !this.stopButtonVisible;
5394
3835
  }
5395
- /** Whether the message input shows a drag handle to resize its height.
5396
- * On by default; hosts opt out via `chatConfig.ui.resizableInput: false`. */
3836
+ /** Whether the message input shows a drag handle to resize its height. */
5397
3837
  get isComposerResizable() {
5398
- var _a;
5399
- return ((_a = this.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.resizableInput) !== false;
3838
+ return this.composerResize.resizable;
5400
3839
  }
5401
- /** Inline style for the chat input: writes the chosen height into the
5402
- * `--composer-height` custom property (read by `.chat-input` + its control in
5403
- * `main.styles.ts`). Empty until the user drags (or when resizing is disabled),
5404
- * so the default applies. */
3840
+ /** Inline style carrying the dragged height into `--composer-height`. */
5405
3841
  get composerHeightStyle() {
5406
- return this.isComposerResizable && this.composerHeightPx != null
5407
- ? `--composer-height: ${this.composerHeightPx}px`
5408
- : '';
3842
+ return this.composerResize.heightStyle;
5409
3843
  }
5410
- /** Begin a composer resize drag. Captures the pointer so move/up keep firing
5411
- * on the handle even when the cursor leaves its 7px band. */
5412
3844
  onComposerResizeStart(e) {
5413
- var _a, _b, _c, _d, _e;
5414
- const measured = (_b = (_a = this.chatInputEl) === null || _a === void 0 ? void 0 : _a.getBoundingClientRect().height) !== null && _b !== void 0 ? _b : COMPOSER_MIN_HEIGHT_PX;
5415
- // First drag (no explicit height yet) measures the natural height — the
5416
- // floor for shrinking, so the box never goes below where it started.
5417
- if (this.composerHeightPx == null)
5418
- this._composerNaturalHeight = measured;
5419
- this._resizeStartY = e.clientY;
5420
- this._resizeStartHeight = (_c = this.composerHeightPx) !== null && _c !== void 0 ? _c : measured;
5421
- // Cap growth to the headroom currently above the composer: the message
5422
- // list can yield all but a visible strip of its height. The pool (composer
5423
- // + messages) is fixed for the duration of one drag, so measuring once here
5424
- // is correct throughout the gesture.
5425
- const messagesHeight = (_e = (_d = this.messagesEl) === null || _d === void 0 ? void 0 : _d.getBoundingClientRect().height) !== null && _e !== void 0 ? _e : COMPOSER_MAX_HEIGHT_PX;
5426
- this._resizeMaxHeight = Math.min(COMPOSER_MAX_HEIGHT_PX, this._resizeStartHeight + Math.max(0, messagesHeight - COMPOSER_MIN_MESSAGES_PX));
5427
- e.currentTarget.setPointerCapture(e.pointerId);
5428
- }
5429
- /** Resize while dragging. Dragging up (clientY decreases) grows the composer;
5430
- * the result is clamped between the natural height (lower) and the available
5431
- * headroom/max (upper). No-op unless a drag is active (the handle holds pointer
5432
- * capture), so plain hover-moves are ignored. */
3845
+ this.composerResize.onStart(e);
3846
+ }
5433
3847
  onComposerResizeMove(e) {
5434
- const handle = e.currentTarget;
5435
- if (!handle.hasPointerCapture(e.pointerId))
5436
- return;
5437
- const min = Math.max(COMPOSER_MIN_HEIGHT_PX, Math.round(this._composerNaturalHeight));
5438
- const max = Math.max(min, Math.round(this._resizeMaxHeight));
5439
- const next = this._resizeStartHeight + (this._resizeStartY - e.clientY);
5440
- this.composerHeightPx = Math.max(min, Math.min(max, Math.round(next)));
3848
+ this.composerResize.onMove(e);
5441
3849
  }
5442
- /** End a resize drag and release the pointer capture. */
5443
3850
  onComposerResizeEnd(e) {
5444
- const handle = e.currentTarget;
5445
- if (handle.hasPointerCapture(e.pointerId))
5446
- handle.releasePointerCapture(e.pointerId);
3851
+ this.composerResize.onEnd(e);
5447
3852
  }
5448
3853
  };
5449
3854
  FoundationAiAssistant.SCROLL_BOTTOM_THRESHOLD_PX = 50;
@@ -5452,16 +3857,6 @@ FoundationAiAssistant.CONTEXT_USAGE_WARN_PERCENT = 80;
5452
3857
  FoundationAiAssistant.DEFAULT_LOADING_DELAY_S = 0;
5453
3858
  FoundationAiAssistant.DEFAULT_SUGGESTION_COUNT = 3;
5454
3859
  FoundationAiAssistant.MS_PER_SECOND = 1000;
5455
- /**
5456
- * Vertical space an open menu must leave free above the button, covering BOTH
5457
- * the gap the menu sits off the button by (8px, declared in design units in
5458
- * main.styles.ts) and the breathing room wanted above it. The two are lumped
5459
- * together because only the second half is a free choice: the first is fixed by
5460
- * the stylesheet and simply has to be subtracted from the headroom, and a
5461
- * constant named for only one of them invites someone to "fix" a gap that is
5462
- * doing exactly what it should.
5463
- */
5464
- FoundationAiAssistant.MENU_HEADROOM_RESERVE = 20;
5465
3860
  __decorate([
5466
3861
  AIProviderRegistry
5467
3862
  ], FoundationAiAssistant.prototype, "providerRegistry", void 0);
@@ -5528,78 +3923,15 @@ __decorate([
5528
3923
  __decorate([
5529
3924
  volatile
5530
3925
  ], FoundationAiAssistant.prototype, "busy", null);
5531
- __decorate([
5532
- volatile
5533
- ], FoundationAiAssistant.prototype, "blocked", null);
5534
- __decorate([
5535
- volatile
5536
- ], FoundationAiAssistant.prototype, "reachableVendors", null);
5537
- __decorate([
5538
- observable
5539
- ], FoundationAiAssistant.prototype, "budgetMeterLastFedAt", void 0);
5540
- __decorate([
5541
- volatile
5542
- ], FoundationAiAssistant.prototype, "settingsBudgetUpdatedLabel", null);
5543
- __decorate([
5544
- volatile
5545
- ], FoundationAiAssistant.prototype, "relevantBlockedVendors", null);
5546
- __decorate([
5547
- volatile
5548
- ], FoundationAiAssistant.prototype, "bannerVisible", null);
5549
- __decorate([
5550
- volatile
5551
- ], FoundationAiAssistant.prototype, "suggestionsBlocked", null);
5552
- __decorate([
5553
- volatile
5554
- ], FoundationAiAssistant.prototype, "activeVendor", null);
5555
- __decorate([
5556
- volatile
5557
- ], FoundationAiAssistant.prototype, "effectiveBlockedReason", null);
5558
3926
  __decorate([
5559
3927
  volatile
5560
3928
  ], FoundationAiAssistant.prototype, "effectiveChatInputDuringExecution", null);
5561
- __decorate([
5562
- volatile
5563
- ], FoundationAiAssistant.prototype, "uniqueProviderModels", null);
5564
- __decorate([
5565
- volatile
5566
- ], FoundationAiAssistant.prototype, "sortedCostSessionHistory", null);
5567
- __decorate([
5568
- volatile
5569
- ], FoundationAiAssistant.prototype, "cumulativeUsage", null);
5570
- __decorate([
5571
- volatile
5572
- ], FoundationAiAssistant.prototype, "cumulativeCostUsd", null);
5573
- __decorate([
5574
- volatile
5575
- ], FoundationAiAssistant.prototype, "liveUsageIsProjectLifetime", null);
5576
- __decorate([
5577
- volatile
5578
- ], FoundationAiAssistant.prototype, "resolvedCostHistory", null);
5579
- __decorate([
5580
- volatile
5581
- ], FoundationAiAssistant.prototype, "liveCostSessionTitle", null);
5582
- __decorate([
5583
- observable
5584
- ], FoundationAiAssistant.prototype, "attachments", void 0);
5585
- __decorate([
5586
- observable
5587
- ], FoundationAiAssistant.prototype, "attachmentErrors", void 0);
5588
3929
  __decorate([
5589
3930
  observable
5590
3931
  ], FoundationAiAssistant.prototype, "showLoadingIndicator", void 0);
5591
- __decorate([
5592
- observable
5593
- ], FoundationAiAssistant.prototype, "appSettingsRevision", void 0);
5594
- __decorate([
5595
- observable
5596
- ], FoundationAiAssistant.prototype, "settingsOpen", void 0);
5597
3932
  __decorate([
5598
3933
  observable
5599
3934
  ], FoundationAiAssistant.prototype, "downloadingDebugLog", void 0);
5600
- __decorate([
5601
- observable
5602
- ], FoundationAiAssistant.prototype, "settingsModalTab", void 0);
5603
3935
  __decorate([
5604
3936
  observable
5605
3937
  ], FoundationAiAssistant.prototype, "persistenceBroken", void 0);
@@ -5609,27 +3941,9 @@ __decorate([
5609
3941
  __decorate([
5610
3942
  observable
5611
3943
  ], FoundationAiAssistant.prototype, "persistenceBrokenDetail", void 0);
5612
- __decorate([
5613
- observable
5614
- ], FoundationAiAssistant.prototype, "settingsModelSlotted", void 0);
5615
- __decorate([
5616
- observable
5617
- ], FoundationAiAssistant.prototype, "settingsAppSlotted", void 0);
5618
- __decorate([
5619
- observable
5620
- ], FoundationAiAssistant.prototype, "settingsCostCurrentSlotted", void 0);
5621
- __decorate([
5622
- observable
5623
- ], FoundationAiAssistant.prototype, "settingsCostHistorySlotted", void 0);
5624
- __decorate([
5625
- observable
5626
- ], FoundationAiAssistant.prototype, "costSessionHistory", void 0);
5627
3944
  __decorate([
5628
3945
  observable
5629
3946
  ], FoundationAiAssistant.prototype, "showingSplash", void 0);
5630
- __decorate([
5631
- observable
5632
- ], FoundationAiAssistant.prototype, "composerHeightPx", void 0);
5633
3947
  __decorate([
5634
3948
  observable
5635
3949
  ], FoundationAiAssistant.prototype, "isEngaged", void 0);
@@ -5639,9 +3953,6 @@ __decorate([
5639
3953
  __decorate([
5640
3954
  volatile
5641
3955
  ], FoundationAiAssistant.prototype, "visibleMessages", null);
5642
- __decorate([
5643
- volatile
5644
- ], FoundationAiAssistant.prototype, "contextGuardPolicy", null);
5645
3956
  __decorate([
5646
3957
  observable
5647
3958
  ], FoundationAiAssistant.prototype, "confirmingClear", void 0);
@@ -5663,90 +3974,12 @@ __decorate([
5663
3974
  __decorate([
5664
3975
  volatile
5665
3976
  ], FoundationAiAssistant.prototype, "persistenceActive", null);
5666
- __decorate([
5667
- volatile
5668
- ], FoundationAiAssistant.prototype, "contextBudget", null);
5669
3977
  __decorate([
5670
3978
  volatile
5671
3979
  ], FoundationAiAssistant.prototype, "contextReserve", null);
5672
- __decorate([
5673
- volatile
5674
- ], FoundationAiAssistant.prototype, "compactionPlan", null);
5675
- __decorate([
5676
- volatile
5677
- ], FoundationAiAssistant.prototype, "compactHint", null);
5678
- __decorate([
5679
- volatile
5680
- ], FoundationAiAssistant.prototype, "contextBlockCause", null);
5681
- __decorate([
5682
- volatile
5683
- ], FoundationAiAssistant.prototype, "contextGate", null);
5684
- __decorate([
5685
- volatile
5686
- ], FoundationAiAssistant.prototype, "contextGateReason", null);
5687
- __decorate([
5688
- volatile
5689
- ], FoundationAiAssistant.prototype, "contextGateLabel", null);
5690
- __decorate([
5691
- volatile
5692
- ], FoundationAiAssistant.prototype, "composerNoticeVisible", null);
5693
- __decorate([
5694
- volatile
5695
- ], FoundationAiAssistant.prototype, "composerNoticeText", null);
5696
- __decorate([
5697
- volatile
5698
- ], FoundationAiAssistant.prototype, "composerNoticeSrOnly", null);
5699
- __decorate([
5700
- volatile
5701
- ], FoundationAiAssistant.prototype, "compactable", null);
5702
3980
  __decorate([
5703
3981
  volatile
5704
3982
  ], FoundationAiAssistant.prototype, "agentPickerEnabled", null);
5705
- __decorate([
5706
- volatile
5707
- ], FoundationAiAssistant.prototype, "settingsModelSectionVisible", null);
5708
- __decorate([
5709
- volatile
5710
- ], FoundationAiAssistant.prototype, "settingsContextUsageVisible", null);
5711
- __decorate([
5712
- volatile
5713
- ], FoundationAiAssistant.prototype, "settingsBudgetUsageVisible", null);
5714
- __decorate([
5715
- volatile
5716
- ], FoundationAiAssistant.prototype, "settingsBudgetRows", null);
5717
- __decorate([
5718
- volatile
5719
- ], FoundationAiAssistant.prototype, "settingsAppSectionVisible", null);
5720
- __decorate([
5721
- volatile
5722
- ], FoundationAiAssistant.prototype, "visibleAppSettingsToggles", null);
5723
- __decorate([
5724
- volatile
5725
- ], FoundationAiAssistant.prototype, "appSettingsTogglesHeading", null);
5726
- __decorate([
5727
- volatile
5728
- ], FoundationAiAssistant.prototype, "settingsCostCurrentSectionVisible", null);
5729
- __decorate([
5730
- volatile
5731
- ], FoundationAiAssistant.prototype, "settingsCostHistorySectionVisible", null);
5732
- __decorate([
5733
- volatile
5734
- ], FoundationAiAssistant.prototype, "settingsCostHistoryHeadingVisible", null);
5735
- __decorate([
5736
- volatile
5737
- ], FoundationAiAssistant.prototype, "settingsCostSummaryVisible", null);
5738
- __decorate([
5739
- volatile
5740
- ], FoundationAiAssistant.prototype, "settingsCostLifetimeVisible", null);
5741
- __decorate([
5742
- volatile
5743
- ], FoundationAiAssistant.prototype, "settingsCostTabVisible", null);
5744
- __decorate([
5745
- volatile
5746
- ], FoundationAiAssistant.prototype, "settingsSessionTokensVisible", null);
5747
- __decorate([
5748
- volatile
5749
- ], FoundationAiAssistant.prototype, "settingsCogVisible", null);
5750
3983
  __decorate([
5751
3984
  volatile
5752
3985
  ], FoundationAiAssistant.prototype, "pinnedAgentHint", null);