From b88e86d36c99e242e6ff478dfd4d948310df575c Mon Sep 17 00:00:00 2001 From: Max P Date: Tue, 24 Feb 2026 13:38:06 -0500 Subject: [PATCH] feat(chat): add dedicated chat page with persistent conversations Add new /chat route with full-page chat interface featuring: - Conversation list sidebar with delete functionality - Newest-first message ordering - Auto-scroll to latest message - Starter prompt buttons - Response details with confidence/citations/verification - Feedback system (helpful/needs work) LocalStorage persistence: - Conversations sorted by updatedAt - Max 50 conversations, 200 messages each - Active conversation tracking Frontend fixes: - Messages display newest on top (reverse chronological) - Auto-scroll to top when new messages arrive - Template ref for scroll control Related issue: Fix chat page UI/UX for natural conversation flow --- .../app/pages/chat/chat-page.component.html | 238 +++++++ .../app/pages/chat/chat-page.component.scss | 161 +++++ .../src/app/pages/chat/chat-page.component.ts | 304 +++++++++ .../src/app/pages/chat/chat-page.routes.ts | 15 + .../ai-chat-conversations.service.spec.ts | 96 +++ .../services/ai-chat-conversations.service.ts | 590 ++++++++++++++++++ .../plans/2026-02-24-fix-chat-page-ui-ux.md | 402 ++++++++++++ 7 files changed, 1806 insertions(+) create mode 100644 apps/client/src/app/pages/chat/chat-page.component.html create mode 100644 apps/client/src/app/pages/chat/chat-page.component.scss create mode 100644 apps/client/src/app/pages/chat/chat-page.component.ts create mode 100644 apps/client/src/app/pages/chat/chat-page.routes.ts create mode 100644 apps/client/src/app/services/ai-chat-conversations.service.spec.ts create mode 100644 apps/client/src/app/services/ai-chat-conversations.service.ts create mode 100644 thoughts/shared/plans/2026-02-24-fix-chat-page-ui-ux.md diff --git a/apps/client/src/app/pages/chat/chat-page.component.html b/apps/client/src/app/pages/chat/chat-page.component.html new file mode 100644 index 000000000..ce5108621 --- /dev/null +++ b/apps/client/src/app/pages/chat/chat-page.component.html @@ -0,0 +1,238 @@ +
+

AI Chat

+ +
+
+ + + + +
+ @for ( + conversation of conversations; + track trackConversationById($index, conversation) + ) { +
+ + + +
+ } +
+
+
+
+ +
+ + + @if (!hasPermissionToReadAiPrompt) { + + } @else { +
+ @for (prompt of starterPrompts; track prompt) { + + } +
+ + + Ask about your portfolio + + + +
+ + @if (isSubmitting) { + + } +
+ + @if (errorMessage) { + + } + +
+ @for (message of visibleMessages; track message.id) { +
+
+ {{ getRoleLabel(message.role) }} + {{ + message.createdAt | date: 'shortTime' + }} + + @if (message.role === 'assistant' && message.response) { + + } +
+
{{ message.content }}
+ + @if (message.feedback) { + + } +
+ } +
+ + +
+ @if (activeResponseDetails; as details) { +
+ Confidence: + {{ details.confidence.score * 100 | number: '1.0-0' }}% + ({{ details.confidence.band }}) +
+ + @if (details.citations.length > 0) { +
+ Citations +
    + @for (citation of details.citations; track $index) { +
  • + {{ citation.source }} + - + {{ citation.snippet }} +
  • + } +
+
+ } + + @if (details.verification.length > 0) { +
+ Verification +
    + @for (check of details.verification; track $index) { +
  • + {{ check.status }} + - + {{ check.check }}: + {{ check.details }} +
  • + } +
+
+ } + + @if (details.observability) { +
+ Observability: + {{ details.observability.latencyInMs }}ms, ~{{ + details.observability.tokenEstimate.total + }} + tokens +
+ } + } @else { + No response details available. + } +
+
+ } +
+
+
+
+
diff --git a/apps/client/src/app/pages/chat/chat-page.component.scss b/apps/client/src/app/pages/chat/chat-page.component.scss new file mode 100644 index 000000000..f89d73340 --- /dev/null +++ b/apps/client/src/app/pages/chat/chat-page.component.scss @@ -0,0 +1,161 @@ +:host { + --ai-chat-assistant-background: rgba(var(--dark-primary-text), 0.03); + --ai-chat-border-color: rgba(var(--dark-primary-text), 0.14); + --ai-chat-message-text: rgb(var(--dark-primary-text)); + --ai-chat-muted-text: rgba(var(--dark-primary-text), 0.7); + --ai-chat-selection-background: rgba(var(--palette-primary-500), 0.45); + --ai-chat-selection-text: rgb(var(--dark-primary-text)); + --ai-chat-user-background: rgba(var(--palette-primary-500), 0.1); + --ai-chat-user-border: rgba(var(--palette-primary-500), 0.3); + display: block; +} + +:host-context(.theme-dark) { + --ai-chat-assistant-background: rgba(var(--light-primary-text), 0.06); + --ai-chat-border-color: rgba(var(--light-primary-text), 0.2); + --ai-chat-message-text: rgb(var(--light-primary-text)); + --ai-chat-muted-text: rgba(var(--light-primary-text), 0.72); + --ai-chat-selection-background: rgba(var(--palette-primary-300), 0.4); + --ai-chat-selection-text: rgb(var(--light-primary-text)); + --ai-chat-user-background: rgba(var(--palette-primary-500), 0.18); + --ai-chat-user-border: rgba(var(--palette-primary-300), 0.45); +} + +.chat-log { + max-height: 36rem; + overflow-y: auto; + padding-right: 0.25rem; +} + +.chat-message { + border: 1px solid var(--ai-chat-border-color); + color: var(--ai-chat-message-text); +} + +.chat-message.assistant { + background: var(--ai-chat-assistant-background); +} + +.chat-message.user { + background: var(--ai-chat-user-background); + border-color: var(--ai-chat-user-border); +} + +.chat-message-content { + color: var(--ai-chat-message-text); + margin-top: 0.25rem; + white-space: pre-wrap; + word-break: break-word; +} + +.chat-message-content::selection, +.chat-message-header::selection, +.response-details-panel::selection, +.response-details-panel li::selection, +.response-details-panel strong::selection, +textarea::selection { + background: var(--ai-chat-selection-background); + color: var(--ai-chat-selection-text); +} + +.chat-message-header { + align-items: center; + color: var(--ai-chat-muted-text) !important; + display: flex; + flex-wrap: wrap; +} + +.chat-details-trigger { + align-items: center; + color: var(--ai-chat-muted-text); + display: inline-flex; + gap: 0.2rem; + height: 1.75rem; + line-height: 1; + min-width: 0; + padding: 0 0.4rem; +} + +.chat-details-trigger mat-icon { + font-size: 0.95rem; + height: 0.95rem; + width: 0.95rem; +} + +.conversation-list { + max-height: 42rem; + overflow-y: auto; + padding-right: 0.25rem; +} + +.conversation-select { + border-color: var(--ai-chat-border-color); + min-height: 3.5rem; +} + +.conversation-select.active { + background: rgba(var(--palette-primary-500), 0.12); + border-color: var(--ai-chat-user-border); +} + +.conversation-title { + color: var(--ai-chat-message-text); + font-size: 0.95rem; + font-weight: 500; +} + +.conversation-meta { + color: var(--ai-chat-muted-text) !important; + font-size: 0.75rem; +} + +.conversation-delete { + color: var(--ai-chat-muted-text); + flex-shrink: 0; +} + +.prompt-list { + gap: 0.25rem; +} + +.role-label { + letter-spacing: 0.03em; +} + +.feedback-controls { + gap: 0.25rem; +} + +.response-details-panel { + color: var(--ai-chat-message-text); + max-height: min(24rem, calc(100vh - 8rem)); + max-width: min(26rem, calc(100vw - 2rem)); + min-width: min(18rem, calc(100vw - 2rem)); + overflow-y: auto; + white-space: normal; +} + +.response-details-section { + color: var(--ai-chat-muted-text); + font-size: 0.85rem; +} + +.response-details-section + .response-details-section { + border-top: 1px solid var(--ai-chat-border-color); + margin-top: 0.75rem; + padding-top: 0.75rem; +} + +.response-details-list { + margin-top: 0.25rem; +} + +.response-details-list li + li { + margin-top: 0.25rem; +} + +@media (max-width: 991.98px) { + .conversation-list { + max-height: 16rem; + } +} diff --git a/apps/client/src/app/pages/chat/chat-page.component.ts b/apps/client/src/app/pages/chat/chat-page.component.ts new file mode 100644 index 000000000..e38cf2831 --- /dev/null +++ b/apps/client/src/app/pages/chat/chat-page.component.ts @@ -0,0 +1,304 @@ +import { + AiChatConversation, + AiChatConversationsService, + AiChatMessage +} from '@ghostfolio/client/services/ai-chat-conversations.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { AiAgentChatResponse } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { DataService } from '@ghostfolio/ui/services'; + +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit, AfterViewInit, ViewChild, ElementRef } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { Subject } from 'rxjs'; +import { finalize, takeUntil } from 'rxjs/operators'; + +@Component({ + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatCardModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatMenuModule, + MatProgressSpinnerModule + ], + selector: 'gf-chat-page', + styleUrls: ['./chat-page.component.scss'], + templateUrl: './chat-page.component.html' +}) +export class GfChatPageComponent implements AfterViewInit, OnDestroy, OnInit { + @ViewChild('chatLogContainer', { static: false }) + chatLogContainer: ElementRef; + public readonly assistantRoleLabel = $localize`Assistant`; + public activeResponseDetails: AiAgentChatResponse | undefined; + public conversations: AiChatConversation[] = []; + public currentConversation: AiChatConversation | undefined; + public errorMessage: string; + public hasPermissionToReadAiPrompt = false; + public isSubmitting = false; + public query = ''; + public readonly starterPrompts = [ + $localize`Give me a portfolio risk summary.`, + $localize`What are my top concentration risks right now?`, + $localize`Show me the latest market prices for my top holdings.` + ]; + public readonly userRoleLabel = $localize`You`; + + private unsubscribeSubject = new Subject(); + + public constructor( + private readonly aiChatConversationsService: AiChatConversationsService, + private readonly dataService: DataService, + private readonly userService: UserService + ) {} + + public ngOnInit() { + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + this.hasPermissionToReadAiPrompt = hasPermission( + state?.user?.permissions, + permissions.readAiPrompt + ); + }); + + this.aiChatConversationsService + .getConversations() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((conversations) => { + this.conversations = conversations; + }); + + this.aiChatConversationsService + .getCurrentConversation() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((conversation) => { + this.currentConversation = conversation; + this.activeResponseDetails = undefined; + this.scrollToTop(); + }); + + if (this.aiChatConversationsService.getConversationsSnapshot().length === 0) { + this.aiChatConversationsService.createConversation(); + } + } + + public ngAfterViewInit() { + this.scrollToTop(); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private scrollToTop() { + if (this.chatLogContainer) { + this.chatLogContainer.nativeElement.scrollTop = 0; + } + } + + public get visibleMessages() { + const messages = this.currentConversation?.messages ?? []; + return [...messages].reverse(); + } + + public getRoleLabel(role: AiChatMessage['role']) { + return role === 'assistant' ? this.assistantRoleLabel : this.userRoleLabel; + } + + public onDeleteConversation(event: Event, conversationId: string) { + event.stopPropagation(); + + this.aiChatConversationsService.deleteConversation(conversationId); + + if (this.aiChatConversationsService.getConversationsSnapshot().length === 0) { + this.aiChatConversationsService.createConversation(); + } + } + + public onNewChat() { + this.errorMessage = undefined; + this.query = ''; + this.aiChatConversationsService.createConversation(); + } + + public onOpenResponseDetails(response?: AiAgentChatResponse) { + this.activeResponseDetails = response; + } + + public onRateResponse({ + messageId, + rating + }: { + messageId: number; + rating: 'down' | 'up'; + }) { + const conversation = this.currentConversation; + + if (!conversation) { + return; + } + + const message = conversation.messages.find(({ id }) => { + return id === messageId; + }); + + if (!message?.response?.memory?.sessionId) { + return; + } + + if (message.feedback?.isSubmitting || message.feedback?.rating) { + return; + } + + this.aiChatConversationsService.updateMessage({ + conversationId: conversation.id, + messageId, + updater: (currentMessage) => { + return { + ...currentMessage, + feedback: { + ...currentMessage.feedback, + isSubmitting: true + } + }; + } + }); + + this.dataService + .postAiChatFeedback({ + rating, + sessionId: message.response.memory.sessionId + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: ({ feedbackId }) => { + this.aiChatConversationsService.updateMessage({ + conversationId: conversation.id, + messageId, + updater: (currentMessage) => { + return { + ...currentMessage, + feedback: { + feedbackId, + isSubmitting: false, + rating + } + }; + } + }); + }, + error: () => { + this.aiChatConversationsService.updateMessage({ + conversationId: conversation.id, + messageId, + updater: (currentMessage) => { + return { + ...currentMessage, + feedback: { + ...currentMessage.feedback, + isSubmitting: false + } + }; + } + }); + } + }); + } + + public onSelectConversation(conversationId: string) { + this.errorMessage = undefined; + this.query = ''; + this.aiChatConversationsService.selectConversation(conversationId); + } + + public onSelectStarterPrompt(prompt: string) { + this.query = prompt; + } + + public onSubmit() { + const normalizedQuery = this.query?.trim(); + + if ( + !this.hasPermissionToReadAiPrompt || + this.isSubmitting || + !normalizedQuery + ) { + return; + } + + const conversation = + this.currentConversation ?? this.aiChatConversationsService.createConversation(); + + this.aiChatConversationsService.appendUserMessage({ + content: normalizedQuery, + conversationId: conversation.id + }); + + this.errorMessage = undefined; + this.isSubmitting = true; + this.query = ''; + + this.dataService + .postAiChat({ + query: normalizedQuery, + sessionId: conversation.sessionId + }) + .pipe( + finalize(() => { + this.isSubmitting = false; + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe({ + next: (response) => { + this.aiChatConversationsService.setConversationSessionId({ + conversationId: conversation.id, + sessionId: response.memory.sessionId + }); + this.aiChatConversationsService.appendAssistantMessage({ + content: response.answer, + conversationId: conversation.id, + feedback: { + isSubmitting: false + }, + response + }); + }, + error: () => { + this.errorMessage = $localize`AI request failed. Check your model quota and permissions.`; + + this.aiChatConversationsService.appendAssistantMessage({ + content: $localize`Request failed. Please retry.`, + conversationId: conversation.id + }); + } + }); + } + + public onSubmitFromKeyboard(event: KeyboardEvent) { + if (!event.shiftKey) { + this.onSubmit(); + event.preventDefault(); + } + } + + public trackConversationById( + _index: number, + conversation: AiChatConversation + ) { + return conversation.id; + } +} diff --git a/apps/client/src/app/pages/chat/chat-page.routes.ts b/apps/client/src/app/pages/chat/chat-page.routes.ts new file mode 100644 index 000000000..142f2f872 --- /dev/null +++ b/apps/client/src/app/pages/chat/chat-page.routes.ts @@ -0,0 +1,15 @@ +import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; +import { internalRoutes } from '@ghostfolio/common/routes/routes'; + +import { Routes } from '@angular/router'; + +import { GfChatPageComponent } from './chat-page.component'; + +export const routes: Routes = [ + { + canActivate: [AuthGuard], + component: GfChatPageComponent, + path: '', + title: internalRoutes.chat.title + } +]; diff --git a/apps/client/src/app/services/ai-chat-conversations.service.spec.ts b/apps/client/src/app/services/ai-chat-conversations.service.spec.ts new file mode 100644 index 000000000..50b7fbfd5 --- /dev/null +++ b/apps/client/src/app/services/ai-chat-conversations.service.spec.ts @@ -0,0 +1,96 @@ +import { TestBed } from '@angular/core/testing'; + +import { AiChatConversationsService } from './ai-chat-conversations.service'; + +describe('AiChatConversationsService', () => { + let service: AiChatConversationsService; + + beforeEach(() => { + localStorage.clear(); + + TestBed.configureTestingModule({}); + service = TestBed.inject(AiChatConversationsService); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('creates and selects a new conversation', () => { + const createdConversation = service.createConversation(); + + expect(service.getConversationsSnapshot()).toHaveLength(1); + expect(service.getCurrentConversationSnapshot()?.id).toBe(createdConversation.id); + expect(createdConversation.title).toBe('New Chat'); + }); + + it('derives title from first user message and falls back for generic prompts', () => { + const detailedConversation = service.createConversation(); + service.appendUserMessage({ + content: 'Help me rebalance my holdings for lower concentration risk.', + conversationId: detailedConversation.id + }); + + const updatedDetailedConversation = service.getCurrentConversationSnapshot(); + + expect(updatedDetailedConversation?.title).toBe( + 'Help me rebalance my holdings for lower concentr...' + ); + + const genericConversation = service.createConversation(); + service.appendUserMessage({ + content: 'hi', + conversationId: genericConversation.id + }); + + expect(service.getCurrentConversationSnapshot()?.title).toBe('New Chat'); + }); + + it('starts new chats with fresh context and keeps per-conversation session memory', () => { + const firstConversation = service.createConversation(); + service.setConversationSessionId({ + conversationId: firstConversation.id, + sessionId: 'session-1' + }); + + const secondConversation = service.createConversation(); + + expect(service.getCurrentConversationSnapshot()?.id).toBe(secondConversation.id); + expect(service.getCurrentConversationSnapshot()?.sessionId).toBeUndefined(); + + service.selectConversation(firstConversation.id); + + expect(service.getCurrentConversationSnapshot()?.sessionId).toBe('session-1'); + }); + + it('restores conversations and active selection from local storage', () => { + const firstConversation = service.createConversation(); + service.appendUserMessage({ + content: 'first chat message', + conversationId: firstConversation.id + }); + service.setConversationSessionId({ + conversationId: firstConversation.id, + sessionId: 'session-first' + }); + + const secondConversation = service.createConversation(); + service.appendUserMessage({ + content: 'second chat message', + conversationId: secondConversation.id + }); + + const restoredService = new AiChatConversationsService(); + + expect(restoredService.getConversationsSnapshot()).toHaveLength(2); + expect(restoredService.getCurrentConversationSnapshot()?.id).toBe( + secondConversation.id + ); + + restoredService.selectConversation(firstConversation.id); + + expect(restoredService.getCurrentConversationSnapshot()?.sessionId).toBe( + 'session-first' + ); + }); +}); diff --git a/apps/client/src/app/services/ai-chat-conversations.service.ts b/apps/client/src/app/services/ai-chat-conversations.service.ts new file mode 100644 index 000000000..aac174b6e --- /dev/null +++ b/apps/client/src/app/services/ai-chat-conversations.service.ts @@ -0,0 +1,590 @@ +import { AiAgentChatResponse } from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@angular/core'; +import { BehaviorSubject, combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface AiChatFeedbackState { + feedbackId?: string; + isSubmitting: boolean; + rating?: 'down' | 'up'; +} + +export interface AiChatMessage { + content: string; + createdAt: Date; + feedback?: AiChatFeedbackState; + id: number; + response?: AiAgentChatResponse; + role: 'assistant' | 'user'; +} + +export interface AiChatConversation { + createdAt: Date; + id: string; + messages: AiChatMessage[]; + nextMessageId: number; + sessionId?: string; + title: string; + updatedAt: Date; +} + +type StoredAiChatMessage = Omit & { + createdAt: string; +}; + +type StoredAiChatConversation = Omit< + AiChatConversation, + 'createdAt' | 'messages' | 'updatedAt' +> & { + createdAt: string; + messages: StoredAiChatMessage[]; + updatedAt: string; +}; + +@Injectable({ + providedIn: 'root' +}) +export class AiChatConversationsService { + private readonly STORAGE_KEY_ACTIVE_CONVERSATION_ID = + 'gf_ai_chat_active_conversation_id_v1'; + private readonly STORAGE_KEY_CONVERSATIONS = 'gf_ai_chat_conversations_v1'; + private readonly DEFAULT_CONVERSATION_TITLE = 'New Chat'; + private readonly GENERIC_FIRST_MESSAGE_PATTERN = + /^(hi|hello|hey|yo|hola|new chat|start)$/i; + private readonly MAX_STORED_CONVERSATIONS = 50; + private readonly MAX_STORED_MESSAGES = 200; + + private activeConversationIdSubject = new BehaviorSubject( + undefined + ); + private conversationsSubject = new BehaviorSubject([]); + + public constructor() { + this.restoreState(); + } + + public appendAssistantMessage({ + content, + conversationId, + feedback, + response + }: { + content: string; + conversationId: string; + feedback?: AiChatFeedbackState; + response?: AiAgentChatResponse; + }) { + return this.appendMessage({ + content, + conversationId, + feedback, + response, + role: 'assistant' + }); + } + + public appendUserMessage({ + content, + conversationId + }: { + content: string; + conversationId: string; + }) { + return this.appendMessage({ + content, + conversationId, + role: 'user' + }); + } + + public createConversation({ + select = true, + title + }: { + select?: boolean; + title?: string; + } = {}): AiChatConversation { + const now = new Date(); + const conversation: AiChatConversation = { + createdAt: now, + id: this.getConversationId(), + messages: [], + nextMessageId: 0, + title: title?.trim() || this.DEFAULT_CONVERSATION_TITLE, + updatedAt: now + }; + + const conversations = this.conversationsSubject.getValue(); + this.setState({ + activeConversationId: + select || !this.activeConversationIdSubject.getValue() + ? conversation.id + : this.activeConversationIdSubject.getValue(), + conversations: [conversation, ...conversations] + }); + + return conversation; + } + + public deleteConversation(id: string) { + const conversations = this.conversationsSubject + .getValue() + .filter((conversation) => { + return conversation.id !== id; + }); + + const activeConversationId = this.activeConversationIdSubject.getValue(); + + this.setState({ + activeConversationId: + activeConversationId === id ? conversations[0]?.id : activeConversationId, + conversations + }); + } + + public getActiveConversationId() { + return this.activeConversationIdSubject.asObservable(); + } + + public getConversations() { + return this.conversationsSubject.asObservable(); + } + + public getConversationsSnapshot() { + return this.conversationsSubject.getValue(); + } + + public getCurrentConversation() { + return combineLatest([ + this.conversationsSubject, + this.activeConversationIdSubject + ]).pipe( + map(([conversations, activeConversationId]) => { + return conversations.find(({ id }) => { + return id === activeConversationId; + }); + }) + ); + } + + public getCurrentConversationSnapshot() { + const activeConversationId = this.activeConversationIdSubject.getValue(); + + return this.conversationsSubject.getValue().find(({ id }) => { + return id === activeConversationId; + }); + } + + public renameConversation({ id, title }: { id: string; title: string }) { + return this.updateConversation(id, (conversation) => { + return { + ...conversation, + title: title.trim() || this.DEFAULT_CONVERSATION_TITLE, + updatedAt: new Date() + }; + }); + } + + public selectConversation(id: string) { + const hasConversation = this.conversationsSubject.getValue().some((conversation) => { + return conversation.id === id; + }); + + if (!hasConversation) { + return false; + } + + this.setState({ + activeConversationId: id, + conversations: this.conversationsSubject.getValue() + }); + + return true; + } + + public setConversationSessionId({ + conversationId, + sessionId + }: { + conversationId: string; + sessionId: string; + }) { + return this.updateConversation(conversationId, (conversation) => { + return { + ...conversation, + sessionId, + updatedAt: new Date() + }; + }); + } + + public updateMessage({ + conversationId, + messageId, + updater + }: { + conversationId: string; + messageId: number; + updater: (message: AiChatMessage) => AiChatMessage; + }) { + return this.updateConversation(conversationId, (conversation) => { + const messageIndex = conversation.messages.findIndex(({ id }) => { + return id === messageId; + }); + + if (messageIndex < 0) { + return conversation; + } + + const updatedMessages = conversation.messages.map((message, index) => { + return index === messageIndex ? updater(message) : message; + }); + + return { + ...conversation, + messages: updatedMessages + }; + }); + } + + private appendMessage({ + content, + conversationId, + feedback, + response, + role + }: { + content: string; + conversationId: string; + feedback?: AiChatFeedbackState; + response?: AiAgentChatResponse; + role: AiChatMessage['role']; + }) { + let appendedMessage: AiChatMessage | undefined; + + this.updateConversation(conversationId, (conversation) => { + const now = new Date(); + appendedMessage = { + content, + createdAt: now, + feedback, + id: conversation.nextMessageId, + response, + role + }; + + const hasExistingUserMessage = conversation.messages.some((message) => { + return message.role === 'user'; + }); + + return { + ...conversation, + messages: [...conversation.messages, appendedMessage].slice( + -this.MAX_STORED_MESSAGES + ), + nextMessageId: conversation.nextMessageId + 1, + title: + role === 'user' && !hasExistingUserMessage + ? this.getConversationTitleFromFirstMessage(content) + : conversation.title, + updatedAt: now + }; + }); + + return appendedMessage; + } + + private getConversationId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return `conversation-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + } + + private getConversationTitleFromFirstMessage(content: string) { + const normalized = this.stripMarkdown(content) + .replace(/\s+/g, ' ') + .trim(); + + if ( + normalized.length < 10 || + this.GENERIC_FIRST_MESSAGE_PATTERN.test(normalized) || + this.isEmojiOnly(normalized) + ) { + return this.DEFAULT_CONVERSATION_TITLE; + } + + if (normalized.length > 48) { + return `${normalized.slice(0, 48).trimEnd()}...`; + } + + return normalized; + } + + private getStorage() { + try { + return globalThis.localStorage; + } catch { + return undefined; + } + } + + private isEmojiOnly(content: string) { + return /^\p{Emoji}+$/u.test(content.replace(/\s+/g, '')); + } + + private persistState() { + const storage = this.getStorage(); + + if (!storage) { + return; + } + + try { + storage.setItem( + this.STORAGE_KEY_CONVERSATIONS, + JSON.stringify( + this.conversationsSubject.getValue().map((conversation) => { + return this.toStoredConversation(conversation); + }) + ) + ); + + const activeConversationId = this.activeConversationIdSubject.getValue(); + + if (activeConversationId) { + storage.setItem(this.STORAGE_KEY_ACTIVE_CONVERSATION_ID, activeConversationId); + } else { + storage.removeItem(this.STORAGE_KEY_ACTIVE_CONVERSATION_ID); + } + } catch { + // Keep chat usable when browser storage is unavailable or full. + } + } + + private restoreState() { + const storage = this.getStorage(); + + if (!storage) { + return; + } + + const rawConversations = storage.getItem(this.STORAGE_KEY_CONVERSATIONS); + + if (!rawConversations) { + return; + } + + try { + const parsed = JSON.parse(rawConversations) as unknown; + + if (!Array.isArray(parsed)) { + return; + } + + const conversations = parsed + .map((conversation) => { + return this.toConversation(conversation); + }) + .filter((conversation): conversation is AiChatConversation => { + return Boolean(conversation); + }); + + const sortedConversations = this.sortConversations(conversations).slice( + 0, + this.MAX_STORED_CONVERSATIONS + ); + + const activeConversationId = storage.getItem( + this.STORAGE_KEY_ACTIVE_CONVERSATION_ID + ); + const hasActiveConversation = sortedConversations.some((conversation) => { + return conversation.id === activeConversationId; + }); + + this.conversationsSubject.next(sortedConversations); + this.activeConversationIdSubject.next( + hasActiveConversation ? activeConversationId : sortedConversations[0]?.id + ); + } catch { + storage.removeItem(this.STORAGE_KEY_ACTIVE_CONVERSATION_ID); + storage.removeItem(this.STORAGE_KEY_CONVERSATIONS); + } + } + + private setState({ + activeConversationId, + conversations + }: { + activeConversationId?: string; + conversations: AiChatConversation[]; + }) { + const sortedConversations = this.sortConversations(conversations).slice( + 0, + this.MAX_STORED_CONVERSATIONS + ); + + const hasActiveConversation = sortedConversations.some((conversation) => { + return conversation.id === activeConversationId; + }); + + this.conversationsSubject.next(sortedConversations); + this.activeConversationIdSubject.next( + hasActiveConversation ? activeConversationId : sortedConversations[0]?.id + ); + this.persistState(); + } + + private sortConversations(conversations: AiChatConversation[]) { + return [...conversations].sort((a, b) => { + return b.updatedAt.getTime() - a.updatedAt.getTime(); + }); + } + + private stripMarkdown(content: string) { + return content + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`([^`]*)`/g, '$1') + .replace(/(\[|\]|_|#|>|~|\*)/g, ' ') + .trim(); + } + + private toConversation( + conversation: unknown + ): AiChatConversation | undefined { + if (!conversation || typeof conversation !== 'object') { + return undefined; + } + + const storedConversation = conversation as Partial; + + if ( + typeof storedConversation.id !== 'string' || + typeof storedConversation.title !== 'string' || + typeof storedConversation.createdAt !== 'string' || + typeof storedConversation.updatedAt !== 'string' || + !Array.isArray(storedConversation.messages) + ) { + return undefined; + } + + const createdAt = new Date(storedConversation.createdAt); + const updatedAt = new Date(storedConversation.updatedAt); + + if ( + Number.isNaN(createdAt.getTime()) || + Number.isNaN(updatedAt.getTime()) || + (storedConversation.sessionId && + typeof storedConversation.sessionId !== 'string') + ) { + return undefined; + } + + const messages = storedConversation.messages + .map((message) => { + return this.toMessage(message); + }) + .filter((message): message is AiChatMessage => { + return Boolean(message); + }) + .slice(-this.MAX_STORED_MESSAGES); + + const nextMessageId = + Math.max( + typeof storedConversation.nextMessageId === 'number' + ? storedConversation.nextMessageId + : -1, + messages.reduce((maxId, message) => { + return Math.max(maxId, message.id); + }, -1) + 1 + ) || 0; + + return { + createdAt, + id: storedConversation.id, + messages, + nextMessageId, + sessionId: storedConversation.sessionId?.trim() || undefined, + title: storedConversation.title.trim() || this.DEFAULT_CONVERSATION_TITLE, + updatedAt + }; + } + + private toMessage(message: unknown): AiChatMessage | undefined { + if (!message || typeof message !== 'object') { + return undefined; + } + + const storedMessage = message as Partial; + + if ( + typeof storedMessage.content !== 'string' || + typeof storedMessage.id !== 'number' || + typeof storedMessage.createdAt !== 'string' || + (storedMessage.role !== 'assistant' && storedMessage.role !== 'user') + ) { + return undefined; + } + + const createdAt = new Date(storedMessage.createdAt); + + if (Number.isNaN(createdAt.getTime())) { + return undefined; + } + + return { + content: storedMessage.content, + createdAt, + feedback: storedMessage.feedback, + id: storedMessage.id, + response: storedMessage.response, + role: storedMessage.role + }; + } + + private toStoredConversation( + conversation: AiChatConversation + ): StoredAiChatConversation { + return { + ...conversation, + createdAt: conversation.createdAt.toISOString(), + messages: conversation.messages.map((message) => { + return { + ...message, + createdAt: message.createdAt.toISOString() + }; + }), + updatedAt: conversation.updatedAt.toISOString() + }; + } + + private updateConversation( + id: string, + updater: (conversation: AiChatConversation) => AiChatConversation + ) { + let hasUpdatedConversation = false; + + const conversations = this.conversationsSubject.getValue().map((conversation) => { + if (conversation.id !== id) { + return conversation; + } + + hasUpdatedConversation = true; + + return updater(conversation); + }); + + if (!hasUpdatedConversation) { + return false; + } + + this.setState({ + activeConversationId: this.activeConversationIdSubject.getValue(), + conversations + }); + + return true; + } +} diff --git a/thoughts/shared/plans/2026-02-24-fix-chat-page-ui-ux.md b/thoughts/shared/plans/2026-02-24-fix-chat-page-ui-ux.md new file mode 100644 index 000000000..39ef22945 --- /dev/null +++ b/thoughts/shared/plans/2026-02-24-fix-chat-page-ui-ux.md @@ -0,0 +1,402 @@ +# Chat Page UI/UX Fixes - Implementation Plan + +## Problem Statement + +The newly implemented dedicated chat page (`/chat`) has two critical UX issues that make it feel unnatural and hard to use: + +### Issue 1: Message Ordering +**Current behavior:** Messages appear with oldest on top, newest on bottom (standard chat log order). +**Expected behavior:** Newest messages should appear on top, oldest on bottom (reverse chronological). +**Impact:** Users have to scroll to see the latest response, which is the opposite of what they expect in a conversation view. + +### Issue 2: Robotic System Prompts +**Current behavior:** Generic queries like "hi", "hello", or "remember my name is Max" trigger canned, robotic responses like: +``` +I am Ghostfolio AI. I can help with portfolio analysis, concentration risk, market prices, diversification options, and stress scenarios. +Try one of these: +- "Show my top holdings" +- "What is my concentration risk?" +- "Help me diversify with actionable options" +``` + +**Expected behavior:** Natural, conversational responses that acknowledge the user's input in a friendly way. For example: +- User: "remember my name is Max" → Assistant: "Got it, Max! I'll remember that. What would you like to know about your portfolio?" +- User: "hi" → Assistant: "Hello! I'm here to help with your portfolio. What's on your mind today?" + +**Impact:** The current responses feel impersonal and automated, breaking the conversational flow and making users feel like they're interacting with a script rather than an assistant. + +## Root Cause Analysis + +### Message Ordering + +**Location:** `apps/client/src/app/pages/chat/chat-page.component.ts:99-101` + +```typescript +public get visibleMessages() { + return [...(this.currentConversation?.messages ?? [])].reverse(); +} +``` + +The code already reverses messages, but this is being applied to the message array **before** it's displayed. The issue is that `.reverse()` reverses the array in place and returns the same array reference, which can cause issues with Angular's change detection. Additionally, the CSS or layout may be positioning messages incorrectly. + +**Verification needed:** +1. Confirm the actual order of messages in the DOM +2. Check if CSS is affecting visual order vs DOM order +3. Verify Angular's trackBy function is working correctly with reversed arrays + +### Robotic System Prompts + +**Location:** `apps/api/src/app/endpoints/ai/ai-agent.policy.utils.ts:336-342, 466` + +The `createNoToolDirectResponse()` function returns canned responses for queries that don't require tools. This is triggered when: +1. User sends a greeting or generic message +2. No tools are planned (`plannedTools.length === 0`) +3. Policy route is `'direct'` with `blockReason: 'no_tool_query'` + +The responses are intentionally generic and informative, but they don't feel conversational or acknowledge the user's specific input. + +## Solution Architecture + +### Phase 1: Fix Message Ordering (Quick Win) + +#### 1.1 Update Message Display Logic + +**File:** `apps/client/src/app/pages/chat/chat-page.component.ts` + +Change: +```typescript +public get visibleMessages() { + return [...(this.currentConversation?.messages ?? [])].reverse(); +} +``` + +To: +```typescript +public get visibleMessages() { + // Create a copy and reverse for newest-first display + const messages = this.currentConversation?.messages ?? []; + return [...messages].reverse(); +} +``` + +**Verification:** +1. Test with 1, 5, 10+ messages +2. Verify new messages appear at top immediately after submission +3. Confirm scroll position behavior (should stay at top or auto-scroll to newest) +4. Check that trackBy function still works correctly + +#### 1.2 Add Auto-Scroll to Newest Message + +**File:** `apps/client/src/app/pages/chat/chat-page.component.ts` + +```typescript +import { ElementRef, ViewChild, AfterViewInit } from '@angular/core'; + +export class GfChatPageComponent implements OnDestroy, OnInit, AfterViewInit { + @ViewChild('chatLogContainer', { static: false }) + chatLogContainer: ElementRef; + + // ... existing code ... + + ngAfterViewInit() { + // Scroll to top (newest message) when messages change + this.visibleMessages; // Trigger change detection + } + + private scrollToTop() { + if (this.chatLogContainer) { + this.chatLogContainer.nativeElement.scrollTop = 0; + } + } +} +``` + +**Template update:** `apps/client/src/app/pages/chat/chat-page.component.html` + +```html +
+ +
+``` + +### Phase 2: Natural Language Responses for Non-Tool Queries + +#### 2.1 Update Backend Response Generation + +**File:** `apps/api/src/app/endpoints/ai/ai-agent.policy.utils.ts` + +Replace the `createNoToolDirectResponse()` function to generate more natural, contextual responses: + +```typescript +export function createNoToolDirectResponse(query: string): string { + const normalizedQuery = query.toLowerCase().trim(); + + // Greeting patterns + const greetingPatterns = [ + /^(hi|hello|hey|hiya|greetings)/i, + /^(good (morning|afternoon|evening))/i, + /^(how are you|how's it going|what's up)/i + ]; + + // Name introduction patterns + const nameIntroductionPatterns = [ + /(?:my name is|i'm|i am|call me)\s+(\w+)/i, + /remember (?:that )?my name is\s+(\w+)/i + ]; + + // Check for greeting + if (greetingPatterns.some(pattern => pattern.test(normalizedQuery))) { + const greetings = [ + "Hello! I'm here to help with your portfolio analysis. What would you like to know?", + "Hi! I can help you understand your portfolio better. What's on your mind?", + "Hey there! Ready to dive into your portfolio? Just ask!" + ]; + return greetings[Math.floor(Math.random() * greetings.length)]; + } + + // Check for name introduction + const nameMatch = nameIntroductionPatterns.find(pattern => + pattern.test(normalizedQuery) + ); + if (nameMatch) { + const match = normalizedQuery.match(nameMatch); + const name = match?.[1]; + if (name) { + return `Nice to meet you, ${name.charAt(0).toUpperCase() + name.slice(1)}! I've got that saved. What would you like to know about your portfolio today?`; + } + } + + // Default helpful response (more conversational) + const defaults = [ + "I'm here to help with your portfolio! You can ask me things like 'Show my top holdings' or 'What's my concentration risk?'", + "Sure! I can analyze your portfolio, check concentration risks, look up market prices, and more. What would you like to explore?", + "I'd be happy to help! Try asking about your holdings, risk analysis, or market data for your investments." + ]; + + return defaults[Math.floor(Math.random() * defaults.length)]; +} +``` + +#### 2.2 Add Context Awareness for Follow-up Queries + +For users who say "thanks" or "ok" after a previous interaction, acknowledge it conversationally: + +```typescript +// Add to createNoToolDirectResponse +const acknowledgmentPatterns = [ + /^(thanks|thank you|thx|ty|ok|okay|great|awesome)/i +]; + +if (acknowledgmentPatterns.some(pattern => pattern.test(normalizedQuery))) { + const acknowledgments = [ + "You're welcome! Let me know if you need anything else.", + "Happy to help! What else would you like to know?", + "Anytime! Feel free to ask if you have more questions." + ]; + return acknowledgments[Math.floor(Math.random() * acknowledgments.length)]; +} +``` + +#### 2.3 Update Memory to Track User Name + +When a user introduces themselves, store this in user preferences so it can be used in future responses: + +**File:** `apps/api/src/app/endpoints/ai/ai-agent.chat.helpers.ts` + +Extend the `AiAgentUserPreferenceState` interface: + +```typescript +export interface AiAgentUserPreferenceState { + name?: string; // Add this + responseStyle?: 'concise' | 'detailed'; + updatedAt?: string; +} +``` + +Update `resolvePreferenceUpdate()` to extract and store user names: + +```typescript +export function resolvePreferenceUpdate({ + query, + userPreferences +}: { + query: string; + userPreferences?: AiAgentUserPreferenceState; +}): { + acknowledgement?: string; + userPreferences: AiAgentUserPreferenceState; +} { + const normalizedQuery = query.toLowerCase().trim(); + const nameMatch = normalizedQuery.match(/(?:my name is|i'm|i am|call me)\s+(\w+)/i); + + let name = userPreferences?.name; + if (nameMatch && nameMatch[1]) { + name = nameMatch[1]; + } + + return { + userPreferences: { + ...userPreferences, + name, + responseStyle: userPreferences?.responseStyle, + updatedAt: new Date().toISOString() + } + }; +} +``` + +Then personalize responses when we know the user's name: + +```typescript +// In createNoToolDirectResponse or buildAnswer +if (userPreferences?.name) { + return `Hi ${userPreferences.name}! ${restOfResponse}`; +} +``` + +## Implementation Steps + +### Step 1: Message Ordering Fix (Frontend) +1. Update `chat-page.component.ts` to properly reverse message array +2. Add `AfterViewInit` hook and scroll-to-top logic +3. Update template with `#chatLogContainer` reference +4. Test with various message counts +5. Verify accessibility (aria-live still works correctly) + +### Step 2: Natural Language Responses (Backend) +1. Refactor `createNoToolDirectResponse()` in `ai-agent.policy.utils.ts` +2. Add greeting, acknowledgment, and name-introduction pattern matching +3. Add randomness to responses for variety +4. Write unit tests for new response patterns +5. Test with user queries from evals dataset + +### Step 3: User Name Memory (Backend) +1. Extend `AiAgentUserPreferenceState` interface with `name` field +2. Update `resolvePreferenceUpdate()` to extract names +3. Update `setUserPreferences()` to store names in Redis +4. Personalize responses when name is available +5. Add tests for name extraction and storage + +### Step 4: Integration Testing +1. End-to-end test: full conversation flow with greetings +2. Verify message ordering works correctly +3. Test name memory across multiple queries +4. Test with existing evals dataset (ensure no regressions) +5. Manual QA: test natural language feels conversational + +## Success Criteria + +### Message Ordering +- [ ] Newest messages appear at top of chat log +- [ ] New messages immediately appear at top after submission +- [ ] Scroll position stays at top (or auto-scrolls to newest) +- [ ] Works correctly with 1, 5, 10, 50+ messages +- [ ] Angular change detection works efficiently +- [ ] Accessibility (screen readers) still function correctly + +### Natural Language Responses +- [ ] Greetings ("hi", "hello") return friendly, varied responses +- [ ] Name introduction ("my name is Max") acknowledges the name +- [ ] Acknowledgments ("thanks", "ok") return polite follow-ups +- [ ] Default non-tool queries are more conversational +- [ ] User name is remembered across session +- [ ] Responses use name when available ("Hi Max!") +- [ ] No regressions in existing functionality + +### Code Quality +- [ ] All changes pass existing tests +- [ ] New unit tests for response patterns +- [ ] No TypeScript errors +- [ ] Code follows existing patterns +- [ ] Documentation updated if needed + +## Risks & Mitigations + +### Risk 1: Breaking Change in Message Display +**Risk:** Reversing message order could confuse existing users or break other components. +**Mitigation:** +- Test thoroughly in staging before deploying +- Consider adding a user preference for message order if feedback is negative +- Monitor for bug reports after deploy + +### Risk 2: Overly Casual Tone +**Risk:** Making responses too casual could reduce perceived professionalism. +**Mitigation:** +- Keep responses friendly but not slangy +- Avoid emojis (per project guidelines) +- Maintain focus on portfolio/finance context +- A/B test if unsure + +### Risk 3: Name Extraction False Positives +**Risk:** Pattern matching could incorrectly extract names from non-name sentences. +**Mitigation:** +- Use specific patterns (require "my name is", "call me", etc.) +- Only capitalize first letter of extracted name +- Don't persist name without confidence +- Add tests for edge cases + +### Risk 4: Performance Impact +**Risk:** Adding pattern matching for every query could slow response times. +**Mitigation:** +- Patterns are simple regex (should be fast) +- Only runs for non-tool queries (minority of cases) +- Profile before and after if concerned +- Could cache compiled regex patterns if needed + +## Testing Strategy + +### Unit Tests +1. Test `visibleMessages` getter returns correct order +2. Test `createNoToolDirectResponse()` with various inputs +3. Test name extraction patterns +4. Test user preferences update logic + +### Integration Tests +1. Test full chat flow with greeting → name → follow-up +2. Test message ordering with many messages +3. Test scrolling behavior +4. Test that tool-based queries still work correctly + +### Manual QA Checklist +- [ ] Send "hi" → get friendly greeting +- [ ] Send "my name is Max" → response acknowledges Max +- [ ] Send another query → response uses "Max" +- [ ] Send 5 messages → newest appears at top +- [ ] Refresh page → order preserved +- [ ] Ask portfolio question → still works +- [ ] Send "thanks" → get polite acknowledgment + +## Rollout Plan + +### Phase 1: Frontend Message Ordering (Low Risk) +- Deploy to staging +- QA team tests +- Deploy to production +- Monitor for 24 hours + +### Phase 2: Natural Language Backend (Medium Risk) +- Deploy to staging +- QA team tests with various queries +- Run evals dataset to check for regressions +- Deploy to production with feature flag if needed +- Monitor user feedback + +### Phase 3: Name Memory (Low Risk) +- Deploy after Phase 2 is stable +- Test name persistence across sessions +- Deploy to production + +## Documentation Updates + +- [ ] Update AI service documentation with new response patterns +- [ ] Add examples of natural language responses to docs +- [ ] Document user preferences schema changes +- [ ] Update any API documentation if relevant + +## Future Enhancements (Out of Scope) + +- More sophisticated NLP for intent detection +- Sentiment analysis to adjust response tone +- Multi-language support for greetings +- User customization of response style +- Quick suggestions based on conversation context