tin-spa 20.16.39 → 20.16.46
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.
- package/core/index.d.ts +51 -3
- package/fesm2022/{tin-spa-accounting.module-tLjerUSJ.mjs → tin-spa-accounting.module-DjB3qVz9.mjs} +21 -21
- package/fesm2022/{tin-spa-accounting.module-tLjerUSJ.mjs.map → tin-spa-accounting.module-DjB3qVz9.mjs.map} +1 -1
- package/fesm2022/{tin-spa-agent-page.component-sMhdglOQ.mjs → tin-spa-agent-page.component-BlhxpBi2.mjs} +2 -2
- package/fesm2022/{tin-spa-agent-page.component-sMhdglOQ.mjs.map → tin-spa-agent-page.component-BlhxpBi2.mjs.map} +1 -1
- package/fesm2022/{tin-spa-agent.component-BbAKYiFv.mjs → tin-spa-agent.component-DaRkrwha.mjs} +8 -8
- package/fesm2022/{tin-spa-agent.component-BbAKYiFv.mjs.map → tin-spa-agent.component-DaRkrwha.mjs.map} +1 -1
- package/fesm2022/{tin-spa-assets.module-BB1jV4ni.mjs → tin-spa-assets.module-BP6hHGza.mjs} +2 -2
- package/fesm2022/{tin-spa-assets.module-BB1jV4ni.mjs.map → tin-spa-assets.module-BP6hHGza.mjs.map} +1 -1
- package/fesm2022/tin-spa-core.mjs +1452 -5
- package/fesm2022/tin-spa-core.mjs.map +1 -1
- package/fesm2022/{tin-spa-general-erp.module-urL5So4z.mjs → tin-spa-general-erp.module-XH_c5aVG.mjs} +2 -2
- package/fesm2022/{tin-spa-general-erp.module-urL5So4z.mjs.map → tin-spa-general-erp.module-XH_c5aVG.mjs.map} +1 -1
- package/fesm2022/{tin-spa-hr.module-lMgTujaL.mjs → tin-spa-hr.module-C5W5MFIf.mjs} +2 -2
- package/fesm2022/{tin-spa-hr.module-lMgTujaL.mjs.map → tin-spa-hr.module-C5W5MFIf.mjs.map} +1 -1
- package/fesm2022/{tin-spa-inventory.module-D6E720b3.mjs → tin-spa-inventory.module-C-iKAbI2.mjs} +2 -2
- package/fesm2022/{tin-spa-inventory.module-D6E720b3.mjs.map → tin-spa-inventory.module-C-iKAbI2.mjs.map} +1 -1
- package/fesm2022/tin-spa-landing.mjs +35 -3
- package/fesm2022/tin-spa-landing.mjs.map +1 -1
- package/fesm2022/{tin-spa-loans.module-DPqG5dbT.mjs → tin-spa-loans.module-CyZfKe3B.mjs} +2 -2
- package/fesm2022/{tin-spa-loans.module-DPqG5dbT.mjs.map → tin-spa-loans.module-CyZfKe3B.mjs.map} +1 -1
- package/fesm2022/{tin-spa-manufacturing.module-DphDbjjV.mjs → tin-spa-manufacturing.module-CUDM_9R8.mjs} +2 -2
- package/fesm2022/{tin-spa-manufacturing.module-DphDbjjV.mjs.map → tin-spa-manufacturing.module-CUDM_9R8.mjs.map} +1 -1
- package/fesm2022/{tin-spa-payroll.module-XekyNje-.mjs → tin-spa-payroll.module-DuoFnQwF.mjs} +8 -8
- package/fesm2022/{tin-spa-payroll.module-XekyNje-.mjs.map → tin-spa-payroll.module-DuoFnQwF.mjs.map} +1 -1
- package/fesm2022/{tin-spa-purchasing.module-TY3v_R4Y.mjs → tin-spa-purchasing.module-Dn4nl415.mjs} +2 -2
- package/fesm2022/{tin-spa-purchasing.module-TY3v_R4Y.mjs.map → tin-spa-purchasing.module-Dn4nl415.mjs.map} +1 -1
- package/fesm2022/{tin-spa-sales.module-BjAeQZT1.mjs → tin-spa-sales.module-XkDWimVG.mjs} +2 -2
- package/fesm2022/{tin-spa-sales.module-BjAeQZT1.mjs.map → tin-spa-sales.module-XkDWimVG.mjs.map} +1 -1
- package/fesm2022/{tin-spa-tenancy-erp.module-BPkLuqdQ.mjs → tin-spa-tenancy-erp.module-D3l3PSkN.mjs} +7 -7
- package/fesm2022/{tin-spa-tenancy-erp.module-BPkLuqdQ.mjs.map → tin-spa-tenancy-erp.module-D3l3PSkN.mjs.map} +1 -1
- package/fesm2022/{tin-spa-tin-spa-CCAfYbuE.mjs → tin-spa-tin-spa-BMILOpiX.mjs} +971 -136
- package/fesm2022/tin-spa-tin-spa-BMILOpiX.mjs.map +1 -0
- package/fesm2022/tin-spa.mjs +1 -1
- package/index.d.ts +116 -18
- package/landing/index.d.ts +4 -2
- package/package.json +1 -1
- package/src/tin-styles.css +31 -0
- package/fesm2022/tin-spa-tin-spa-CCAfYbuE.mjs.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { Component } from '@angular/core';
|
|
3
3
|
import { take } from 'rxjs/operators';
|
|
4
|
-
import { A as AgentService, a as AgentComponent } from './tin-spa-agent.component-
|
|
4
|
+
import { A as AgentService, a as AgentComponent } from './tin-spa-agent.component-DaRkrwha.mjs';
|
|
5
5
|
|
|
6
6
|
// Routed chat page (home/agent) — the shared agent widget rendered in page mode. The component is a shell:
|
|
7
7
|
// all chat state lives in the AgentService singleton, which is what lets a conversation started in the
|
|
@@ -36,4 +36,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
36
36
|
}], ctorParameters: () => [{ type: AgentService }] });
|
|
37
37
|
|
|
38
38
|
export { AgentPageComponent };
|
|
39
|
-
//# sourceMappingURL=tin-spa-agent-page.component-
|
|
39
|
+
//# sourceMappingURL=tin-spa-agent-page.component-BlhxpBi2.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tin-spa-agent-page.component-
|
|
1
|
+
{"version":3,"file":"tin-spa-agent-page.component-BlhxpBi2.mjs","sources":["../../../projects/tin-spa/src/lib/pages/agent-page/agent-page.component.ts","../../../projects/tin-spa/src/lib/pages/agent-page/agent-page.component.html"],"sourcesContent":["// Routed chat page (home/agent) — the shared agent widget rendered in page mode. The component is a shell:\n// all chat state lives in the AgentService singleton, which is what lets a conversation started in the\n// desktop floating window continue here unchanged.\nimport { Component, OnInit, OnDestroy } from '@angular/core';\nimport { Subscription } from 'rxjs';\nimport { take } from 'rxjs/operators';\nimport { AgentService } from '../../services/agent.service';\nimport { AgentComponent } from '../../components/agent/agent.component'; // Added (lazy-modularization S7): a standalone host imports its template's components by path — this template renders <spa-agent>\n\n@Component({\n selector: 'spa-agent-page',\n templateUrl: './agent-page.component.html',\n styleUrls: ['./agent-page.component.css'],\n // Changed (lazy-modularization S7): standalone, because the home/agent route now reaches this component\n // through a DYNAMIC import (spa-home-routing.module.ts) and a lazily-loaded route target cannot be an\n // NgModule declaration. TinSpaModule no longer declares it — see that file.\n standalone: true,\n imports: [AgentComponent] // Added (S7): the template's only element, imported by path now that no NgModule supplies it\n})\nexport class AgentPageComponent implements OnInit, OnDestroy {\n\n private subs: Subscription[] = [];\n\n constructor(private agentService: AgentService) {}\n\n ngOnInit(): void {\n // open() is the call that restores the last conversation, so the page needs it — but isOpen is SHARED\n // with the desktop floating window, and forcing it true when a thread is already loaded would leave that\n // window popped open on the next route. So it is called only when nothing has loaded yet, mirroring the\n // service's own hasLoadedInitial guard.\n this.subs.push(this.agentService.currentConversationId$.pipe(take(1)).subscribe(id => {\n if (id === null) this.agentService.open();\n }));\n }\n\n ngOnDestroy(): void {\n // Deliberately no close() here — isOpen belongs to the floating window. Closing it on leaving the page\n // would reach across and shut a chat the user never opened from here.\n this.subs.forEach(s => s.unsubscribe());\n }\n}\n","<!-- No page chrome of its own: the widget header already carries the agent name, online status, conversation\n switcher and new-conversation button, and a second title bar above it would just repeat them. -->\n<spa-agent [pageMode]=\"true\"></spa-agent>\n"],"names":["i1.AgentService"],"mappings":";;;;;AAAA;AACA;AACA;MAiBa,kBAAkB,CAAA;AAI7B,IAAA,WAAA,CAAoB,YAA0B,EAAA;QAA1B,IAAA,CAAA,YAAY,GAAZ,YAAY;QAFxB,IAAA,CAAA,IAAI,GAAmB,EAAE;IAEgB;IAEjD,QAAQ,GAAA;;;;;QAKN,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,IAAG;YACnF,IAAI,EAAE,KAAK,IAAI;AAAE,gBAAA,IAAI,CAAC,YAAY,CAAC,IAAI,EAAE;QAC3C,CAAC,CAAC,CAAC;IACL;IAEA,WAAW,GAAA;;;AAGT,QAAA,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC;IACzC;+GApBW,kBAAkB,EAAA,IAAA,EAAA,CAAA,EAAA,KAAA,EAAAA,YAAA,EAAA,CAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA,CAAA;mGAAlB,kBAAkB,EAAA,YAAA,EAAA,IAAA,EAAA,QAAA,EAAA,gBAAA,EAAA,QAAA,EAAA,EAAA,EAAA,QAAA,ECnB/B,sQAGA,EAAA,MAAA,EAAA,CAAA,iNAAA,CAAA,EAAA,YAAA,EAAA,CAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EDcY,cAAc,EAAA,QAAA,EAAA,WAAA,EAAA,MAAA,EAAA,CAAA,UAAA,CAAA,EAAA,CAAA,EAAA,CAAA,CAAA;;4FAEb,kBAAkB,EAAA,UAAA,EAAA,CAAA;kBAV9B,SAAS;AACE,YAAA,IAAA,EAAA,CAAA,EAAA,QAAA,EAAA,gBAAgB,cAMd,IAAI,EAAA,OAAA,EACP,CAAC,cAAc,CAAC;;;;;;"}
|
package/fesm2022/{tin-spa-agent.component-BbAKYiFv.mjs → tin-spa-agent.component-DaRkrwha.mjs}
RENAMED
|
@@ -6,17 +6,17 @@ import { NavigationEnd } from '@angular/router';
|
|
|
6
6
|
import { trigger, transition, style, animate } from '@angular/animations';
|
|
7
7
|
import * as i1$1 from 'tin-spa/core';
|
|
8
8
|
import { SpaMatModule } from 'tin-spa/core';
|
|
9
|
-
import { g as CamelToWordsPipe } from './tin-spa-tin-spa-
|
|
9
|
+
import { g as CamelToWordsPipe } from './tin-spa-tin-spa-BMILOpiX.mjs';
|
|
10
10
|
import * as i1 from '@angular/platform-browser';
|
|
11
11
|
import { BehaviorSubject } from 'rxjs';
|
|
12
12
|
import * as i2 from '@angular/cdk/layout';
|
|
13
13
|
import * as i1$3 from '@angular/common';
|
|
14
14
|
import * as i2$1 from '@angular/forms';
|
|
15
15
|
import * as i7 from '@angular/material/menu';
|
|
16
|
-
import * as
|
|
17
|
-
import * as i3
|
|
18
|
-
import * as i3$
|
|
19
|
-
import * as i4 from '@angular/material/input';
|
|
16
|
+
import * as i4 from '@angular/material/button';
|
|
17
|
+
import * as i3 from '@angular/material/icon';
|
|
18
|
+
import * as i3$1 from '@angular/material/form-field';
|
|
19
|
+
import * as i4$1 from '@angular/material/input';
|
|
20
20
|
import * as i7$1 from '@angular/material/tooltip';
|
|
21
21
|
|
|
22
22
|
// Renders the agent's replies as the markdown they are actually written in.
|
|
@@ -1316,7 +1316,7 @@ class AgentComponent {
|
|
|
1316
1316
|
}
|
|
1317
1317
|
}
|
|
1318
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: [
|
|
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}.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: i4.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: i4.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4.MatFabButton, selector: "button[mat-fab], a[mat-fab], button[matFab], a[matFab]", inputs: ["extended"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4$1.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
1320
|
trigger('slideUp', [
|
|
1321
1321
|
// Changed: page mode enters in normal flow and must not slide up — a page that animates in like a
|
|
1322
1322
|
// dialog still reads as a dialog. Both empty transitions are declared BEFORE :enter/:leave because the
|
|
@@ -1350,7 +1350,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
1350
1350
|
animate('200ms cubic-bezier(0.4, 0, 0.2, 1)', style({ opacity: 0, transform: 'translateY(20px) scale(0.95)' }))
|
|
1351
1351
|
])
|
|
1352
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"] }]
|
|
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}.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
1354
|
}], ctorParameters: () => [{ type: AgentService }, { type: i1$1.SignalRService }, { type: i0.NgZone }, { type: i1$2.Router }, { type: i2.BreakpointObserver }], propDecorators: { messageContainer: [{
|
|
1355
1355
|
type: ViewChild,
|
|
1356
1356
|
args: ['messageContainer']
|
|
@@ -1376,4 +1376,4 @@ var agent_component = /*#__PURE__*/Object.freeze({
|
|
|
1376
1376
|
});
|
|
1377
1377
|
|
|
1378
1378
|
export { AgentService as A, AgentComponent as a, agent_component as b };
|
|
1379
|
-
//# sourceMappingURL=tin-spa-agent.component-
|
|
1379
|
+
//# sourceMappingURL=tin-spa-agent.component-DaRkrwha.mjs.map
|