tin-spa 20.14.99 → 20.14.113

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 (51) hide show
  1. package/core/index.d.ts +3033 -0
  2. package/fesm2022/tin-spa-accounting.module-Cg4vWaJE.mjs +2231 -0
  3. package/fesm2022/tin-spa-accounting.module-Cg4vWaJE.mjs.map +1 -0
  4. package/fesm2022/tin-spa-agent-page.component-DJvMPjCQ.mjs +39 -0
  5. package/fesm2022/tin-spa-agent-page.component-DJvMPjCQ.mjs.map +1 -0
  6. package/fesm2022/tin-spa-agent.component-D0CGGnGZ.mjs +1379 -0
  7. package/fesm2022/tin-spa-agent.component-D0CGGnGZ.mjs.map +1 -0
  8. package/fesm2022/tin-spa-assets.module-qIWNwJ2E.mjs +167 -0
  9. package/fesm2022/tin-spa-assets.module-qIWNwJ2E.mjs.map +1 -0
  10. package/fesm2022/tin-spa-core.mjs +13628 -0
  11. package/fesm2022/tin-spa-core.mjs.map +1 -0
  12. package/fesm2022/tin-spa-general-erp.module-CoYxceLj.mjs +138 -0
  13. package/fesm2022/tin-spa-general-erp.module-CoYxceLj.mjs.map +1 -0
  14. package/fesm2022/tin-spa-hr.module-CKPZ9ZOj.mjs +134 -0
  15. package/fesm2022/tin-spa-hr.module-CKPZ9ZOj.mjs.map +1 -0
  16. package/fesm2022/tin-spa-http.mjs +117 -0
  17. package/fesm2022/tin-spa-http.mjs.map +1 -0
  18. package/fesm2022/tin-spa-inventory.module-DRKAcNau.mjs +338 -0
  19. package/fesm2022/tin-spa-inventory.module-DRKAcNau.mjs.map +1 -0
  20. package/fesm2022/tin-spa-keys.mjs +14 -0
  21. package/fesm2022/tin-spa-keys.mjs.map +1 -0
  22. package/fesm2022/tin-spa-landing.mjs +189 -0
  23. package/fesm2022/tin-spa-landing.mjs.map +1 -0
  24. package/fesm2022/tin-spa-loans.module-BTDmgrWo.mjs +115 -0
  25. package/fesm2022/tin-spa-loans.module-BTDmgrWo.mjs.map +1 -0
  26. package/fesm2022/tin-spa-login.mjs +719 -0
  27. package/fesm2022/tin-spa-login.mjs.map +1 -0
  28. package/fesm2022/tin-spa-manufacturing.module-CHta6oV_.mjs +96 -0
  29. package/fesm2022/tin-spa-manufacturing.module-CHta6oV_.mjs.map +1 -0
  30. package/fesm2022/tin-spa-map.mjs +750 -0
  31. package/fesm2022/tin-spa-map.mjs.map +1 -0
  32. package/fesm2022/tin-spa-payroll.module-CZ_GO1jF.mjs +847 -0
  33. package/fesm2022/tin-spa-payroll.module-CZ_GO1jF.mjs.map +1 -0
  34. package/fesm2022/tin-spa-purchasing.module-NCdwquaF.mjs +161 -0
  35. package/fesm2022/tin-spa-purchasing.module-NCdwquaF.mjs.map +1 -0
  36. package/fesm2022/tin-spa-sales.module-D0uEGJW7.mjs +1346 -0
  37. package/fesm2022/tin-spa-sales.module-D0uEGJW7.mjs.map +1 -0
  38. package/fesm2022/tin-spa-tenancy-erp.module-GwIv8Wbc.mjs +932 -0
  39. package/fesm2022/tin-spa-tenancy-erp.module-GwIv8Wbc.mjs.map +1 -0
  40. package/fesm2022/tin-spa-tin-spa-Cqe5hYTr.mjs +22576 -0
  41. package/fesm2022/tin-spa-tin-spa-Cqe5hYTr.mjs.map +1 -0
  42. package/fesm2022/tin-spa.mjs +2 -42079
  43. package/fesm2022/tin-spa.mjs.map +1 -1
  44. package/http/index.d.ts +16 -0
  45. package/index.d.ts +588 -4687
  46. package/keys/index.d.ts +4 -0
  47. package/landing/index.d.ts +175 -0
  48. package/login/index.d.ts +141 -0
  49. package/map/index.d.ts +148 -0
  50. package/package.json +25 -1
  51. package/src/tin-styles.css +81 -2
@@ -0,0 +1,1379 @@
1
+ import * as i0 from '@angular/core';
2
+ import { Pipe, Injectable, HostListener, Input, ViewChild, Component } from '@angular/core';
3
+ import { filter } from 'rxjs/operators';
4
+ import * as i1$2 from '@angular/router';
5
+ import { NavigationEnd } from '@angular/router';
6
+ import { trigger, transition, style, animate } from '@angular/animations';
7
+ import * as i1$1 from 'tin-spa/core';
8
+ import { SpaMatModule } from 'tin-spa/core';
9
+ import { f as CamelToWordsPipe } from './tin-spa-tin-spa-Cqe5hYTr.mjs';
10
+ import * as i1 from '@angular/platform-browser';
11
+ import { BehaviorSubject } from 'rxjs';
12
+ import * as i2 from '@angular/cdk/layout';
13
+ import * as i1$3 from '@angular/common';
14
+ import * as i2$1 from '@angular/forms';
15
+ import * as i7 from '@angular/material/menu';
16
+ import * as i3 from '@angular/material/button';
17
+ import * as i3$1 from '@angular/material/icon';
18
+ import * as i3$2 from '@angular/material/form-field';
19
+ import * as i4 from '@angular/material/input';
20
+ import * as i7$1 from '@angular/material/tooltip';
21
+
22
+ // Renders the agent's replies as the markdown they are actually written in.
23
+ //
24
+ // The agent writes **bold**, lists, headings and menu paths, and the widget printed all of it verbatim — so a
25
+ // reply the guide carefully formatted arrived as a wall of asterisks. Telegram and WhatsApp now convert to
26
+ // their own markup; this is the web's half of the same job.
27
+ //
28
+ // Deliberately a small hand-rolled subset rather than a markdown library: the input is not arbitrary user
29
+ // markdown, it is the agent's own output, which uses bold, italic, inline code, headings, bullets and
30
+ // numbered steps and nothing else. A library would add a dependency and a much larger escaping surface for
31
+ // no gain here.
32
+ //
33
+ // SECURITY: every character is HTML-escaped BEFORE any formatting is applied, so nothing in a reply can
34
+ // become markup. That matters more than usual for this component — an agent reply can contain data a
35
+ // different user typed, so it must be treated as hostile text, not as trusted output.
36
+ // Changed (nav-menu-standalone): standalone so the standalone AgentComponent can import the pipe by path.
37
+ // TinSpaModule now IMPORTS it; it was never exported, so no consumer-facing surface changes.
38
+ class AgentMarkdownPipe {
39
+ constructor(sanitizer) {
40
+ this.sanitizer = sanitizer;
41
+ }
42
+ transform(value) {
43
+ if (!value)
44
+ return '';
45
+ let html = this.escape(value);
46
+ // Headings first: they are line-anchored, and converting them after inline rules would let a ** on the
47
+ // same line interfere
48
+ html = html.replace(/^#{1,6}\s*(.+)$/gm, '<strong class="md-heading">$1</strong>');
49
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
50
+ html = html.replace(/(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])/g, '<em>$1</em>');
51
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
52
+ // Bullets and numbered steps keep their marker — turning them into <ul>/<ol> would need block parsing,
53
+ // and the agent's lists are already readable as lines
54
+ html = html.replace(/^(\s*)[-•*]\s+(.+)$/gm, '$1<span class="md-bullet">•</span> $2');
55
+ html = html.replace(/\n/g, '<br>');
56
+ return this.sanitizer.bypassSecurityTrustHtml(html);
57
+ }
58
+ // Escaping BEFORE formatting is what makes bypassSecurityTrustHtml safe here: by the time anything is
59
+ // trusted, the only tags present are the ones this pipe put there itself.
60
+ escape(text) {
61
+ return text
62
+ .replace(/&/g, '&amp;')
63
+ .replace(/</g, '&lt;')
64
+ .replace(/>/g, '&gt;')
65
+ .replace(/"/g, '&quot;')
66
+ .replace(/'/g, '&#39;');
67
+ }
68
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentMarkdownPipe, deps: [{ token: i1.DomSanitizer }], target: i0.ɵɵFactoryTarget.Pipe }); }
69
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "20.3.14", ngImport: i0, type: AgentMarkdownPipe, isStandalone: true, name: "agentMarkdown" }); }
70
+ }
71
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentMarkdownPipe, decorators: [{
72
+ type: Pipe,
73
+ args: [{ name: 'agentMarkdown', standalone: true }]
74
+ }], ctorParameters: () => [{ type: i1.DomSanitizer }] });
75
+
76
+ // Frontend service for Agent chat — state management, API calls, and SignalR subscriptions (renamed from AssistantService)
77
+ class AgentService {
78
+ constructor(dataService, signalRService, apiErrorService // Changed: injected for loadMessages and deleteConversation only
79
+ ) {
80
+ this.dataService = dataService;
81
+ this.signalRService = signalRService;
82
+ this.apiErrorService = apiErrorService;
83
+ this.messages = new BehaviorSubject([]);
84
+ this.conversations = new BehaviorSubject([]);
85
+ this.typing = new BehaviorSubject(false);
86
+ this.isOpen = new BehaviorSubject(false);
87
+ this.currentConversationId = new BehaviorSubject(null);
88
+ this.greeting = new BehaviorSubject('');
89
+ this.suggestedQuestions = new BehaviorSubject([]);
90
+ this.sessionInfo = new BehaviorSubject(null); // Changed: what the agent is in the middle of
91
+ this.messages$ = this.messages.asObservable();
92
+ this.conversations$ = this.conversations.asObservable();
93
+ this.typing$ = this.typing.asObservable();
94
+ this.isOpen$ = this.isOpen.asObservable();
95
+ this.currentConversationId$ = this.currentConversationId.asObservable();
96
+ this.greeting$ = this.greeting.asObservable();
97
+ this.suggestedQuestions$ = this.suggestedQuestions.asObservable();
98
+ this.sessionInfo$ = this.sessionInfo.asObservable();
99
+ // Changed: Load the most recent conversation's messages on first open
100
+ this.hasLoadedInitial = false;
101
+ this.initListeners();
102
+ }
103
+ // App name derived from the existing appConfig
104
+ get appName() {
105
+ return this.dataService.appConfig.appName || 'App';
106
+ }
107
+ // Agent name derived from appConfig (changed from assistantName)
108
+ get agentName() {
109
+ return `${this.appName} Agent`;
110
+ }
111
+ // Subscribe to SignalR events for real-time message delivery
112
+ initListeners() {
113
+ // Changed: SignalR is the SECOND path to a reply, not the only one. It still matters — a turn started on
114
+ // the phone should appear on the desktop — but it now goes through the same deduping append as the HTTP
115
+ // answer, so the common case (both arrive) shows one bubble rather than two.
116
+ this.signalRService.agentMessage$.subscribe(msg => {
117
+ if (msg && msg.content) {
118
+ this.addAssistantMessage(msg.content);
119
+ }
120
+ });
121
+ this.signalRService.agentTyping$.subscribe(typing => {
122
+ this.typing.next(typing);
123
+ });
124
+ }
125
+ // Load agent config (greeting + suggested questions) from backend
126
+ loadConfig() {
127
+ this.dataService.CallApi({ url: `agent/config/${this.appName}` }).subscribe(res => {
128
+ // DELIBERATELY SILENT (WS-6 Phase 4 triage). Greeting text and suggested-question chips are decoration
129
+ // on a chat window that works perfectly without them — the user can still type. Nothing they can see is
130
+ // wrong, so there is nothing truthful to tell them.
131
+ if (res.success && res.data) {
132
+ this.greeting.next(res.data.greeting || '');
133
+ this.suggestedQuestions.next(res.data.suggestedQuestions || []);
134
+ }
135
+ });
136
+ }
137
+ // Load user's conversation list
138
+ loadConversations() {
139
+ this.dataService.CallApi({ url: 'agent/conversations' }).subscribe(res => {
140
+ // DELIBERATELY SILENT (WS-6 Phase 4 triage), and this was the closest call in the sweep — an empty
141
+ // thread list IS a false statement about the user's own history. It stays silent because of WHEN it
142
+ // fires: agent.component.ts:212 calls it from the widget's ngOnInit, so it runs on every app-shell load
143
+ // whether or not anyone opens the chat. Converting would put a dialog in front of every user of the app
144
+ // for a list none of them had asked to see. loadMessages() below — which only ever runs because someone
145
+ // clicked a specific thread — is the one that reports, and it covers the same outage the moment the
146
+ // list is actually used.
147
+ if (res.success) {
148
+ this.conversations.next(res.data || []);
149
+ }
150
+ });
151
+ }
152
+ // Load messages for a specific conversation
153
+ loadMessages(conversationId) {
154
+ this.currentConversationId.next(conversationId);
155
+ this.dataService.CallApi({ url: `agent/conversations/${conversationId}/messages` }).subscribe(res => {
156
+ // CONVERTED (WS-6 Phase 4 triage). Unlike everything else in this service, this only runs because the
157
+ // user clicked a named conversation in the switcher. Failing left the thread pane holding the PREVIOUS
158
+ // conversation's messages under the new conversation's id — so the next thing they typed would have
159
+ // been sent into a thread they were not looking at.
160
+ if (!res.success)
161
+ this.apiErrorService.presentAppFailure(res, 'load', `agent/conversations/${conversationId}/messages`);
162
+ if (res.success) {
163
+ this.messages.next((res.data || []).map((m) => ({
164
+ conversationId: m.conversationID,
165
+ role: m.role,
166
+ content: m.content,
167
+ intent: m.intent,
168
+ createdDate: new Date(m.createdDate)
169
+ })));
170
+ }
171
+ });
172
+ }
173
+ // Send a message — optimistically add user message, then POST to backend
174
+ sendMessage(text) {
175
+ if (!text.trim())
176
+ return;
177
+ // Optimistic: add user message immediately
178
+ const current = this.messages.value;
179
+ this.messages.next([...current, { role: 'user', content: text, createdDate: new Date() }]);
180
+ // Changed: the HTTP response is now the AUTHORITATIVE reply. It used to be discarded entirely and the
181
+ // answer was expected to arrive over SignalR — so with the hub down, or real-time switched off, the
182
+ // server answered and the user saw nothing at all. The request that asked the question is the one that
183
+ // carries the answer; SignalR is now only a cross-device echo, deduped on arrival.
184
+ this.dataService.CallApi({ url: 'agent/message', method: 'post' }, // Changed: was assistant/message
185
+ { text, appName: this.appName }).subscribe({
186
+ next: (res) => {
187
+ this.typing.next(false);
188
+ const reply = res?.data?.message;
189
+ if (reply)
190
+ this.addAssistantMessage(reply);
191
+ this.applySessionInfo(res?.data?.sessionInfo);
192
+ },
193
+ error: () => {
194
+ this.typing.next(false);
195
+ this.addAssistantMessage('Sorry, that could not be sent. Please check your connection and try again.');
196
+ }
197
+ });
198
+ }
199
+ // Changed: one place that appends an assistant reply, so the HTTP answer and the SignalR echo of the same
200
+ // answer cannot both land. Matching on content against the last few messages rather than an id because the
201
+ // two paths carry no shared identifier — and the failure being prevented (the same answer shown twice) is
202
+ // more annoying than the rare case this suppresses (a genuine identical repeat).
203
+ addAssistantMessage(content) {
204
+ const recent = this.messages.value.slice(-4);
205
+ if (recent.some(m => m.role === 'assistant' && m.content === content))
206
+ return;
207
+ this.messages.next([...this.messages.value, {
208
+ role: 'assistant',
209
+ content,
210
+ createdDate: new Date()
211
+ }]);
212
+ }
213
+ // Changed: keeps the widget's idea of "what am I in the middle of" in step with the server's, and draws the
214
+ // boundary when a conversation rolls over so the user can see why the agent stopped referring to earlier
215
+ // messages. Without a visible line, a rollover looks like the agent simply forgetting.
216
+ applySessionInfo(info) {
217
+ if (!info) {
218
+ this.sessionInfo.next(null);
219
+ return;
220
+ }
221
+ if (info.newConversation) {
222
+ if (info.conversationID)
223
+ this.currentConversationId.next(info.conversationID);
224
+ this.messages.next([...this.messages.value, {
225
+ role: 'assistant',
226
+ content: 'New conversation',
227
+ createdDate: new Date(),
228
+ divider: true
229
+ }]);
230
+ this.loadConversations();
231
+ }
232
+ // A finished operation reports no session type — that is the signal to clear the banner
233
+ this.sessionInfo.next(info.sessionType ? info : null);
234
+ }
235
+ // Transcribe a recording WITHOUT invoking the agent — backs the composer's Stop button, so the user can
236
+ // review and edit what was heard before deciding to send.
237
+ transcribeOnly(audio, fileName) {
238
+ const form = new FormData();
239
+ form.append('audio', audio, fileName);
240
+ return this.dataService.CallApi({ url: 'agent/transcribe', method: 'post', isFormData: true }, form);
241
+ }
242
+ // One hands-free spoken turn: audio in, spoken answer out, in a single round trip. The reply comes back
243
+ // twice over — replyText verbatim for the transcript panel, spokenText as actually voiced — because a long
244
+ // list is summarised before it is spoken.
245
+ voiceTurn(audio, fileName) {
246
+ const form = new FormData();
247
+ form.append('audio', audio, fileName);
248
+ form.append('appName', this.appName);
249
+ return this.dataService.CallApi({ url: 'agent/voice-turn', method: 'post', isFormData: true }, form);
250
+ }
251
+ // Puts what was heard into the conversation thread, so a spoken turn reads like a typed one. Only the user
252
+ // side is added here — the agent's reply arrives over SignalR exactly as it does for a typed message, and
253
+ // adding it here as well would show it twice.
254
+ appendSpokenMessage(text) {
255
+ if (!text || !text.trim())
256
+ return;
257
+ this.messages.next([...this.messages.value, { role: 'user', content: text.trim(), createdDate: new Date() }]);
258
+ }
259
+ // Send a composed message — any combination of a staged photo, a voice clip and typed text, in one go.
260
+ // A placeholder user bubble goes up immediately and is swapped for the derived text when the backend
261
+ // replies, which also keeps it above the assistant's answer (that arrives over SignalR mid-request).
262
+ sendComposed(photo, photoName, audio, audioName, caption = '') {
263
+ const hasPhoto = !!(photo && photo.size);
264
+ const hasAudio = !!(audio && audio.size);
265
+ if (!hasPhoto && !hasAudio && !caption.trim())
266
+ return;
267
+ const placeholder = {
268
+ role: 'user',
269
+ content: caption.trim() || (hasAudio ? 'Voice note…' : 'Photo…'),
270
+ createdDate: new Date(),
271
+ pending: true
272
+ };
273
+ this.messages.next([...this.messages.value, placeholder]);
274
+ this.typing.next(true);
275
+ const form = new FormData();
276
+ if (hasPhoto)
277
+ form.append('photo', photo, photoName);
278
+ if (hasAudio)
279
+ form.append('audio', audio, audioName);
280
+ form.append('text', caption.trim());
281
+ form.append('appName', this.appName);
282
+ this.dataService.CallApi({ url: 'agent/media', method: 'post', isFormData: true }, form).subscribe({
283
+ next: (res) => {
284
+ this.typing.next(false);
285
+ if (res?.success && res.data?.inputText) {
286
+ this.replaceMessage(placeholder, { ...placeholder, content: res.data.inputText, pending: false });
287
+ return;
288
+ }
289
+ // Nothing usable came back — drop the placeholder and say why
290
+ this.removeMessage(placeholder);
291
+ this.messages.next([...this.messages.value, {
292
+ role: 'assistant',
293
+ content: res?.message || 'Sorry, I could not process that. Please try again.',
294
+ createdDate: new Date()
295
+ }]);
296
+ },
297
+ error: () => {
298
+ this.typing.next(false);
299
+ this.removeMessage(placeholder);
300
+ this.messages.next([...this.messages.value, {
301
+ role: 'assistant',
302
+ content: 'Sorry, that could not be sent. Please check your connection and try again.',
303
+ createdDate: new Date()
304
+ }]);
305
+ }
306
+ });
307
+ }
308
+ // Changed: kept so existing callers of the single-attachment API keep working
309
+ sendMedia(file, fileName, kind, caption = '') {
310
+ if (kind === 'voice')
311
+ this.sendComposed(null, '', file, fileName, caption);
312
+ else
313
+ this.sendComposed(file, fileName, null, '', caption);
314
+ }
315
+ // Swap a message in place so its position in the thread is preserved
316
+ replaceMessage(target, updated) {
317
+ const msgs = this.messages.value.slice();
318
+ const index = msgs.indexOf(target);
319
+ if (index < 0)
320
+ return;
321
+ msgs[index] = updated;
322
+ this.messages.next(msgs);
323
+ }
324
+ removeMessage(target) {
325
+ this.messages.next(this.messages.value.filter(m => m !== target));
326
+ }
327
+ // Start a new conversation — only when user explicitly clicks "New conversation"
328
+ newConversation() {
329
+ this.messages.next([]);
330
+ this.currentConversationId.next(null);
331
+ this.dataService.CallApi({ url: 'agent/conversations', method: 'post' }, { appName: this.appName }).subscribe(res => {
332
+ // DELIBERATELY SILENT (WS-6 Phase 4 triage). The UI has already done the thing the user asked for —
333
+ // messages cleared, id nulled, a blank thread on screen — before this call is even made. All the server
334
+ // round trip adds is a pre-allocated id, and sendMessage() creates one anyway when there is none. So a
335
+ // failure here changes nothing the user can see or do.
336
+ if (res.success && res.data) {
337
+ this.currentConversationId.next(res.data.conversationID);
338
+ }
339
+ });
340
+ }
341
+ // Delete a conversation
342
+ deleteConversation(id) {
343
+ this.dataService.CallApi({ url: `agent/conversations/${id}/delete`, method: 'post' }).subscribe(res => {
344
+ // CONVERTED (WS-6 Phase 4 triage). A delete the user pressed. Failing left the conversation sitting in
345
+ // the list exactly as it was, which reads as "the button did nothing" — and the answer to a button that
346
+ // did nothing is to press it again. Note the comment on the URL below: this endpoint has already been
347
+ // wrong once (a 405 on every call), and it was silence that let that survive.
348
+ if (!res.success)
349
+ this.apiErrorService.presentAppFailure(res, 'action', `agent/conversations/${id}/delete`);
350
+ if (res.success) {
351
+ this.loadConversations();
352
+ if (this.currentConversationId.value === id) {
353
+ this.messages.next([]);
354
+ this.currentConversationId.next(null);
355
+ }
356
+ }
357
+ });
358
+ }
359
+ // Toggle chat window open/closed
360
+ toggle() {
361
+ const opening = !this.isOpen.value;
362
+ this.isOpen.next(opening);
363
+ if (opening && !this.hasLoadedInitial) {
364
+ this.hasLoadedInitial = true;
365
+ this.restoreLastConversation(); // Changed: Load previous chat on first open
366
+ }
367
+ }
368
+ open() {
369
+ this.isOpen.next(true);
370
+ if (!this.hasLoadedInitial) {
371
+ this.hasLoadedInitial = true;
372
+ this.restoreLastConversation();
373
+ }
374
+ }
375
+ close() { this.isOpen.next(false); }
376
+ // Changed: Fetch conversation list and load the most recent one's messages
377
+ restoreLastConversation() {
378
+ this.dataService.CallApi({ url: 'agent/conversations' }).subscribe(res => {
379
+ if (res.success && res.data?.length > 0) {
380
+ this.conversations.next(res.data);
381
+ const latest = res.data[0]; // Backend returns ordered by CreatedDate desc
382
+ this.currentConversationId.next(latest.conversationID);
383
+ this.loadMessages(latest.conversationID);
384
+ }
385
+ });
386
+ }
387
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, deps: [{ token: i1$1.DataServiceLib }, { token: i1$1.SignalRService }, { token: i1$1.ApiErrorService }], target: i0.ɵɵFactoryTarget.Injectable }); }
388
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, providedIn: 'root' }); }
389
+ }
390
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, decorators: [{
391
+ type: Injectable,
392
+ args: [{ providedIn: 'root' }]
393
+ }], ctorParameters: () => [{ type: i1$1.DataServiceLib }, { type: i1$1.SignalRService }, { type: i1$1.ApiErrorService }] });
394
+
395
+ // Floating chat widget component for in-app Agent (renamed from AssistantComponent)
396
+ class AgentComponent {
397
+ // Changed: the floating FAB is suppressed in page mode (Phase 1), on mobile, and on the chat page's own
398
+ // route at any width — otherwise it hovers over the very page it would open.
399
+ get showFab() {
400
+ return !this.pageMode && !this.smallScreen && !this.onAgentRoute;
401
+ }
402
+ // Changed: the floating window follows the FAB. Page mode renders regardless of isOpen; the floating
403
+ // instance additionally refuses to render wherever its FAB is gone, so there are never two chat surfaces.
404
+ get showWindow() {
405
+ return this.pageMode || (this.isOpen && !this.smallScreen && !this.onAgentRoute);
406
+ }
407
+ static { this.MAX_RECORD_SECONDS = 120; } // Auto-stop so a forgotten recording can't run away
408
+ static { this.SETTLE_MS = 450; } // let the speakers finish before trusting the mic again
409
+ static { this.CALIBRATION_MS = 600; } // listen to the room before listening to the person
410
+ static { this.CONTINUE_RATIO = 0.55; } // hysteresis: it takes less to STAY in speech than to enter it
411
+ static { this.MAX_UTTERANCE_MS = 30000; } // a noisy cab must not record forever
412
+ static { this.IDLE_MS = 12000; } // no speech this long → don't upload silence, listen again
413
+ static { this.MAX_SILENT_MS = 180000; } // ...but give up after three minutes of nothing at all
414
+ constructor(agentService, signalRService, zone, router, breakpointObserver) {
415
+ this.agentService = agentService;
416
+ this.signalRService = signalRService;
417
+ this.zone = zone;
418
+ this.router = router;
419
+ this.breakpointObserver = breakpointObserver;
420
+ // Changed: one component, two presentations. false = the floating widget (FAB + fixed window, unchanged);
421
+ // true = the routed chat page (home/agent) — no FAB, window always rendered, no outside-click close, no
422
+ // minimize. Only presentation differs; all chat behaviour stays shared through AgentService.
423
+ this.pageMode = false;
424
+ // Changed: mobile retires the floating widget entirely — the bottom tab bar's Chat tab is the entry point
425
+ // and home/agent is the surface. Both flags only ever gate the FLOATING presentation; page mode ignores them.
426
+ this.smallScreen = false; // Changed: same (max-width: 600px) breakpoint nav-menu uses — one source of truth
427
+ this.onAgentRoute = false; // Changed: true while the routed chat page owns the screen
428
+ this.messages = [];
429
+ this.conversations = []; // Changed: switcher list
430
+ this.currentConversationId = null;
431
+ this.showConversations = false; // Changed: the switcher is a dropdown, not a permanent panel — the window is small
432
+ this.sessionInfo = null; // Changed: drives the "you are creating a Customer" banner
433
+ this.isOpen = false;
434
+ this.isTyping = false;
435
+ this.inputText = '';
436
+ this.greeting = '';
437
+ this.suggestedQuestions = [];
438
+ this.isOnline = false;
439
+ this.isRecording = false; // Changed: voice note capture state
440
+ this.recordSeconds = 0;
441
+ this.mediaError = ''; // Changed: shown inline when the mic is blocked or unavailable
442
+ this.pendingPhoto = null; // Changed: photo is staged, not sent — the user adds a caption first
443
+ this.isTranscribing = false; // Changed: Stop pressed, waiting for the transcript to land in the text box
444
+ this.subs = [];
445
+ this.shouldScroll = false;
446
+ this.viewportHandler = null; // Changed: visualViewport resize handler reference
447
+ // Changed: voice note recording internals
448
+ this.recorder = null;
449
+ this.mediaStream = null;
450
+ this.chunks = [];
451
+ this.recordTimer = null;
452
+ this.stopMode = 'send'; // Changed: what to do with the clip once stopped
453
+ // ---------------------------------------------------------------------------------------------------
454
+ // Voice mode — hands-free. Enter once, then speak and listen in a loop until you exit. Built for someone
455
+ // driving or with their hands full, so nothing between entry and exit requires touch or sight.
456
+ //
457
+ // The session is deliberately HALF-DUPLEX: the microphone is closed whenever audio is playing, and
458
+ // playback finishes before it reopens. That single property removes echo cancellation, acoustic feedback
459
+ // and interrupt arbitration entirely. Preserve it — barge-in would be a re-architecture, not an increment.
460
+ // ---------------------------------------------------------------------------------------------------
461
+ this.voiceMode = false;
462
+ // 'starting' is the room-calibration window. It exists because showing "Listening" during it was a lie —
463
+ // the microphone is open but the detector is still measuring the noise floor, so anything said then is
464
+ // measured AS noise and raises the bar against the speaker's own voice.
465
+ this.voiceState = 'idle';
466
+ this.voiceTranscript = ''; // what it heard on the last turn
467
+ this.voiceReply = ''; // what it answered, verbatim (the spoken version is shortened)
468
+ this.voiceError = '';
469
+ this.voiceLevel = 0; // 0..1, drives the level meter so the user can see the mic is live
470
+ // Tunable because a vehicle cabin is not a desk. These are fields rather than constants so they can be
471
+ // adjusted against a real environment without a rebuild.
472
+ this.voiceThreshold = 0.01; // absolute floor — the room can raise this, never lower it
473
+ this.noiseMultiplier = 3; // speech must be this many times the measured room noise
474
+ this.maxThreshold = 0.08; // ...but never so high that a normal voice cannot clear it
475
+ this.onsetMs = 250; // sustained loudness needed before it counts as speech at all
476
+ this.silenceMs = 2200; // quiet needed to call the utterance finished
477
+ this.minSpeechMs = 400; // anything shorter than this was a noise, not a sentence
478
+ // What calibration actually settled on, exposed so it can be inspected while running
479
+ this.noiseFloor = 0;
480
+ this.activeThreshold = 0.03;
481
+ this.calibrationEndsAt = 0;
482
+ this.calibrationSamples = [];
483
+ this.loudSince = 0;
484
+ this.voiceRecorder = null;
485
+ this.voiceStream = null;
486
+ this.voiceChunks = [];
487
+ this.audioContext = null;
488
+ this.analyser = null;
489
+ this.vadTimer = null;
490
+ this.player = null;
491
+ this.speechStartedAt = 0;
492
+ this.lastLoudAt = 0;
493
+ this.listenStartedAt = 0;
494
+ this.silentSince = 0;
495
+ this.abandonUtterance = false; // true when the clip should be dropped rather than uploaded
496
+ // ---------------------------------------------------------------------------------------------------
497
+ // Voice mode
498
+ // ---------------------------------------------------------------------------------------------------
499
+ // Tap versus hold on the single mic button. The composer row is full — attach, text, mic, send — so voice
500
+ // mode gets a gesture rather than a fifth control that would squeeze the text field on a phone. Same idea
501
+ // as holding the mic in WhatsApp: the button keeps its obvious job, the second mode hides behind the hold.
502
+ this.micHeld = false;
503
+ this.micHoldTimer = null;
504
+ this.micHoldFired = false;
505
+ } // Changed: Router + BreakpointObserver gate the floating presentation
506
+ // Every voice state change goes through here. The states are set from audio-element events, a MediaRecorder
507
+ // callback and a sampling timer, and those did not reliably trigger a repaint — the panel was showing
508
+ // "Listening" while the machine was already capturing, thinking, or speaking aloud. That is worse than no
509
+ // indicator at all: someone who cannot look at the screen is being told the wrong thing about whose turn it
510
+ // is. Re-entering the Angular zone forces the view to catch up immediately.
511
+ setVoiceState(state) {
512
+ if (this.voiceState === state)
513
+ return;
514
+ this.zone.run(() => { this.voiceState = state; });
515
+ }
516
+ get agentName() {
517
+ return this.agentService.agentName;
518
+ }
519
+ ngOnInit() {
520
+ this.agentService.loadConfig();
521
+ this.initMobileViewport(); // Changed: Listen for keyboard resize on mobile
522
+ this.subs.push(this.agentService.messages$.subscribe(msgs => {
523
+ this.messages = msgs;
524
+ this.shouldScroll = true;
525
+ }), this.agentService.isOpen$.subscribe(open => {
526
+ this.isOpen = open;
527
+ if (open)
528
+ this.focusInput();
529
+ // Changed: the widget can be closed by an outside click while a voice session is running. The
530
+ // component survives that (only the window is *ngIf'd away), so without this the microphone would
531
+ // stay live behind a hidden window with no way to reach the exit button.
532
+ // Changed: page mode ignores isOpen for visibility, so a close() from the floating window elsewhere
533
+ // must not tear down a voice session that is still on screen on the page.
534
+ else if (this.voiceMode && !this.pageMode)
535
+ this.stopVoiceMode();
536
+ }), this.agentService.typing$.subscribe(typing => {
537
+ this.isTyping = typing;
538
+ this.shouldScroll = true;
539
+ }), this.agentService.greeting$.subscribe(g => this.greeting = g), this.agentService.suggestedQuestions$.subscribe(q => this.suggestedQuestions = q),
540
+ // Changed: the conversation switcher and the session banner both come from the service
541
+ this.agentService.conversations$.subscribe(list => this.conversations = list), this.agentService.currentConversationId$.subscribe(id => this.currentConversationId = id), this.agentService.sessionInfo$.subscribe(info => this.sessionInfo = info), this.signalRService.dataHubConnected$.subscribe(connected => this.isOnline = connected),
542
+ // Changed: crossing into mobile must not strand an open floating window behind the new tab bar. Only
543
+ // the FLOATING instance closes it — the page instance never mutates the shared isOpen (see fsd Risk 2).
544
+ this.breakpointObserver.observe(['(max-width: 600px)']).subscribe((result) => {
545
+ this.smallScreen = result.matches;
546
+ if (result.matches && !this.pageMode && this.isOpen)
547
+ this.agentService.close();
548
+ }),
549
+ // Changed: the routed chat page renders its own instance, so the floating one steps aside on that URL
550
+ this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe((event) => {
551
+ this.onAgentRoute = this.isAgentRoute(event.urlAfterRedirects || event.url);
552
+ if (this.onAgentRoute && !this.pageMode && this.isOpen)
553
+ this.agentService.close();
554
+ }));
555
+ this.onAgentRoute = this.isAgentRoute(this.router.url); // Changed: NavigationEnd has usually already fired by now
556
+ this.agentService.loadConversations(); // Changed: the switcher needs the list before it is opened
557
+ }
558
+ // Changed: 'home/agent' is mounted through the shared home routing module, so the reliable test is the
559
+ // trailing segment rather than a whole-URL match that would miss a consumer app's own route prefix.
560
+ isAgentRoute(url) {
561
+ const path = (url || '').split('?')[0].split(';')[0].split('#')[0].replace(/\/+$/, '');
562
+ return path.endsWith('/agent');
563
+ }
564
+ // Reads as "creating a Customer" rather than "Create a Customer" in the banner
565
+ get sessionVerb() {
566
+ switch (this.sessionInfo?.sessionType) {
567
+ case 'Create': return 'Creating';
568
+ case 'Edit': return 'Editing';
569
+ case 'Delete': return 'Deleting';
570
+ case 'BatchPost': return 'Batch posting';
571
+ default: return 'Working on';
572
+ }
573
+ }
574
+ // Changed: switching threads. The agent has always kept several conversations per user, but the widget
575
+ // could only ever show the newest one — earlier threads existed and were unreachable.
576
+ selectConversation(id) {
577
+ if (id === this.currentConversationId)
578
+ return;
579
+ this.showConversations = false;
580
+ this.agentService.loadMessages(id);
581
+ }
582
+ toggleConversationList() {
583
+ this.showConversations = !this.showConversations;
584
+ if (this.showConversations)
585
+ this.agentService.loadConversations();
586
+ }
587
+ ngAfterViewChecked() {
588
+ if (this.shouldScroll) {
589
+ this.scrollToBottom();
590
+ this.shouldScroll = false;
591
+ }
592
+ }
593
+ toggleChat() {
594
+ this.agentService.toggle();
595
+ }
596
+ // Changed: Close chat when clicking outside the chat window and FAB button
597
+ onDocumentClick(event) {
598
+ if (this.pageMode)
599
+ return; // Changed: a page does not close when you click the page — outside-click close is float-only
600
+ if (!this.isOpen)
601
+ return;
602
+ // Changed: match on composedPath() instead of closest(). A control that re-renders the widget removes
603
+ // itself from the DOM before this handler runs — the mic button lives inside *ngIf="!isRecording", so
604
+ // starting a recording detaches it. closest() on a detached node finds nothing, and the widget used to
605
+ // close itself the instant recording began, leaving the mic live behind a hidden window.
606
+ //
607
+ // cdk-overlay-container is checked for the same reason: Material renders menus, tooltips and dialogs in
608
+ // an overlay attached to <body>, OUTSIDE .agent-window. Without this, picking an item from the attach
609
+ // menu closed the whole chat. Any overlay open on top of the widget counts as being inside it.
610
+ const path = (event.composedPath ? event.composedPath() : []);
611
+ if (path.some(el => el?.classList?.contains('agent-window')
612
+ || el?.classList?.contains('agent-fab')
613
+ || el?.classList?.contains('cdk-overlay-container')))
614
+ return;
615
+ // Fallback for browsers without composedPath, and for targets still attached
616
+ const target = event.target;
617
+ if (target?.isConnected && (target.closest('.agent-window') || target.closest('.agent-fab')))
618
+ return;
619
+ // A detached target means the click came from a control that just re-rendered — i.e. inside the widget
620
+ if (target && !target.isConnected)
621
+ return;
622
+ this.agentService.close();
623
+ }
624
+ // Changed: sends whatever is staged — typed text, an attached photo, or both. A recording in progress is
625
+ // stopped and folded into the same send rather than being lost.
626
+ sendMessage() {
627
+ if (this.isRecording) {
628
+ this.stopMode = 'send';
629
+ this.recorder?.stop();
630
+ return;
631
+ }
632
+ const caption = this.inputText.trim();
633
+ if (!caption && !this.pendingPhoto)
634
+ return;
635
+ if (this.pendingPhoto) {
636
+ this.agentService.sendComposed(this.pendingPhoto, this.pendingPhoto.name || 'photo.jpg', null, '', caption);
637
+ this.pendingPhoto = null;
638
+ }
639
+ else {
640
+ this.agentService.sendMessage(caption);
641
+ }
642
+ this.inputText = '';
643
+ }
644
+ // Changed: true when there is anything worth sending
645
+ get canSend() {
646
+ return !!this.inputText.trim() || !!this.pendingPhoto;
647
+ }
648
+ clearPhoto() {
649
+ this.pendingPhoto = null;
650
+ }
651
+ sendSuggested(question) {
652
+ this.agentService.sendMessage(question);
653
+ }
654
+ onKeydown(event) {
655
+ if (event.key === 'Enter' && !event.shiftKey) {
656
+ event.preventDefault();
657
+ this.sendMessage();
658
+ }
659
+ }
660
+ newConversation() {
661
+ this.agentService.newConversation();
662
+ }
663
+ // Changed: mm:ss shown while recording
664
+ get recordDisplay() {
665
+ const mins = Math.floor(this.recordSeconds / 60);
666
+ const secs = this.recordSeconds % 60;
667
+ return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
668
+ }
669
+ // Changed: voice note capture — the clip goes straight to the backend, which transcribes it and feeds the
670
+ // text into the agent exactly as Telegram and WhatsApp voice notes are handled
671
+ async startRecording() {
672
+ if (this.isRecording)
673
+ return;
674
+ this.mediaError = '';
675
+ if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
676
+ this.mediaError = 'Voice notes are not supported in this browser.';
677
+ return;
678
+ }
679
+ try {
680
+ this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
681
+ }
682
+ catch {
683
+ this.mediaError = 'Microphone access was blocked. Allow it in your browser settings to send voice notes.';
684
+ return;
685
+ }
686
+ this.chunks = [];
687
+ this.stopMode = 'send';
688
+ const mimeType = this.pickAudioMimeType();
689
+ this.recorder = mimeType ? new MediaRecorder(this.mediaStream, { mimeType }) : new MediaRecorder(this.mediaStream);
690
+ this.recorder.ondataavailable = e => { if (e.data && e.data.size > 0)
691
+ this.chunks.push(e.data); };
692
+ this.recorder.onstop = () => this.finishRecording();
693
+ this.recorder.start();
694
+ this.isRecording = true;
695
+ this.recordSeconds = 0;
696
+ this.recordTimer = setInterval(() => {
697
+ this.recordSeconds++;
698
+ if (this.recordSeconds >= AgentComponent.MAX_RECORD_SECONDS)
699
+ this.stopRecording();
700
+ }, 1000);
701
+ }
702
+ // Changed: send immediately — the clip goes with whatever else is staged, no review step
703
+ stopRecording() {
704
+ if (!this.isRecording || !this.recorder)
705
+ return;
706
+ this.stopMode = 'send';
707
+ this.recorder.stop(); // finishRecording() runs from onstop
708
+ }
709
+ // Changed: stop and transcribe INTO the text box so the user can read it back, edit it, and send when ready
710
+ stopAndTranscribe() {
711
+ if (!this.isRecording || !this.recorder)
712
+ return;
713
+ this.stopMode = 'transcribe';
714
+ this.recorder.stop();
715
+ }
716
+ cancelRecording() {
717
+ if (!this.isRecording || !this.recorder)
718
+ return;
719
+ this.stopMode = 'cancel';
720
+ this.recorder.stop();
721
+ }
722
+ static { this.MIC_HOLD_MS = 500; } // long enough not to fire on a normal tap
723
+ onMicPressStart() {
724
+ if (this.voiceMode || this.isRecording)
725
+ return;
726
+ this.micHeld = true;
727
+ this.micHoldFired = false;
728
+ this.micHoldTimer = setTimeout(() => {
729
+ this.micHeld = false;
730
+ this.micHoldFired = true; // swallows the click that follows the release
731
+ if (navigator.vibrate)
732
+ navigator.vibrate(30); // confirms the mode switch without needing to look
733
+ this.startVoiceMode();
734
+ }, AgentComponent.MIC_HOLD_MS);
735
+ }
736
+ onMicPressEnd() {
737
+ this.micHeld = false;
738
+ if (this.micHoldTimer) {
739
+ clearTimeout(this.micHoldTimer);
740
+ this.micHoldTimer = null;
741
+ }
742
+ }
743
+ // Runs after pointerup. A hold has already acted, so the click that follows it must do nothing.
744
+ onMicClick() {
745
+ if (this.micHoldFired) {
746
+ this.micHoldFired = false;
747
+ return;
748
+ }
749
+ this.startRecording();
750
+ }
751
+ // Entering is the ONLY user gesture in the whole session, so everything that needs one happens here: iOS
752
+ // will not let audio play later unless an AudioContext was created and resumed inside a real tap.
753
+ async startVoiceMode() {
754
+ if (this.voiceMode)
755
+ return;
756
+ this.voiceError = '';
757
+ this.voiceTranscript = '';
758
+ this.voiceReply = '';
759
+ if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
760
+ this.voiceError = 'Voice mode is not supported in this browser.';
761
+ return;
762
+ }
763
+ if (this.isRecording)
764
+ this.cancelRecording(); // never run both capture paths at once
765
+ try {
766
+ const Ctx = window.AudioContext || window.webkitAudioContext;
767
+ this.audioContext = new Ctx();
768
+ if (this.audioContext.state === 'suspended')
769
+ await this.audioContext.resume();
770
+ }
771
+ catch {
772
+ this.voiceError = 'Audio could not be started in this browser.';
773
+ return;
774
+ }
775
+ this.voiceMode = true;
776
+ this.silentSince = Date.now();
777
+ await this.beginListening();
778
+ }
779
+ // One tap out, from anywhere in the loop. Everything is torn down: recorder, microphone, playback, timers.
780
+ stopVoiceMode() {
781
+ this.voiceMode = false;
782
+ this.setVoiceState('idle');
783
+ this.voiceLevel = 0;
784
+ this.abandonUtterance = true;
785
+ this.stopVoiceCapture();
786
+ this.stopPlayback();
787
+ if (this.audioContext) {
788
+ try {
789
+ this.audioContext.close();
790
+ }
791
+ catch { }
792
+ this.audioContext = null;
793
+ }
794
+ }
795
+ // Opens the mic and waits. The clip records from the moment the mic opens, so the first syllable is never
796
+ // clipped — the detector decides afterwards whether anything worth sending was actually said.
797
+ async beginListening() {
798
+ if (!this.voiceMode)
799
+ return;
800
+ try {
801
+ // These three constraints were all settled by measurement on a real machine, not by reasoning:
802
+ //
803
+ // echoCancellation ON — without it the microphone hears the agent's OWN reply from the speakers and
804
+ // answers itself; a spoken reply came back as the next command.
805
+ // noiseSuppression OFF — tried ON to fight fan noise and it crushed the voice instead: a normal
806
+ // speaking voice fell from 0.15 to 0.026 RMS, barely grazing the threshold, and
807
+ // no utterance was ever captured. Steady noise is already handled better by the
808
+ // per-cycle noise-floor calibration and the sustained-onset rule below, neither
809
+ // of which costs any signal.
810
+ // autoGainControl ON — also tried OFF (to keep levels stable for calibration) and that made it worse
811
+ // still, 0.008 RMS. A drifting floor beats a microphone that cannot hear you.
812
+ this.voiceStream = await navigator.mediaDevices.getUserMedia({
813
+ audio: {
814
+ echoCancellation: true,
815
+ noiseSuppression: false,
816
+ autoGainControl: true
817
+ }
818
+ });
819
+ }
820
+ catch {
821
+ this.voiceError = 'Microphone access was blocked. Allow it in your browser settings to use voice mode.';
822
+ this.stopVoiceMode();
823
+ return;
824
+ }
825
+ if (!this.voiceMode) { // exited while the permission prompt was up
826
+ this.voiceStream.getTracks().forEach(t => t.stop());
827
+ this.voiceStream = null;
828
+ return;
829
+ }
830
+ this.voiceChunks = [];
831
+ this.abandonUtterance = false;
832
+ this.speechStartedAt = 0;
833
+ this.listenStartedAt = Date.now();
834
+ this.lastLoudAt = 0;
835
+ this.loudSince = 0;
836
+ // Re-measure the room on every cycle: an engine starts, a window opens, the air-con changes note
837
+ this.calibrationSamples = [];
838
+ this.calibrationEndsAt = Date.now() + AgentComponent.CALIBRATION_MS;
839
+ const mimeType = this.pickAudioMimeType();
840
+ this.voiceRecorder = mimeType
841
+ ? new MediaRecorder(this.voiceStream, { mimeType })
842
+ : new MediaRecorder(this.voiceStream);
843
+ this.voiceRecorder.ondataavailable = e => { if (e.data && e.data.size > 0)
844
+ this.voiceChunks.push(e.data); };
845
+ this.voiceRecorder.onstop = () => this.onUtteranceCaptured();
846
+ this.voiceRecorder.start();
847
+ // NOT 'listening' yet — the room is being measured first. The "your turn" tone and the Listening label
848
+ // both wait for calibration to finish, so the tone always means the same thing: start talking now.
849
+ this.setVoiceState('starting');
850
+ this.startVad();
851
+ }
852
+ // Voice activity detection: RMS over a short window, no dependencies. Speech starts when it goes loud, ends
853
+ // after a run of quiet — which is what makes the turn hands-free, since nothing has to be pressed to stop.
854
+ startVad() {
855
+ if (!this.audioContext || !this.voiceStream)
856
+ return;
857
+ const source = this.audioContext.createMediaStreamSource(this.voiceStream);
858
+ this.analyser = this.audioContext.createAnalyser();
859
+ this.analyser.fftSize = 1024;
860
+ source.connect(this.analyser);
861
+ const samples = new Uint8Array(this.analyser.fftSize);
862
+ this.vadTimer = setInterval(() => {
863
+ if (!this.analyser)
864
+ return;
865
+ this.analyser.getByteTimeDomainData(samples);
866
+ let sum = 0;
867
+ for (let i = 0; i < samples.length; i++) {
868
+ const deviation = (samples[i] - 128) / 128;
869
+ sum += deviation * deviation;
870
+ }
871
+ const rms = Math.sqrt(sum / samples.length);
872
+ this.voiceLevel = Math.min(1, rms / 0.15); // meter only — the decision uses rms directly
873
+ this.evaluateVad(rms);
874
+ }, 50);
875
+ }
876
+ evaluateVad(rms) {
877
+ const now = Date.now();
878
+ // Phase 0 — listen to the ROOM before listening to the person. A fixed threshold cannot know whether it
879
+ // is sitting in a quiet office or next to a fan, so the first moments of every listening cycle measure the
880
+ // ambient level and the speech threshold is set above it. This is what stops a fan being heard as speech.
881
+ if (this.calibrationEndsAt) {
882
+ if (now < this.calibrationEndsAt) {
883
+ this.calibrationSamples.push(rms);
884
+ return;
885
+ }
886
+ this.applyCalibration();
887
+ // Only NOW is it actually listening to the person, so this is where the label flips and the "your turn"
888
+ // tone plays. The listening clock restarts here too — the calibration window must not count against the
889
+ // idle timeout, or a slow starter loses part of their thinking time.
890
+ this.setVoiceState('listening');
891
+ this.listenStartedAt = now;
892
+ this.earcon('ready');
893
+ }
894
+ const enterThreshold = this.activeThreshold;
895
+ // Hysteresis: once someone is speaking it takes LESS to keep them speaking. Without this, the natural dip
896
+ // between words reads as the end of the sentence.
897
+ const stayThreshold = enterThreshold * AgentComponent.CONTINUE_RATIO;
898
+ if (!this.speechStartedAt) {
899
+ // Not speaking yet. Loudness must be SUSTAINED to count — a door, a keyboard, a fan surge is loud for
900
+ // an instant, and a single loud sample used to be enough to start capturing.
901
+ if (rms >= enterThreshold) {
902
+ if (!this.loudSince)
903
+ this.loudSince = now;
904
+ if (now - this.loudSince >= this.onsetMs) {
905
+ this.speechStartedAt = this.loudSince; // backdated, so the first word is not clipped
906
+ this.lastLoudAt = now;
907
+ this.setVoiceState('capturing');
908
+ this.silentSince = 0; // they are talking, so the give-up clock stops
909
+ this.earcon('gotyou'); // "I can hear you" — answers the question the old single beep left open
910
+ }
911
+ }
912
+ else {
913
+ this.loudSince = 0; // a blip, not a sentence
914
+ }
915
+ }
916
+ else {
917
+ // Mid-sentence. Pauses and breaths are expected — only a long quiet ends the turn.
918
+ if (rms >= stayThreshold) {
919
+ this.lastLoudAt = now;
920
+ }
921
+ else if (now - this.lastLoudAt >= this.silenceMs) {
922
+ // Too short to be a sentence? Then it was a noise — throw it away instead of asking the model to
923
+ // find words in it, which is where "Hallo!" and other invented transcripts come from.
924
+ const spoken = this.lastLoudAt - this.speechStartedAt;
925
+ this.endUtterance(spoken < this.minSpeechMs);
926
+ return;
927
+ }
928
+ if (now - this.speechStartedAt >= AgentComponent.MAX_UTTERANCE_MS)
929
+ this.endUtterance(false);
930
+ return;
931
+ }
932
+ // Nothing said yet — don't upload silence. Reopen the mic instead of ending the session, because someone
933
+ // driving may simply not be ready to talk yet.
934
+ // The speechStartedAt guard matters: the branch above can START speech on this very sample, and without
935
+ // it the idle check below would immediately throw that fresh utterance away.
936
+ if (!this.speechStartedAt && now - this.listenStartedAt >= AgentComponent.IDLE_MS) {
937
+ if (!this.silentSince)
938
+ this.silentSince = now;
939
+ if (now - this.silentSince >= AgentComponent.MAX_SILENT_MS) {
940
+ this.voiceError = 'Voice mode ended after a long silence.';
941
+ this.stopVoiceMode();
942
+ return;
943
+ }
944
+ this.endUtterance(true); // drop this clip and listen again
945
+ }
946
+ }
947
+ // Turns the room measurement into a working threshold. Uses a high percentile rather than the average so a
948
+ // steady hum sets the floor without one loud knock dragging it up, then requires speech to be several times
949
+ // that. The configured voiceThreshold acts as a floor only — a noisy room can raise the bar, never lower it.
950
+ applyCalibration() {
951
+ this.calibrationEndsAt = 0;
952
+ const samples = this.calibrationSamples.slice().sort((a, b) => a - b);
953
+ this.calibrationSamples = [];
954
+ if (!samples.length) {
955
+ this.activeThreshold = this.voiceThreshold;
956
+ return;
957
+ }
958
+ const percentile = samples[Math.min(samples.length - 1, Math.floor(samples.length * 0.9))];
959
+ this.noiseFloor = percentile;
960
+ // Clamped at BOTH ends. The upper clamp is not theoretical: calibrating while someone was still talking
961
+ // measured a "room" of 0.17 and set the bar at 0.52 — around three times louder than any normal voice, so
962
+ // the microphone was open and permanently deaf. A ceiling means the worst case is over-sensitivity, which
963
+ // the onset rule and minimum-utterance check still catch, rather than total silence.
964
+ const scaled = percentile * this.noiseMultiplier;
965
+ this.activeThreshold = Math.min(this.maxThreshold, Math.max(this.voiceThreshold, scaled));
966
+ }
967
+ // Ends the current capture. `abandon` means the clip is thrown away rather than sent.
968
+ endUtterance(abandon) {
969
+ this.abandonUtterance = abandon;
970
+ if (this.vadTimer) {
971
+ clearInterval(this.vadTimer);
972
+ this.vadTimer = null;
973
+ }
974
+ this.voiceLevel = 0;
975
+ if (this.voiceRecorder && this.voiceRecorder.state !== 'inactive') {
976
+ this.voiceRecorder.stop(); // onUtteranceCaptured() runs from onstop
977
+ }
978
+ else {
979
+ this.onUtteranceCaptured();
980
+ }
981
+ }
982
+ // The microphone is released HERE, before anything is sent — so it is provably shut for the whole of
983
+ // thinking and speaking, which is what keeps the session half-duplex.
984
+ onUtteranceCaptured() {
985
+ const chunks = this.voiceChunks;
986
+ const mimeType = this.voiceRecorder?.mimeType || 'audio/webm';
987
+ this.releaseMic();
988
+ if (!this.voiceMode)
989
+ return;
990
+ if (this.abandonUtterance || !chunks.length) {
991
+ this.voiceChunks = [];
992
+ this.beginListening(); // nothing worth sending — straight back to listening
993
+ return;
994
+ }
995
+ this.voiceChunks = [];
996
+ this.setVoiceState('thinking');
997
+ const blob = new Blob(chunks, { type: mimeType });
998
+ const fileName = `voice-turn.${this.extensionFor(mimeType)}`;
999
+ this.agentService.voiceTurn(blob, fileName).subscribe({
1000
+ next: (res) => this.onTurnAnswered(res),
1001
+ error: () => this.speakThenListen('', 'Sorry, that did not get through. Please try again.')
1002
+ });
1003
+ }
1004
+ onTurnAnswered(res) {
1005
+ if (!this.voiceMode)
1006
+ return;
1007
+ const data = res?.data || {};
1008
+ if (data.transcript) {
1009
+ this.voiceTranscript = data.transcript;
1010
+ this.agentService.appendSpokenMessage(data.transcript); // the spoken turn joins the normal thread
1011
+ }
1012
+ const reply = data.replyText || res?.message || 'Sorry, something went wrong.';
1013
+ this.speakThenListen(data.audioBase64, reply, data.audioMimeType);
1014
+ }
1015
+ // Plays the answer, then reopens the microphone STRICTLY on 'ended'. Never before — that ordering is the
1016
+ // half-duplex guarantee, and it is why no echo cancellation is needed anywhere in this feature.
1017
+ speakThenListen(audioBase64, replyText, mimeType = 'audio/mpeg') {
1018
+ this.voiceReply = replyText;
1019
+ this.setVoiceState('speaking');
1020
+ if (!audioBase64) {
1021
+ // No audio came back (a network failure, or speech synthesis was unavailable server-side). Say it with
1022
+ // the browser's own voice rather than leaving someone who cannot look at the screen in silence.
1023
+ this.speakFallback(replyText);
1024
+ return;
1025
+ }
1026
+ this.stopPlayback();
1027
+ this.player = new Audio(`data:${mimeType || 'audio/mpeg'};base64,${audioBase64}`);
1028
+ // Keep the label honest: 'speaking' is re-asserted when sound actually starts, not merely when it was
1029
+ // requested, so the indicator never claims to be doing something the ear cannot hear yet.
1030
+ this.player.onplaying = () => { if (this.voiceMode)
1031
+ this.setVoiceState('speaking'); };
1032
+ this.player.onended = () => this.afterSpeaking();
1033
+ this.player.onerror = () => this.afterSpeaking();
1034
+ this.player.play().catch(() => this.afterSpeaking()); // autoplay refused → don't hang the loop
1035
+ }
1036
+ // Browser speech synthesis, used only when the server could not produce audio.
1037
+ // Chrome's utterance.onend is unreliable for long text — it can fire early or not at all, which is what made
1038
+ // the panel say "Listening" while a voice was still talking. So the end of speech is confirmed by POLLING
1039
+ // speechSynthesis.speaking rather than by trusting the event.
1040
+ speakFallback(text) {
1041
+ const synth = window.speechSynthesis;
1042
+ if (!synth || !text) {
1043
+ this.afterSpeaking();
1044
+ return;
1045
+ }
1046
+ try {
1047
+ const utterance = new window.SpeechSynthesisUtterance(text);
1048
+ synth.speak(utterance);
1049
+ const started = Date.now();
1050
+ const watch = setInterval(() => {
1051
+ const stillTalking = synth.speaking || synth.pending;
1052
+ const tooLong = Date.now() - started > 60000; // never wedge the loop on a stuck engine
1053
+ if (!this.voiceMode) {
1054
+ clearInterval(watch);
1055
+ return;
1056
+ }
1057
+ if (stillTalking && !tooLong) {
1058
+ this.setVoiceState('speaking');
1059
+ return;
1060
+ }
1061
+ clearInterval(watch);
1062
+ this.afterSpeaking();
1063
+ }, 200);
1064
+ }
1065
+ catch {
1066
+ this.afterSpeaking();
1067
+ }
1068
+ }
1069
+ // 'ended' means the audio element finished, NOT that the room is quiet: the last of it is still leaving the
1070
+ // speakers and reverberating. Reopening instantly meant the mic caught the tail of the agent's own sentence
1071
+ // and, worse, calibrated the noise floor against it. A short settle keeps the loop honest.
1072
+ afterSpeaking() {
1073
+ this.player = null;
1074
+ if (!this.voiceMode)
1075
+ return;
1076
+ setTimeout(() => { if (this.voiceMode)
1077
+ this.beginListening(); }, AgentComponent.SETTLE_MS);
1078
+ }
1079
+ // Three tones, each meaning exactly ONE thing, because "a beep happened" is not information:
1080
+ //
1081
+ // ready — rising two-note, plays when it is ready FOR YOU. "Start talking now."
1082
+ // gotyou — single soft note, plays the moment your speech is "I can hear you, keep going."
1083
+ // detected, so you know you were picked up
1084
+ // taken — falling two-note, plays when the mic closes and it "I have it, I'm working on it."
1085
+ // starts working
1086
+ //
1087
+ // Rising always means the floor is yours; falling always means it has been taken away. That direction is
1088
+ // the whole cue — it can be recognised without looking and without remembering pitches.
1089
+ earcon(kind) {
1090
+ if (!this.audioContext)
1091
+ return;
1092
+ const notes = kind === 'ready' ? [660, 990]
1093
+ : kind === 'taken' ? [660, 440]
1094
+ : [880];
1095
+ try {
1096
+ const start = this.audioContext.currentTime;
1097
+ notes.forEach((frequency, index) => {
1098
+ const oscillator = this.audioContext.createOscillator();
1099
+ const gain = this.audioContext.createGain();
1100
+ oscillator.type = 'sine';
1101
+ oscillator.frequency.value = frequency;
1102
+ const at = start + index * 0.1;
1103
+ const peak = kind === 'gotyou' ? 0.06 : 0.12; // the mid-speech cue is deliberately quieter
1104
+ gain.gain.setValueAtTime(0.0001, at);
1105
+ gain.gain.exponentialRampToValueAtTime(peak, at + 0.01);
1106
+ gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.09);
1107
+ oscillator.connect(gain);
1108
+ gain.connect(this.audioContext.destination);
1109
+ oscillator.start(at);
1110
+ oscillator.stop(at + 0.1);
1111
+ });
1112
+ }
1113
+ catch { }
1114
+ }
1115
+ releaseMic() {
1116
+ if (this.vadTimer) {
1117
+ clearInterval(this.vadTimer);
1118
+ this.vadTimer = null;
1119
+ }
1120
+ this.analyser = null;
1121
+ this.voiceRecorder = null;
1122
+ this.voiceLevel = 0;
1123
+ if (this.voiceStream) {
1124
+ this.voiceStream.getTracks().forEach(t => t.stop());
1125
+ this.voiceStream = null;
1126
+ this.earcon('taken'); // "I have it" — the floor is no longer yours
1127
+ }
1128
+ }
1129
+ stopVoiceCapture() {
1130
+ if (this.voiceRecorder && this.voiceRecorder.state !== 'inactive') {
1131
+ try {
1132
+ this.voiceRecorder.stop();
1133
+ }
1134
+ catch { }
1135
+ }
1136
+ this.releaseMic();
1137
+ this.voiceChunks = [];
1138
+ }
1139
+ stopPlayback() {
1140
+ if (!this.player)
1141
+ return;
1142
+ try {
1143
+ this.player.pause();
1144
+ this.player.currentTime = 0;
1145
+ }
1146
+ catch { }
1147
+ this.player.onended = null;
1148
+ this.player.onerror = null;
1149
+ this.player = null;
1150
+ const synth = window.speechSynthesis;
1151
+ if (synth) {
1152
+ try {
1153
+ synth.cancel();
1154
+ }
1155
+ catch { }
1156
+ }
1157
+ }
1158
+ // What the state indicator reads. Plain words, because it is glanced at rather than read.
1159
+ get voiceStateLabel() {
1160
+ switch (this.voiceState) {
1161
+ // Changed: shown during room calibration. "Wait" rather than "Listening", because talking during this
1162
+ // window is actively harmful — the words get measured as background noise.
1163
+ case 'starting': return 'Getting ready';
1164
+ case 'listening': return 'Listening';
1165
+ // Changed: capturing is now shown. It tells the user it has locked onto their voice and is WAITING for
1166
+ // them to finish — without it, a long pause feels like the thing has stopped paying attention.
1167
+ case 'capturing': return 'Hearing you';
1168
+ case 'thinking': return 'Thinking';
1169
+ case 'speaking': return 'Speaking';
1170
+ default: return '';
1171
+ }
1172
+ }
1173
+ get voiceStateIcon() {
1174
+ switch (this.voiceState) {
1175
+ case 'starting': return 'graphic_eq'; // measuring the room, not the person
1176
+ case 'listening':
1177
+ case 'capturing': return 'mic';
1178
+ case 'thinking': return 'hourglass_top';
1179
+ case 'speaking': return 'volume_up';
1180
+ default: return 'mic_off';
1181
+ }
1182
+ }
1183
+ // Changed: "Take photo" — hands off to the device camera. On a phone this opens the camera app on the rear
1184
+ // lens and returns the shot; no in-page camera permission is involved because the OS handles it. Desktop
1185
+ // browsers ignore `capture` and fall back to the file picker.
1186
+ takePhoto() {
1187
+ this.cameraInput?.nativeElement.click();
1188
+ }
1189
+ // Changed: "Choose photo" — same input minus `capture`, so it opens the gallery / file picker
1190
+ choosePhoto() {
1191
+ this.galleryInput?.nativeElement.click();
1192
+ }
1193
+ // Changed: photo capture now STAGES the file instead of sending it. Nothing leaves until the user presses
1194
+ // send, so they can add a typed or spoken caption first — that caption is the actual command, the photo is
1195
+ // the evidence it refers to. `capture` opens the camera directly on mobile, the file picker on desktop.
1196
+ onPhotoSelected(event) {
1197
+ const input = event.target;
1198
+ const file = input.files && input.files.length ? input.files[0] : null;
1199
+ input.value = ''; // Reset so picking the same file twice still fires a change event
1200
+ if (!file)
1201
+ return;
1202
+ this.pendingPhoto = file;
1203
+ this.focusInput();
1204
+ }
1205
+ // Picks a container the browser can record AND the transcription endpoint accepts. Chrome/Edge/Firefox give
1206
+ // webm/opus, Safari gives mp4 — both are supported server-side, so no transcoding is needed anywhere.
1207
+ pickAudioMimeType() {
1208
+ const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/ogg'];
1209
+ return candidates.find(t => MediaRecorder.isTypeSupported(t)) || '';
1210
+ }
1211
+ finishRecording() {
1212
+ const mode = this.stopMode;
1213
+ this.clearRecording();
1214
+ if (mode === 'cancel' || !this.chunks.length) {
1215
+ this.chunks = [];
1216
+ return;
1217
+ }
1218
+ const type = this.recorder?.mimeType || 'audio/webm';
1219
+ const blob = new Blob(this.chunks, { type });
1220
+ const fileName = `voice-note.${this.extensionFor(type)}`;
1221
+ this.chunks = [];
1222
+ // Stop → transcribe into the text box for review; the photo (if any) stays staged
1223
+ if (mode === 'transcribe') {
1224
+ this.isTranscribing = true;
1225
+ this.agentService.transcribeOnly(blob, fileName).subscribe({
1226
+ next: (res) => {
1227
+ this.isTranscribing = false;
1228
+ if (res?.success && res.data?.text) {
1229
+ const existing = this.inputText.trim();
1230
+ this.inputText = existing ? `${existing} ${res.data.text}` : res.data.text;
1231
+ this.focusInput();
1232
+ }
1233
+ else {
1234
+ this.mediaError = res?.message || 'Sorry, that recording could not be transcribed.';
1235
+ }
1236
+ },
1237
+ error: () => {
1238
+ this.isTranscribing = false;
1239
+ this.mediaError = 'Sorry, that recording could not be transcribed.';
1240
+ }
1241
+ });
1242
+ return;
1243
+ }
1244
+ // Send → the clip goes with the staged photo and whatever is typed, all in one message
1245
+ this.agentService.sendComposed(this.pendingPhoto, this.pendingPhoto?.name || 'photo.jpg', blob, fileName, this.inputText.trim());
1246
+ this.pendingPhoto = null;
1247
+ this.inputText = '';
1248
+ }
1249
+ extensionFor(mimeType) {
1250
+ const type = (mimeType || '').toLowerCase();
1251
+ if (type.indexOf('mp4') >= 0)
1252
+ return 'mp4';
1253
+ if (type.indexOf('ogg') >= 0)
1254
+ return 'ogg';
1255
+ return 'webm';
1256
+ }
1257
+ // Releases the mic — without this the browser keeps showing the recording indicator
1258
+ clearRecording() {
1259
+ this.isRecording = false;
1260
+ this.recordSeconds = 0;
1261
+ if (this.recordTimer) {
1262
+ clearInterval(this.recordTimer);
1263
+ this.recordTimer = null;
1264
+ }
1265
+ if (this.mediaStream) {
1266
+ this.mediaStream.getTracks().forEach(t => t.stop());
1267
+ this.mediaStream = null;
1268
+ }
1269
+ }
1270
+ scrollToBottom() {
1271
+ try {
1272
+ if (this.messageContainer) {
1273
+ this.messageContainer.nativeElement.scrollTop = this.messageContainer.nativeElement.scrollHeight;
1274
+ }
1275
+ }
1276
+ catch (e) { }
1277
+ }
1278
+ focusInput() {
1279
+ setTimeout(() => {
1280
+ if (this.messageInput) {
1281
+ this.messageInput.nativeElement.focus();
1282
+ }
1283
+ }, 300);
1284
+ }
1285
+ trackMessage(index, msg) {
1286
+ return `${index}-${msg.role}-${msg.createdDate}`;
1287
+ }
1288
+ // Changed: Set --agent-vh CSS variable from visualViewport so the chat resizes when the mobile keyboard opens
1289
+ initMobileViewport() {
1290
+ if (!window.visualViewport)
1291
+ return;
1292
+ this.viewportHandler = () => {
1293
+ const vh = window.visualViewport.height;
1294
+ document.documentElement.style.setProperty('--agent-vh', `${vh}px`);
1295
+ // Changed: one signal, a second consumer. When the on-screen keyboard opens the visual viewport
1296
+ // collapses well below the layout viewport; the fixed bottom tab bar has to get out of the way or it
1297
+ // sits between the keyboard and the field being typed into. Published as a class on <html> so the tab
1298
+ // bar reacts in CSS alone — deliberately NOT a second visualViewport listener of its own.
1299
+ // 0.75 is the usual heuristic: real keyboards take 30-50% of the height, browser chrome far less.
1300
+ document.documentElement.classList.toggle('keyboard-open', vh < window.innerHeight * 0.75);
1301
+ };
1302
+ window.visualViewport.addEventListener('resize', this.viewportHandler);
1303
+ this.viewportHandler(); // Set initial value
1304
+ }
1305
+ ngOnDestroy() {
1306
+ this.subs.forEach(s => s.unsubscribe());
1307
+ if (this.isRecording)
1308
+ this.cancelRecording(); // Changed: never leave the mic open behind a destroyed widget
1309
+ if (this.voiceMode)
1310
+ this.stopVoiceMode(); // Changed: same for a voice session — no orphaned mic
1311
+ this.onMicPressEnd(); // Changed: a held mic must not fire after teardown
1312
+ this.clearRecording();
1313
+ if (this.viewportHandler && window.visualViewport) {
1314
+ window.visualViewport.removeEventListener('resize', this.viewportHandler);
1315
+ document.documentElement.classList.remove('keyboard-open'); // Changed: never leave the tab bar hidden by a destroyed widget
1316
+ }
1317
+ }
1318
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentComponent, deps: [{ token: AgentService }, { token: i1$1.SignalRService }, { token: i0.NgZone }, { token: i1$2.Router }, { token: i2.BreakpointObserver }], target: i0.ɵɵFactoryTarget.Component }); }
1319
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: AgentComponent, isStandalone: true, selector: "spa-agent", inputs: { pageMode: "pageMode" }, host: { listeners: { "document:click": "onDocumentClick($event)" } }, viewQueries: [{ propertyName: "messageContainer", first: true, predicate: ["messageContainer"], descendants: true }, { propertyName: "messageInput", first: true, predicate: ["messageInput"], descendants: true }, { propertyName: "cameraInput", first: true, predicate: ["cameraInput"], descendants: true }, { propertyName: "galleryInput", first: true, predicate: ["galleryInput"], descendants: true }], ngImport: i0, template: "<!-- Floating chat widget for in-app Agent (renamed from Assistant) -->\n\n<!-- FAB toggle button -->\n<!-- Changed: the page presentation has no FAB \u2014 the page IS the chat, so a button to open it is nonsense.\n Changed: showFab also stands the FAB down on mobile (the bottom tab bar's Chat tab replaces it) and on\n the home/agent route at any width, where it would otherwise hover over the page it opens. -->\n<button mat-fab class=\"agent-fab\" *ngIf=\"showFab\" (click)=\"toggleChat()\" [matTooltip]=\"agentName\">\n <mat-icon>{{ isOpen ? 'close' : 'chat_bubble' }}</mat-icon>\n</button>\n\n<!-- Chat window -->\n<!-- Changed: rendered unconditionally in page mode (isOpen only governs the floating window); .page-mode\n swaps the fixed overlay for in-flow layout and 'none' opts out of the slide-up animation -->\n<!-- Changed: showWindow keeps the floating window off wherever its FAB is off, so mobile and the chat route\n can never show two chat surfaces at once -->\n<div class=\"agent-window\" [class.page-mode]=\"pageMode\" *ngIf=\"showWindow\" [@slideUp]=\"pageMode ? 'none' : 'float'\">\n\n <!-- Header with online status -->\n <div class=\"agent-header\">\n <div class=\"header-info\">\n <mat-icon class=\"header-icon\">smart_toy</mat-icon>\n <div class=\"header-text\">\n <span class=\"header-title\">{{ agentName }}</span>\n <span class=\"status-badge\" [class.online]=\"isOnline\" [class.offline]=\"!isOnline\">\n <span class=\"status-dot\"></span>\n {{ isOnline ? 'Online' : 'Offline' }}\n </span>\n </div>\n </div>\n <div class=\"header-actions\">\n <!-- Changed: earlier conversations existed but were unreachable \u2014 the widget only ever showed the newest -->\n <button mat-icon-button matTooltip=\"Conversations\" (click)=\"toggleConversationList()\">\n <mat-icon>forum</mat-icon>\n </button>\n <button mat-icon-button matTooltip=\"New conversation\" (click)=\"newConversation()\">\n <mat-icon>add_comment</mat-icon>\n </button>\n <!-- Changed: minimise is float-only \u2014 there is nothing to minimise a routed page into -->\n <button mat-icon-button matTooltip=\"Close\" *ngIf=\"!pageMode\" (click)=\"toggleChat()\">\n <mat-icon>remove</mat-icon>\n </button>\n </div>\n </div>\n\n <!-- Conversation switcher. A dropdown rather than a panel: the window is small and this is a jump list,\n not somewhere the user spends time. -->\n <div class=\"agent-conversations\" *ngIf=\"showConversations\">\n <div class=\"conversation-row\" *ngFor=\"let c of conversations\"\n [class.current]=\"c.conversationID === currentConversationId\"\n (click)=\"selectConversation(c.conversationID)\">\n <mat-icon class=\"conversation-icon\">chat</mat-icon>\n <span class=\"conversation-title\">{{ c.title }}</span>\n </div>\n <div class=\"conversation-empty\" *ngIf=\"conversations.length === 0\">No earlier conversations</div>\n </div>\n\n <!-- Changed: the session banner. The agent has always known it was holding a half-built record; until the\n reply carried structured state, that fact only existed as prose the user had to read carefully. -->\n <div class=\"agent-session-banner\" *ngIf=\"sessionInfo?.sessionType\">\n <mat-icon class=\"session-icon\">edit_note</mat-icon>\n <span class=\"session-text\">{{ sessionVerb }} a {{ sessionInfo?.modelName }}</span>\n <!-- Changed: chips rather than a sentence. What is still required is the one thing the user has to act\n on, and a chip per field is scannable where a comma-separated list inside a sentence is not. -->\n <span class=\"session-chip\" *ngFor=\"let field of sessionInfo?.missingFields\">{{ field | camelToWords }}</span>\n </div>\n\n <!-- Messages area -->\n <div class=\"agent-messages\" #messageContainer>\n\n <!-- Welcome experience (show when no messages) -->\n <div class=\"agent-greeting\" *ngIf=\"messages.length === 0\">\n <mat-icon class=\"greeting-icon\">smart_toy</mat-icon>\n <p class=\"greeting-text\">{{ greeting }}</p>\n\n <!-- Three capability pillars -->\n <div class=\"capability-pillars\">\n\n <!-- Pillar 1: Navigate & Inquire -->\n <div class=\"pillar\" (click)=\"sendSuggested('How do I post a transaction?')\">\n <div class=\"pillar-header\">\n <mat-icon class=\"pillar-icon\">explore</mat-icon>\n <span class=\"pillar-title\">Ask & Navigate</span>\n </div>\n <p class=\"pillar-desc\">Ask how to do things or find features in the app</p>\n <span class=\"pillar-example\">\"How do I post a transaction?\"</span>\n </div>\n\n <!-- Pillar 2: Create & Modify -->\n <div class=\"pillar\" (click)=\"sendSuggested('Create a new customer')\">\n <div class=\"pillar-header\">\n <mat-icon class=\"pillar-icon\">edit_note</mat-icon>\n <span class=\"pillar-title\">Create & Modify</span>\n </div>\n <p class=\"pillar-desc\">Add, edit, or delete records using natural language</p>\n <span class=\"pillar-example\">\"Create a new customer called Acme Ltd\"</span>\n </div>\n\n <!-- Pillar 3: Get Information -->\n <div class=\"pillar\" (click)=\"sendSuggested('List all accounts')\">\n <div class=\"pillar-header\">\n <mat-icon class=\"pillar-icon\">search</mat-icon>\n <span class=\"pillar-title\">Get Information</span>\n </div>\n <p class=\"pillar-desc\">Retrieve details, list records, or get summaries</p>\n <span class=\"pillar-example\">\"Show me the details of Customer A\"</span>\n </div>\n\n </div>\n </div>\n\n <!-- Message bubbles -->\n <ng-container *ngFor=\"let msg of messages; trackBy: trackMessage\">\n\n <!-- Changed: a conversation boundary is drawn, not spoken. Without a visible line, the agent starting a\n fresh thread after a long gap just looks like it forgetting what was said. -->\n <div class=\"conversation-divider\" *ngIf=\"msg.divider\">\n <span class=\"divider-label\">{{ msg.content }}</span>\n </div>\n\n <div *ngIf=\"!msg.divider\" class=\"message-row\" [class.user-row]=\"msg.role === 'user'\" [class.agent-row]=\"msg.role === 'assistant'\">\n <!-- Changed: agent replies are markdown and were being printed verbatim, so a carefully formatted\n answer arrived as a wall of asterisks. User messages stay plain text \u2014 they are not markdown,\n and interpreting them as such would be putting words in the user's mouth. -->\n <div class=\"message-bubble\" [class.user-bubble]=\"msg.role === 'user'\" [class.agent-bubble]=\"msg.role === 'assistant'\" [class.pending-bubble]=\"msg.pending\">\n <span *ngIf=\"msg.role === 'assistant'\" [innerHTML]=\"msg.content | agentMarkdown\"></span>\n <span *ngIf=\"msg.role !== 'assistant'\">{{ msg.content }}</span>\n </div>\n </div>\n\n </ng-container>\n\n <!-- Typing indicator -->\n <div class=\"message-row agent-row\" *ngIf=\"isTyping\">\n <div class=\"message-bubble agent-bubble typing-bubble\">\n <span class=\"typing-dot\"></span>\n <span class=\"typing-dot\"></span>\n <span class=\"typing-dot\"></span>\n </div>\n </div>\n\n </div>\n\n <!-- Media error (mic blocked / unsupported browser) -->\n <div class=\"agent-media-error\" *ngIf=\"mediaError\">\n <mat-icon class=\"media-error-icon\">error_outline</mat-icon>\n <span>{{ mediaError }}</span>\n </div>\n\n <!-- Staged attachment: a photo waits here until the user adds a caption and presses send -->\n <div class=\"agent-attachment\" *ngIf=\"pendingPhoto\">\n <mat-icon class=\"attach-icon\">image</mat-icon>\n <span class=\"attach-label\">Photo attached</span>\n <button mat-icon-button class=\"attach-remove\" matTooltip=\"Remove photo\" (click)=\"clearPhoto()\">\n <mat-icon>close</mat-icon>\n </button>\n </div>\n\n <!-- Transcribing indicator for the Stop button -->\n <div class=\"agent-transcribing\" *ngIf=\"isTranscribing\">\n <mat-icon class=\"transcribing-icon\">graphic_eq</mat-icon>\n <span>Transcribing\u2026</span>\n </div>\n\n <!-- Voice mode: replaces the composer entirely. Everything here is sized to be used without looking \u2014\n a big state indicator, the last exchange, and a thumb-sized exit. -->\n <div class=\"agent-voice\" *ngIf=\"voiceMode\">\n\n <div class=\"voice-state\" [class.is-starting]=\"voiceState === 'starting'\"\n [class.is-listening]=\"voiceState === 'listening' || voiceState === 'capturing'\"\n [class.is-thinking]=\"voiceState === 'thinking'\" [class.is-speaking]=\"voiceState === 'speaking'\">\n <mat-icon class=\"voice-state-icon\">{{ voiceStateIcon }}</mat-icon>\n <span class=\"voice-state-label\">{{ voiceStateLabel }}</span>\n </div>\n\n <!-- Live level, so the mic being open is visible as well as audible. Not shown while calibrating: the\n meter would invite exactly the talking that ruins the measurement. -->\n <div class=\"voice-meter\" *ngIf=\"voiceState === 'listening' || voiceState === 'capturing'\">\n <div class=\"voice-meter-fill\" [style.width.%]=\"voiceLevel * 100\"></div>\n </div>\n\n <!-- Deliberately NO transcript/reply repeated here: both already appear in the chat thread directly above,\n and duplicating them pushed the state indicator \u2014 the one thing that must be readable at a glance \u2014\n down the panel. The state, the meter and the exit are all this needs. -->\n\n <!-- Says explicitly that pauses are fine, because the natural worry is that stopping to think will send -->\n <p class=\"voice-hint\" *ngIf=\"voiceState === 'starting'\">Checking the background noise \u2014 hold on\u2026</p>\n <p class=\"voice-hint\" *ngIf=\"voiceState === 'capturing'\">Take your time \u2014 it waits until you're done.</p>\n <p class=\"voice-hint\" *ngIf=\"voiceState === 'listening'\">Just speak \u2014 it sends when you finish.</p>\n\n <button mat-flat-button color=\"warn\" class=\"voice-exit\" (click)=\"stopVoiceMode()\">\n <mat-icon>close</mat-icon>\n <span>Exit voice mode</span>\n </button>\n </div>\n\n <!-- Input area -->\n <div class=\"agent-input\" [class.recording]=\"isRecording\" *ngIf=\"!voiceMode\">\n\n <!-- Normal input: attach, text, voice note, hands-free, send -->\n <ng-container *ngIf=\"!isRecording\">\n <button mat-icon-button class=\"media-btn\" matTooltip=\"Attach\" [matMenuTriggerFor]=\"attachMenu\">\n <mat-icon>add</mat-icon>\n </button>\n <mat-form-field appearance=\"outline\" class=\"input-field\">\n <input matInput #messageInput placeholder=\"Type, speak or snap...\" [(ngModel)]=\"inputText\" (keydown)=\"onKeydown($event)\" autocomplete=\"off\" />\n </mat-form-field>\n <!-- Tap records a voice note; HOLD enters hands-free voice mode. Deliberately no separate button for\n voice mode: the row is already at capacity, and a fifth control would crowd out the text field on a\n phone. The gesture is the accelerator, the \"+\" menu below is the discoverable path to the same\n thing \u2014 a gesture nobody can see is a feature nobody finds. -->\n <button mat-icon-button class=\"media-btn mic-btn\" [class.arming]=\"micHeld\"\n matTooltip=\"Tap to record \u00B7 hold for hands-free\"\n (click)=\"onMicClick()\"\n (pointerdown)=\"onMicPressStart()\" (pointerup)=\"onMicPressEnd()\"\n (pointerleave)=\"onMicPressEnd()\" (pointercancel)=\"onMicPressEnd()\">\n <mat-icon>mic</mat-icon>\n </button>\n <button mat-icon-button color=\"primary\" class=\"send-btn\" (click)=\"sendMessage()\" [disabled]=\"!canSend\">\n <mat-icon>send</mat-icon>\n </button>\n </ng-container>\n\n <!-- Recording bar: replaces the input while a voice note is being captured.\n Three exits \u2014 discard it, stop and read it back as text, or send it straight away. -->\n <ng-container *ngIf=\"isRecording\">\n <button mat-icon-button class=\"media-btn cancel-btn\" matTooltip=\"Discard\" (click)=\"cancelRecording()\">\n <mat-icon>delete</mat-icon>\n </button>\n <div class=\"recording-status\">\n <span class=\"rec-dot\"></span>\n <span class=\"rec-label\">Recording</span>\n <span class=\"rec-time\">{{ recordDisplay }}</span>\n </div>\n <button mat-icon-button class=\"media-btn stop-btn\" matTooltip=\"Stop and add as text\" (click)=\"stopAndTranscribe()\">\n <mat-icon>stop_circle</mat-icon>\n </button>\n <button mat-icon-button color=\"primary\" class=\"send-btn\" matTooltip=\"Send now\" (click)=\"stopRecording()\">\n <mat-icon>send</mat-icon>\n </button>\n </ng-container>\n\n <!-- Attach menu \u2014 keeps the row compact: one \"+\" instead of a button per source -->\n <mat-menu #attachMenu=\"matMenu\">\n <button mat-menu-item (click)=\"takePhoto()\">\n <mat-icon>photo_camera</mat-icon>\n <span>Take photo</span>\n </button>\n <button mat-menu-item (click)=\"choosePhoto()\">\n <mat-icon>photo_library</mat-icon>\n <span>Choose photo</span>\n </button>\n <!-- The discoverable way into voice mode. The mic long-press does the same thing faster, but only\n once you know it exists \u2014 this is how you find out. -->\n <button mat-menu-item (click)=\"startVoiceMode()\">\n <mat-icon>record_voice_over</mat-icon>\n <span>Hands-free voice mode</span>\n </button>\n </mat-menu>\n\n <!-- Two separate pickers on purpose. `capture=\"environment\"` hands off to the phone's camera app and\n opens the rear lens; WITHOUT it the same input opens the gallery/file picker. One input cannot do\n both, which is why the old single button always went to the camera on mobile. -->\n <input type=\"file\" #cameraInput accept=\"image/*\" capture=\"environment\" hidden (change)=\"onPhotoSelected($event)\" />\n <input type=\"file\" #galleryInput accept=\"image/*\" hidden (change)=\"onPhotoSelected($event)\" />\n </div>\n\n</div>\n", styles: [".agent-fab{position:fixed;bottom:var(--tin-fab-bottom, 24px);right:var(--tin-fab-right, 24px);z-index:1001;background:#1976d2;color:#fff}.agent-window{position:fixed;bottom:calc(var(--tin-fab-bottom, 24px) + 72px);right:var(--tin-fab-right, 24px);width:400px;height:580px;background:#fff;border-radius:16px;box-shadow:0 8px 32px #00000026;display:flex;flex-direction:column;z-index:1000;overflow:hidden}.agent-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#1976d2;color:#fff;min-height:56px}.header-info{display:flex;align-items:center;gap:10px}.header-icon{font-size:24px;width:24px;height:24px}.header-text{display:flex;flex-direction:column;gap:1px}.header-title{font-size:16px;font-weight:500;line-height:1.2}.status-badge{display:flex;align-items:center;gap:4px;font-size:11px;font-weight:400;opacity:.9;line-height:1}.status-dot{width:7px;height:7px;border-radius:50%;display:inline-block}.status-badge.online .status-dot{background:#4caf50;box-shadow:0 0 4px #4caf5099}.status-badge.offline .status-dot{background:#9e9e9e}.header-actions{display:flex;gap:0}.header-actions button{color:#fff}.agent-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:8px}.agent-greeting{display:flex;flex-direction:column;align-items:center;text-align:center;padding:20px 8px 8px;gap:10px}.greeting-icon{font-size:48px;width:48px;height:48px;color:#1976d2;opacity:.8}.greeting-text{font-size:15px;color:#424242;line-height:1.5;margin:0}.capability-pillars{display:flex;flex-direction:column;gap:10px;margin-top:8px;width:100%}.pillar{border:1px solid #e0e0e0;border-radius:12px;padding:12px 14px;text-align:left;cursor:pointer;transition:all .2s ease;background:#fafafa}.pillar:hover{border-color:#1976d2;background:#1976d20a;box-shadow:0 2px 8px #1976d21a}.pillar-header{display:flex;align-items:center;gap:8px;margin-bottom:4px}.pillar-icon{font-size:20px;width:20px;height:20px;color:#1976d2}.pillar-title{font-size:13px;font-weight:600;color:#212121}.pillar-desc{font-size:12px;color:#616161;margin:0 0 6px;line-height:1.4}.pillar-example{font-size:12px;color:#1976d2;font-style:italic;opacity:.85}.message-row{display:flex;max-width:85%}.user-row{align-self:flex-end}.agent-row{align-self:flex-start}.message-bubble{padding:10px 14px;border-radius:16px;font-size:14px;line-height:1.5;word-break:break-word;white-space:pre-wrap}.user-bubble{background:#1976d2;color:#fff;border-bottom-right-radius:4px}.agent-bubble{background:#f0f0f0;color:#212121;border-bottom-left-radius:4px}.typing-bubble{display:flex;align-items:center;gap:4px;padding:12px 18px}.typing-dot{width:8px;height:8px;border-radius:50%;background:#9e9e9e;animation:typingBounce 1.4s infinite ease-in-out}.typing-dot:nth-child(2){animation-delay:.2s}.typing-dot:nth-child(3){animation-delay:.4s}@keyframes typingBounce{0%,60%,to{transform:translateY(0);opacity:.4}30%{transform:translateY(-6px);opacity:1}}.agent-input{display:flex;align-items:center;padding:8px 12px;border-top:1px solid #e0e0e0;gap:4px}.input-field{flex:1}.input-field ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.input-field ::ng-deep .mat-mdc-text-field-wrapper{padding:0 12px}.send-btn,.media-btn{margin-bottom:0;display:inline-flex;align-items:center;justify-content:center}.media-btn{color:#757575}.media-btn:hover{color:#424242}.cancel-btn:hover{color:#d32f2f}.agent-input.recording{background:#fff5f5}.recording-status{flex:1;display:flex;align-items:center;gap:8px;padding:0 4px;font-size:13px;color:#616161}.rec-label{flex:1}.rec-time{font-variant-numeric:tabular-nums;color:#424242}.rec-dot{width:10px;height:10px;border-radius:50%;background:#e53935;animation:rec-pulse 1.2s ease-in-out infinite}@keyframes rec-pulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.85)}}.pending-bubble{opacity:.6;font-style:italic}.agent-attachment{display:flex;align-items:center;gap:8px;padding:6px 12px;font-size:12px;color:#1565c0;background:#e8f1fb;border-top:1px solid #cfe0f3}.attach-icon{font-size:18px;width:18px;height:18px}.attach-label{flex:1}.attach-remove{width:24px;height:24px;min-width:0;padding:0;flex:0 0 auto;line-height:24px;display:inline-flex;align-items:center;justify-content:center;color:#1565c0}.attach-remove .mat-icon{font-size:16px;width:16px;height:16px;line-height:16px}.agent-transcribing{display:flex;align-items:center;gap:8px;padding:6px 12px;font-size:12px;color:#616161;background:#f5f5f5;border-top:1px solid #e0e0e0}.transcribing-icon{font-size:18px;width:18px;height:18px;animation:rec-pulse 1.2s ease-in-out infinite}.stop-btn{color:#e53935}.agent-media-error{display:flex;align-items:center;gap:8px;padding:8px 12px;font-size:12px;color:#c62828;background:#fdecea;border-top:1px solid #f5c6c2}.media-error-icon{font-size:18px;width:18px;height:18px}.mic-btn{transition:transform .12s ease,color .12s ease}.mic-btn.arming{transform:scale(1.25);color:#1565c0}.agent-voice{display:flex;flex-direction:column;align-items:center;gap:12px;padding:20px 16px 18px;border-top:1px solid rgba(0,0,0,.08);background:#fafafa}.voice-state{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:600;letter-spacing:.3px}.voice-state-icon{font-size:28px;width:28px;height:28px}.voice-state.is-starting{color:#00000073}.voice-state.is-listening{color:#2e7d32}.voice-state.is-thinking{color:#ef6c00}.voice-state.is-speaking{color:#1565c0}.voice-state.is-listening .voice-state-icon{animation:voicePulse 1.4s ease-in-out infinite}@keyframes voicePulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(1.12)}}.voice-meter{width:100%;max-width:260px;height:6px;border-radius:3px;background:#00000014;overflow:hidden}.voice-meter-fill{height:100%;background:#2e7d32;border-radius:3px;transition:width 80ms linear}.voice-hint{margin:0;font-size:13px;opacity:.6}.voice-exit{width:100%;max-width:280px;height:48px;font-size:15px}.voice-exit mat-icon{margin-right:6px}@media (max-width: 600px){.agent-window{inset:0 0 auto;width:100%;height:100%;height:100dvh;height:var(--agent-vh, 100dvh);border-radius:0}.agent-fab{bottom:var(--tin-fab-bottom, 72px);right:var(--tin-fab-right, 16px)}}.agent-window.page-mode{position:relative;inset:auto;width:100%;max-width:100%;height:calc(var(--agent-vh, 100dvh) - var(--agent-page-offset, 140px));border-radius:12px;border:1px solid #e0e0e0;box-shadow:none;z-index:auto}@media (max-width: 600px){.agent-window.page-mode{height:calc(var(--agent-vh, 100dvh) - var(--agent-page-offset, 72px));border-radius:0;border:none}}.agent-conversations{max-height:180px;overflow-y:auto;border-bottom:1px solid #e0e0e0;background:#fafafa}.conversation-row{display:flex;align-items:center;gap:8px;padding:8px 14px;cursor:pointer;font-size:13px}.conversation-row:hover{background:#eef4fb}.conversation-row.current{background:#e3f0fd;font-weight:500}.conversation-icon{font-size:18px;width:18px;height:18px;opacity:.6}.conversation-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.conversation-empty{padding:10px 14px;font-size:12px;opacity:.6}.agent-session-banner{display:flex;align-items:center;flex-wrap:wrap;gap:6px;padding:6px 14px;background:#fff8e1;border-bottom:1px solid #ffe082;font-size:12px}.session-icon{font-size:16px;width:16px;height:16px;opacity:.7}.session-text{font-weight:500}.session-missing{opacity:.75}.conversation-divider{display:flex;align-items:center;gap:8px;margin:14px 4px;opacity:.55}.conversation-divider:before,.conversation-divider:after{content:\"\";flex:1;height:1px;background:#c8c8c8}.divider-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em}.session-chip{display:inline-block;padding:1px 8px;border-radius:10px;background:#ffe082;font-size:11px;white-space:nowrap}.agent-bubble .md-heading{display:block;margin:4px 0 2px;font-size:13px}.agent-bubble .md-bullet{opacity:.6;margin-right:2px}.agent-bubble code{padding:1px 4px;border-radius:3px;background:#0000000f;font-size:12px}\n"], dependencies: [{ kind: "ngmodule", type: SpaMatModule }, { kind: "directive", type: i1$3.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i7.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i7.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i7.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3.MatFabButton, selector: "button[mat-fab], a[mat-fab], button[matFab], a[matFab]", inputs: ["extended"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i3$2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "directive", type: i7$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "pipe", type: CamelToWordsPipe, name: "camelToWords" }, { kind: "pipe", type: AgentMarkdownPipe, name: "agentMarkdown" }], animations: [
1320
+ trigger('slideUp', [
1321
+ // Changed: page mode enters in normal flow and must not slide up — a page that animates in like a
1322
+ // dialog still reads as a dialog. Both empty transitions are declared BEFORE :enter/:leave because the
1323
+ // first matching transition wins, so 'none' opts the page instance out of the floating animation.
1324
+ transition('void => none', []), // Changed: page mode — no enter animation
1325
+ transition('none => void', []), // Changed: page mode — no leave animation
1326
+ transition(':enter', [
1327
+ style({ opacity: 0, transform: 'translateY(20px) scale(0.95)' }),
1328
+ animate('250ms cubic-bezier(0.4, 0, 0.2, 1)', style({ opacity: 1, transform: 'translateY(0) scale(1)' }))
1329
+ ]),
1330
+ transition(':leave', [
1331
+ animate('200ms cubic-bezier(0.4, 0, 0.2, 1)', style({ opacity: 0, transform: 'translateY(20px) scale(0.95)' }))
1332
+ ])
1333
+ ])
1334
+ ] }); }
1335
+ }
1336
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentComponent, decorators: [{
1337
+ type: Component,
1338
+ args: [{ selector: 'spa-agent', standalone: true, imports: [SpaMatModule, CamelToWordsPipe, AgentMarkdownPipe], animations: [
1339
+ trigger('slideUp', [
1340
+ // Changed: page mode enters in normal flow and must not slide up — a page that animates in like a
1341
+ // dialog still reads as a dialog. Both empty transitions are declared BEFORE :enter/:leave because the
1342
+ // first matching transition wins, so 'none' opts the page instance out of the floating animation.
1343
+ transition('void => none', []), // Changed: page mode — no enter animation
1344
+ transition('none => void', []), // Changed: page mode — no leave animation
1345
+ transition(':enter', [
1346
+ style({ opacity: 0, transform: 'translateY(20px) scale(0.95)' }),
1347
+ animate('250ms cubic-bezier(0.4, 0, 0.2, 1)', style({ opacity: 1, transform: 'translateY(0) scale(1)' }))
1348
+ ]),
1349
+ transition(':leave', [
1350
+ animate('200ms cubic-bezier(0.4, 0, 0.2, 1)', style({ opacity: 0, transform: 'translateY(20px) scale(0.95)' }))
1351
+ ])
1352
+ ])
1353
+ ], template: "<!-- Floating chat widget for in-app Agent (renamed from Assistant) -->\n\n<!-- FAB toggle button -->\n<!-- Changed: the page presentation has no FAB \u2014 the page IS the chat, so a button to open it is nonsense.\n Changed: showFab also stands the FAB down on mobile (the bottom tab bar's Chat tab replaces it) and on\n the home/agent route at any width, where it would otherwise hover over the page it opens. -->\n<button mat-fab class=\"agent-fab\" *ngIf=\"showFab\" (click)=\"toggleChat()\" [matTooltip]=\"agentName\">\n <mat-icon>{{ isOpen ? 'close' : 'chat_bubble' }}</mat-icon>\n</button>\n\n<!-- Chat window -->\n<!-- Changed: rendered unconditionally in page mode (isOpen only governs the floating window); .page-mode\n swaps the fixed overlay for in-flow layout and 'none' opts out of the slide-up animation -->\n<!-- Changed: showWindow keeps the floating window off wherever its FAB is off, so mobile and the chat route\n can never show two chat surfaces at once -->\n<div class=\"agent-window\" [class.page-mode]=\"pageMode\" *ngIf=\"showWindow\" [@slideUp]=\"pageMode ? 'none' : 'float'\">\n\n <!-- Header with online status -->\n <div class=\"agent-header\">\n <div class=\"header-info\">\n <mat-icon class=\"header-icon\">smart_toy</mat-icon>\n <div class=\"header-text\">\n <span class=\"header-title\">{{ agentName }}</span>\n <span class=\"status-badge\" [class.online]=\"isOnline\" [class.offline]=\"!isOnline\">\n <span class=\"status-dot\"></span>\n {{ isOnline ? 'Online' : 'Offline' }}\n </span>\n </div>\n </div>\n <div class=\"header-actions\">\n <!-- Changed: earlier conversations existed but were unreachable \u2014 the widget only ever showed the newest -->\n <button mat-icon-button matTooltip=\"Conversations\" (click)=\"toggleConversationList()\">\n <mat-icon>forum</mat-icon>\n </button>\n <button mat-icon-button matTooltip=\"New conversation\" (click)=\"newConversation()\">\n <mat-icon>add_comment</mat-icon>\n </button>\n <!-- Changed: minimise is float-only \u2014 there is nothing to minimise a routed page into -->\n <button mat-icon-button matTooltip=\"Close\" *ngIf=\"!pageMode\" (click)=\"toggleChat()\">\n <mat-icon>remove</mat-icon>\n </button>\n </div>\n </div>\n\n <!-- Conversation switcher. A dropdown rather than a panel: the window is small and this is a jump list,\n not somewhere the user spends time. -->\n <div class=\"agent-conversations\" *ngIf=\"showConversations\">\n <div class=\"conversation-row\" *ngFor=\"let c of conversations\"\n [class.current]=\"c.conversationID === currentConversationId\"\n (click)=\"selectConversation(c.conversationID)\">\n <mat-icon class=\"conversation-icon\">chat</mat-icon>\n <span class=\"conversation-title\">{{ c.title }}</span>\n </div>\n <div class=\"conversation-empty\" *ngIf=\"conversations.length === 0\">No earlier conversations</div>\n </div>\n\n <!-- Changed: the session banner. The agent has always known it was holding a half-built record; until the\n reply carried structured state, that fact only existed as prose the user had to read carefully. -->\n <div class=\"agent-session-banner\" *ngIf=\"sessionInfo?.sessionType\">\n <mat-icon class=\"session-icon\">edit_note</mat-icon>\n <span class=\"session-text\">{{ sessionVerb }} a {{ sessionInfo?.modelName }}</span>\n <!-- Changed: chips rather than a sentence. What is still required is the one thing the user has to act\n on, and a chip per field is scannable where a comma-separated list inside a sentence is not. -->\n <span class=\"session-chip\" *ngFor=\"let field of sessionInfo?.missingFields\">{{ field | camelToWords }}</span>\n </div>\n\n <!-- Messages area -->\n <div class=\"agent-messages\" #messageContainer>\n\n <!-- Welcome experience (show when no messages) -->\n <div class=\"agent-greeting\" *ngIf=\"messages.length === 0\">\n <mat-icon class=\"greeting-icon\">smart_toy</mat-icon>\n <p class=\"greeting-text\">{{ greeting }}</p>\n\n <!-- Three capability pillars -->\n <div class=\"capability-pillars\">\n\n <!-- Pillar 1: Navigate & Inquire -->\n <div class=\"pillar\" (click)=\"sendSuggested('How do I post a transaction?')\">\n <div class=\"pillar-header\">\n <mat-icon class=\"pillar-icon\">explore</mat-icon>\n <span class=\"pillar-title\">Ask & Navigate</span>\n </div>\n <p class=\"pillar-desc\">Ask how to do things or find features in the app</p>\n <span class=\"pillar-example\">\"How do I post a transaction?\"</span>\n </div>\n\n <!-- Pillar 2: Create & Modify -->\n <div class=\"pillar\" (click)=\"sendSuggested('Create a new customer')\">\n <div class=\"pillar-header\">\n <mat-icon class=\"pillar-icon\">edit_note</mat-icon>\n <span class=\"pillar-title\">Create & Modify</span>\n </div>\n <p class=\"pillar-desc\">Add, edit, or delete records using natural language</p>\n <span class=\"pillar-example\">\"Create a new customer called Acme Ltd\"</span>\n </div>\n\n <!-- Pillar 3: Get Information -->\n <div class=\"pillar\" (click)=\"sendSuggested('List all accounts')\">\n <div class=\"pillar-header\">\n <mat-icon class=\"pillar-icon\">search</mat-icon>\n <span class=\"pillar-title\">Get Information</span>\n </div>\n <p class=\"pillar-desc\">Retrieve details, list records, or get summaries</p>\n <span class=\"pillar-example\">\"Show me the details of Customer A\"</span>\n </div>\n\n </div>\n </div>\n\n <!-- Message bubbles -->\n <ng-container *ngFor=\"let msg of messages; trackBy: trackMessage\">\n\n <!-- Changed: a conversation boundary is drawn, not spoken. Without a visible line, the agent starting a\n fresh thread after a long gap just looks like it forgetting what was said. -->\n <div class=\"conversation-divider\" *ngIf=\"msg.divider\">\n <span class=\"divider-label\">{{ msg.content }}</span>\n </div>\n\n <div *ngIf=\"!msg.divider\" class=\"message-row\" [class.user-row]=\"msg.role === 'user'\" [class.agent-row]=\"msg.role === 'assistant'\">\n <!-- Changed: agent replies are markdown and were being printed verbatim, so a carefully formatted\n answer arrived as a wall of asterisks. User messages stay plain text \u2014 they are not markdown,\n and interpreting them as such would be putting words in the user's mouth. -->\n <div class=\"message-bubble\" [class.user-bubble]=\"msg.role === 'user'\" [class.agent-bubble]=\"msg.role === 'assistant'\" [class.pending-bubble]=\"msg.pending\">\n <span *ngIf=\"msg.role === 'assistant'\" [innerHTML]=\"msg.content | agentMarkdown\"></span>\n <span *ngIf=\"msg.role !== 'assistant'\">{{ msg.content }}</span>\n </div>\n </div>\n\n </ng-container>\n\n <!-- Typing indicator -->\n <div class=\"message-row agent-row\" *ngIf=\"isTyping\">\n <div class=\"message-bubble agent-bubble typing-bubble\">\n <span class=\"typing-dot\"></span>\n <span class=\"typing-dot\"></span>\n <span class=\"typing-dot\"></span>\n </div>\n </div>\n\n </div>\n\n <!-- Media error (mic blocked / unsupported browser) -->\n <div class=\"agent-media-error\" *ngIf=\"mediaError\">\n <mat-icon class=\"media-error-icon\">error_outline</mat-icon>\n <span>{{ mediaError }}</span>\n </div>\n\n <!-- Staged attachment: a photo waits here until the user adds a caption and presses send -->\n <div class=\"agent-attachment\" *ngIf=\"pendingPhoto\">\n <mat-icon class=\"attach-icon\">image</mat-icon>\n <span class=\"attach-label\">Photo attached</span>\n <button mat-icon-button class=\"attach-remove\" matTooltip=\"Remove photo\" (click)=\"clearPhoto()\">\n <mat-icon>close</mat-icon>\n </button>\n </div>\n\n <!-- Transcribing indicator for the Stop button -->\n <div class=\"agent-transcribing\" *ngIf=\"isTranscribing\">\n <mat-icon class=\"transcribing-icon\">graphic_eq</mat-icon>\n <span>Transcribing\u2026</span>\n </div>\n\n <!-- Voice mode: replaces the composer entirely. Everything here is sized to be used without looking \u2014\n a big state indicator, the last exchange, and a thumb-sized exit. -->\n <div class=\"agent-voice\" *ngIf=\"voiceMode\">\n\n <div class=\"voice-state\" [class.is-starting]=\"voiceState === 'starting'\"\n [class.is-listening]=\"voiceState === 'listening' || voiceState === 'capturing'\"\n [class.is-thinking]=\"voiceState === 'thinking'\" [class.is-speaking]=\"voiceState === 'speaking'\">\n <mat-icon class=\"voice-state-icon\">{{ voiceStateIcon }}</mat-icon>\n <span class=\"voice-state-label\">{{ voiceStateLabel }}</span>\n </div>\n\n <!-- Live level, so the mic being open is visible as well as audible. Not shown while calibrating: the\n meter would invite exactly the talking that ruins the measurement. -->\n <div class=\"voice-meter\" *ngIf=\"voiceState === 'listening' || voiceState === 'capturing'\">\n <div class=\"voice-meter-fill\" [style.width.%]=\"voiceLevel * 100\"></div>\n </div>\n\n <!-- Deliberately NO transcript/reply repeated here: both already appear in the chat thread directly above,\n and duplicating them pushed the state indicator \u2014 the one thing that must be readable at a glance \u2014\n down the panel. The state, the meter and the exit are all this needs. -->\n\n <!-- Says explicitly that pauses are fine, because the natural worry is that stopping to think will send -->\n <p class=\"voice-hint\" *ngIf=\"voiceState === 'starting'\">Checking the background noise \u2014 hold on\u2026</p>\n <p class=\"voice-hint\" *ngIf=\"voiceState === 'capturing'\">Take your time \u2014 it waits until you're done.</p>\n <p class=\"voice-hint\" *ngIf=\"voiceState === 'listening'\">Just speak \u2014 it sends when you finish.</p>\n\n <button mat-flat-button color=\"warn\" class=\"voice-exit\" (click)=\"stopVoiceMode()\">\n <mat-icon>close</mat-icon>\n <span>Exit voice mode</span>\n </button>\n </div>\n\n <!-- Input area -->\n <div class=\"agent-input\" [class.recording]=\"isRecording\" *ngIf=\"!voiceMode\">\n\n <!-- Normal input: attach, text, voice note, hands-free, send -->\n <ng-container *ngIf=\"!isRecording\">\n <button mat-icon-button class=\"media-btn\" matTooltip=\"Attach\" [matMenuTriggerFor]=\"attachMenu\">\n <mat-icon>add</mat-icon>\n </button>\n <mat-form-field appearance=\"outline\" class=\"input-field\">\n <input matInput #messageInput placeholder=\"Type, speak or snap...\" [(ngModel)]=\"inputText\" (keydown)=\"onKeydown($event)\" autocomplete=\"off\" />\n </mat-form-field>\n <!-- Tap records a voice note; HOLD enters hands-free voice mode. Deliberately no separate button for\n voice mode: the row is already at capacity, and a fifth control would crowd out the text field on a\n phone. The gesture is the accelerator, the \"+\" menu below is the discoverable path to the same\n thing \u2014 a gesture nobody can see is a feature nobody finds. -->\n <button mat-icon-button class=\"media-btn mic-btn\" [class.arming]=\"micHeld\"\n matTooltip=\"Tap to record \u00B7 hold for hands-free\"\n (click)=\"onMicClick()\"\n (pointerdown)=\"onMicPressStart()\" (pointerup)=\"onMicPressEnd()\"\n (pointerleave)=\"onMicPressEnd()\" (pointercancel)=\"onMicPressEnd()\">\n <mat-icon>mic</mat-icon>\n </button>\n <button mat-icon-button color=\"primary\" class=\"send-btn\" (click)=\"sendMessage()\" [disabled]=\"!canSend\">\n <mat-icon>send</mat-icon>\n </button>\n </ng-container>\n\n <!-- Recording bar: replaces the input while a voice note is being captured.\n Three exits \u2014 discard it, stop and read it back as text, or send it straight away. -->\n <ng-container *ngIf=\"isRecording\">\n <button mat-icon-button class=\"media-btn cancel-btn\" matTooltip=\"Discard\" (click)=\"cancelRecording()\">\n <mat-icon>delete</mat-icon>\n </button>\n <div class=\"recording-status\">\n <span class=\"rec-dot\"></span>\n <span class=\"rec-label\">Recording</span>\n <span class=\"rec-time\">{{ recordDisplay }}</span>\n </div>\n <button mat-icon-button class=\"media-btn stop-btn\" matTooltip=\"Stop and add as text\" (click)=\"stopAndTranscribe()\">\n <mat-icon>stop_circle</mat-icon>\n </button>\n <button mat-icon-button color=\"primary\" class=\"send-btn\" matTooltip=\"Send now\" (click)=\"stopRecording()\">\n <mat-icon>send</mat-icon>\n </button>\n </ng-container>\n\n <!-- Attach menu \u2014 keeps the row compact: one \"+\" instead of a button per source -->\n <mat-menu #attachMenu=\"matMenu\">\n <button mat-menu-item (click)=\"takePhoto()\">\n <mat-icon>photo_camera</mat-icon>\n <span>Take photo</span>\n </button>\n <button mat-menu-item (click)=\"choosePhoto()\">\n <mat-icon>photo_library</mat-icon>\n <span>Choose photo</span>\n </button>\n <!-- The discoverable way into voice mode. The mic long-press does the same thing faster, but only\n once you know it exists \u2014 this is how you find out. -->\n <button mat-menu-item (click)=\"startVoiceMode()\">\n <mat-icon>record_voice_over</mat-icon>\n <span>Hands-free voice mode</span>\n </button>\n </mat-menu>\n\n <!-- Two separate pickers on purpose. `capture=\"environment\"` hands off to the phone's camera app and\n opens the rear lens; WITHOUT it the same input opens the gallery/file picker. One input cannot do\n both, which is why the old single button always went to the camera on mobile. -->\n <input type=\"file\" #cameraInput accept=\"image/*\" capture=\"environment\" hidden (change)=\"onPhotoSelected($event)\" />\n <input type=\"file\" #galleryInput accept=\"image/*\" hidden (change)=\"onPhotoSelected($event)\" />\n </div>\n\n</div>\n", styles: [".agent-fab{position:fixed;bottom:var(--tin-fab-bottom, 24px);right:var(--tin-fab-right, 24px);z-index:1001;background:#1976d2;color:#fff}.agent-window{position:fixed;bottom:calc(var(--tin-fab-bottom, 24px) + 72px);right:var(--tin-fab-right, 24px);width:400px;height:580px;background:#fff;border-radius:16px;box-shadow:0 8px 32px #00000026;display:flex;flex-direction:column;z-index:1000;overflow:hidden}.agent-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#1976d2;color:#fff;min-height:56px}.header-info{display:flex;align-items:center;gap:10px}.header-icon{font-size:24px;width:24px;height:24px}.header-text{display:flex;flex-direction:column;gap:1px}.header-title{font-size:16px;font-weight:500;line-height:1.2}.status-badge{display:flex;align-items:center;gap:4px;font-size:11px;font-weight:400;opacity:.9;line-height:1}.status-dot{width:7px;height:7px;border-radius:50%;display:inline-block}.status-badge.online .status-dot{background:#4caf50;box-shadow:0 0 4px #4caf5099}.status-badge.offline .status-dot{background:#9e9e9e}.header-actions{display:flex;gap:0}.header-actions button{color:#fff}.agent-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:8px}.agent-greeting{display:flex;flex-direction:column;align-items:center;text-align:center;padding:20px 8px 8px;gap:10px}.greeting-icon{font-size:48px;width:48px;height:48px;color:#1976d2;opacity:.8}.greeting-text{font-size:15px;color:#424242;line-height:1.5;margin:0}.capability-pillars{display:flex;flex-direction:column;gap:10px;margin-top:8px;width:100%}.pillar{border:1px solid #e0e0e0;border-radius:12px;padding:12px 14px;text-align:left;cursor:pointer;transition:all .2s ease;background:#fafafa}.pillar:hover{border-color:#1976d2;background:#1976d20a;box-shadow:0 2px 8px #1976d21a}.pillar-header{display:flex;align-items:center;gap:8px;margin-bottom:4px}.pillar-icon{font-size:20px;width:20px;height:20px;color:#1976d2}.pillar-title{font-size:13px;font-weight:600;color:#212121}.pillar-desc{font-size:12px;color:#616161;margin:0 0 6px;line-height:1.4}.pillar-example{font-size:12px;color:#1976d2;font-style:italic;opacity:.85}.message-row{display:flex;max-width:85%}.user-row{align-self:flex-end}.agent-row{align-self:flex-start}.message-bubble{padding:10px 14px;border-radius:16px;font-size:14px;line-height:1.5;word-break:break-word;white-space:pre-wrap}.user-bubble{background:#1976d2;color:#fff;border-bottom-right-radius:4px}.agent-bubble{background:#f0f0f0;color:#212121;border-bottom-left-radius:4px}.typing-bubble{display:flex;align-items:center;gap:4px;padding:12px 18px}.typing-dot{width:8px;height:8px;border-radius:50%;background:#9e9e9e;animation:typingBounce 1.4s infinite ease-in-out}.typing-dot:nth-child(2){animation-delay:.2s}.typing-dot:nth-child(3){animation-delay:.4s}@keyframes typingBounce{0%,60%,to{transform:translateY(0);opacity:.4}30%{transform:translateY(-6px);opacity:1}}.agent-input{display:flex;align-items:center;padding:8px 12px;border-top:1px solid #e0e0e0;gap:4px}.input-field{flex:1}.input-field ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.input-field ::ng-deep .mat-mdc-text-field-wrapper{padding:0 12px}.send-btn,.media-btn{margin-bottom:0;display:inline-flex;align-items:center;justify-content:center}.media-btn{color:#757575}.media-btn:hover{color:#424242}.cancel-btn:hover{color:#d32f2f}.agent-input.recording{background:#fff5f5}.recording-status{flex:1;display:flex;align-items:center;gap:8px;padding:0 4px;font-size:13px;color:#616161}.rec-label{flex:1}.rec-time{font-variant-numeric:tabular-nums;color:#424242}.rec-dot{width:10px;height:10px;border-radius:50%;background:#e53935;animation:rec-pulse 1.2s ease-in-out infinite}@keyframes rec-pulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.85)}}.pending-bubble{opacity:.6;font-style:italic}.agent-attachment{display:flex;align-items:center;gap:8px;padding:6px 12px;font-size:12px;color:#1565c0;background:#e8f1fb;border-top:1px solid #cfe0f3}.attach-icon{font-size:18px;width:18px;height:18px}.attach-label{flex:1}.attach-remove{width:24px;height:24px;min-width:0;padding:0;flex:0 0 auto;line-height:24px;display:inline-flex;align-items:center;justify-content:center;color:#1565c0}.attach-remove .mat-icon{font-size:16px;width:16px;height:16px;line-height:16px}.agent-transcribing{display:flex;align-items:center;gap:8px;padding:6px 12px;font-size:12px;color:#616161;background:#f5f5f5;border-top:1px solid #e0e0e0}.transcribing-icon{font-size:18px;width:18px;height:18px;animation:rec-pulse 1.2s ease-in-out infinite}.stop-btn{color:#e53935}.agent-media-error{display:flex;align-items:center;gap:8px;padding:8px 12px;font-size:12px;color:#c62828;background:#fdecea;border-top:1px solid #f5c6c2}.media-error-icon{font-size:18px;width:18px;height:18px}.mic-btn{transition:transform .12s ease,color .12s ease}.mic-btn.arming{transform:scale(1.25);color:#1565c0}.agent-voice{display:flex;flex-direction:column;align-items:center;gap:12px;padding:20px 16px 18px;border-top:1px solid rgba(0,0,0,.08);background:#fafafa}.voice-state{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:600;letter-spacing:.3px}.voice-state-icon{font-size:28px;width:28px;height:28px}.voice-state.is-starting{color:#00000073}.voice-state.is-listening{color:#2e7d32}.voice-state.is-thinking{color:#ef6c00}.voice-state.is-speaking{color:#1565c0}.voice-state.is-listening .voice-state-icon{animation:voicePulse 1.4s ease-in-out infinite}@keyframes voicePulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(1.12)}}.voice-meter{width:100%;max-width:260px;height:6px;border-radius:3px;background:#00000014;overflow:hidden}.voice-meter-fill{height:100%;background:#2e7d32;border-radius:3px;transition:width 80ms linear}.voice-hint{margin:0;font-size:13px;opacity:.6}.voice-exit{width:100%;max-width:280px;height:48px;font-size:15px}.voice-exit mat-icon{margin-right:6px}@media (max-width: 600px){.agent-window{inset:0 0 auto;width:100%;height:100%;height:100dvh;height:var(--agent-vh, 100dvh);border-radius:0}.agent-fab{bottom:var(--tin-fab-bottom, 72px);right:var(--tin-fab-right, 16px)}}.agent-window.page-mode{position:relative;inset:auto;width:100%;max-width:100%;height:calc(var(--agent-vh, 100dvh) - var(--agent-page-offset, 140px));border-radius:12px;border:1px solid #e0e0e0;box-shadow:none;z-index:auto}@media (max-width: 600px){.agent-window.page-mode{height:calc(var(--agent-vh, 100dvh) - var(--agent-page-offset, 72px));border-radius:0;border:none}}.agent-conversations{max-height:180px;overflow-y:auto;border-bottom:1px solid #e0e0e0;background:#fafafa}.conversation-row{display:flex;align-items:center;gap:8px;padding:8px 14px;cursor:pointer;font-size:13px}.conversation-row:hover{background:#eef4fb}.conversation-row.current{background:#e3f0fd;font-weight:500}.conversation-icon{font-size:18px;width:18px;height:18px;opacity:.6}.conversation-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.conversation-empty{padding:10px 14px;font-size:12px;opacity:.6}.agent-session-banner{display:flex;align-items:center;flex-wrap:wrap;gap:6px;padding:6px 14px;background:#fff8e1;border-bottom:1px solid #ffe082;font-size:12px}.session-icon{font-size:16px;width:16px;height:16px;opacity:.7}.session-text{font-weight:500}.session-missing{opacity:.75}.conversation-divider{display:flex;align-items:center;gap:8px;margin:14px 4px;opacity:.55}.conversation-divider:before,.conversation-divider:after{content:\"\";flex:1;height:1px;background:#c8c8c8}.divider-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em}.session-chip{display:inline-block;padding:1px 8px;border-radius:10px;background:#ffe082;font-size:11px;white-space:nowrap}.agent-bubble .md-heading{display:block;margin:4px 0 2px;font-size:13px}.agent-bubble .md-bullet{opacity:.6;margin-right:2px}.agent-bubble code{padding:1px 4px;border-radius:3px;background:#0000000f;font-size:12px}\n"] }]
1354
+ }], ctorParameters: () => [{ type: AgentService }, { type: i1$1.SignalRService }, { type: i0.NgZone }, { type: i1$2.Router }, { type: i2.BreakpointObserver }], propDecorators: { messageContainer: [{
1355
+ type: ViewChild,
1356
+ args: ['messageContainer']
1357
+ }], messageInput: [{
1358
+ type: ViewChild,
1359
+ args: ['messageInput']
1360
+ }], cameraInput: [{
1361
+ type: ViewChild,
1362
+ args: ['cameraInput']
1363
+ }], galleryInput: [{
1364
+ type: ViewChild,
1365
+ args: ['galleryInput']
1366
+ }], pageMode: [{
1367
+ type: Input
1368
+ }], onDocumentClick: [{
1369
+ type: HostListener,
1370
+ args: ['document:click', ['$event']]
1371
+ }] } });
1372
+
1373
+ var agent_component = /*#__PURE__*/Object.freeze({
1374
+ __proto__: null,
1375
+ AgentComponent: AgentComponent
1376
+ });
1377
+
1378
+ export { AgentService as A, AgentComponent as a, agent_component as b };
1379
+ //# sourceMappingURL=tin-spa-agent.component-D0CGGnGZ.mjs.map