Browse Source

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
pull/6395/head
Max P 1 month ago
parent
commit
b88e86d36c
  1. 238
      apps/client/src/app/pages/chat/chat-page.component.html
  2. 161
      apps/client/src/app/pages/chat/chat-page.component.scss
  3. 304
      apps/client/src/app/pages/chat/chat-page.component.ts
  4. 15
      apps/client/src/app/pages/chat/chat-page.routes.ts
  5. 96
      apps/client/src/app/services/ai-chat-conversations.service.spec.ts
  6. 590
      apps/client/src/app/services/ai-chat-conversations.service.ts
  7. 402
      thoughts/shared/plans/2026-02-24-fix-chat-page-ui-ux.md

238
apps/client/src/app/pages/chat/chat-page.component.html

@ -0,0 +1,238 @@
<div class="container">
<h1 class="h3 mb-3 text-center" i18n>AI Chat</h1>
<div class="row">
<div class="col-lg-4 mb-3 mb-lg-0">
<mat-card appearance="outlined" class="h-100">
<mat-card-content>
<button
class="w-100"
color="primary"
mat-flat-button
type="button"
(click)="onNewChat()"
>
<span class="align-items-center d-inline-flex">
<mat-icon aria-hidden="true" class="mr-1">add</mat-icon>
<span i18n>New chat</span>
</span>
</button>
<div class="conversation-list mt-3">
@for (
conversation of conversations;
track trackConversationById($index, conversation)
) {
<div class="align-items-center conversation-row d-flex mb-2">
<button
class="conversation-select flex-grow-1 text-left"
mat-stroked-button
type="button"
[class.active]="currentConversation?.id === conversation.id"
(click)="onSelectConversation(conversation.id)"
>
<div class="conversation-title text-truncate">{{ conversation.title }}</div>
<div class="conversation-meta text-muted">
{{ conversation.updatedAt | date: 'short' }}
</div>
</button>
<button
aria-label="Delete conversation"
class="conversation-delete ml-1"
i18n-aria-label
mat-icon-button
type="button"
(click)="onDeleteConversation($event, conversation.id)"
>
<mat-icon aria-hidden="true">delete</mat-icon>
</button>
</div>
}
</div>
</mat-card-content>
</mat-card>
</div>
<div class="col-lg-8">
<mat-card appearance="outlined">
<mat-card-content>
@if (!hasPermissionToReadAiPrompt) {
<div class="alert alert-warning mb-0" role="alert" i18n>
You need AI prompt permission to use this assistant.
</div>
} @else {
<div class="d-flex flex-wrap mb-3 prompt-list">
@for (prompt of starterPrompts; track prompt) {
<button
class="mr-2 mb-2"
mat-stroked-button
type="button"
(click)="onSelectStarterPrompt(prompt)"
>
{{ prompt }}
</button>
}
</div>
<mat-form-field class="w-100">
<mat-label i18n>Ask about your portfolio</mat-label>
<textarea
aria-label="Ask about your portfolio"
i18n-aria-label
matInput
rows="3"
[(ngModel)]="query"
[disabled]="isSubmitting"
(keydown.enter)="onSubmitFromKeyboard($event)"
></textarea>
</mat-form-field>
<div class="align-items-center d-flex mb-3">
<button
color="primary"
mat-flat-button
type="button"
[disabled]="isSubmitting || !query?.trim()"
(click)="onSubmit()"
>
<ng-container i18n>Send</ng-container>
</button>
@if (isSubmitting) {
<mat-spinner class="ml-3" color="accent" [diameter]="20" />
}
</div>
@if (errorMessage) {
<div class="alert alert-danger mb-3" role="alert">
{{ errorMessage }}
</div>
}
<div #chatLogContainer aria-live="polite" aria-relevant="additions text" class="chat-log" role="log">
@for (message of visibleMessages; track message.id) {
<div
class="chat-message mb-3 p-3 rounded"
[class.assistant]="message.role === 'assistant'"
[class.user]="message.role === 'user'"
>
<div class="chat-message-header mb-1 text-muted">
<span class="role-label text-uppercase">{{ getRoleLabel(message.role) }}</span>
<span class="ml-2 timestamp">{{
message.createdAt | date: 'shortTime'
}}</span>
@if (message.role === 'assistant' && message.response) {
<button
aria-label="Show response details"
class="chat-details-trigger ml-2"
i18n-aria-label
mat-stroked-button
type="button"
[matMenuTriggerFor]="responseDetailsMenu"
(click)="onOpenResponseDetails(message.response)"
>
<mat-icon aria-hidden="true">info</mat-icon>
<span i18n>Info</span>
</button>
}
</div>
<div class="chat-message-content">{{ message.content }}</div>
@if (message.feedback) {
<div class="align-items-center d-flex feedback-controls mt-2">
<button
class="mr-2"
mat-stroked-button
type="button"
[disabled]="
message.feedback.isSubmitting || !!message.feedback.rating
"
(click)="onRateResponse({ messageId: message.id, rating: 'up' })"
>
<ng-container i18n>Helpful</ng-container>
</button>
<button
mat-stroked-button
type="button"
[disabled]="
message.feedback.isSubmitting || !!message.feedback.rating
"
(click)="onRateResponse({ messageId: message.id, rating: 'down' })"
>
<ng-container i18n>Needs work</ng-container>
</button>
@if (message.feedback.isSubmitting) {
<span class="ml-2 text-muted" i18n>Saving feedback...</span>
} @else if (message.feedback.feedbackId) {
<span class="ml-2 text-muted" i18n>Feedback saved</span>
}
</div>
}
</div>
}
</div>
<mat-menu #responseDetailsMenu="matMenu" class="no-max-width" xPosition="before">
<div class="response-details-panel p-3" (click)="$event.stopPropagation()">
@if (activeResponseDetails; as details) {
<div class="response-details-section">
<strong i18n>Confidence</strong>:
{{ details.confidence.score * 100 | number: '1.0-0' }}%
({{ details.confidence.band }})
</div>
@if (details.citations.length > 0) {
<div class="response-details-section">
<strong i18n>Citations</strong>
<ul class="mb-0 pl-3 response-details-list">
@for (citation of details.citations; track $index) {
<li>
<span class="font-weight-bold">{{ citation.source }}</span>
-
{{ citation.snippet }}
</li>
}
</ul>
</div>
}
@if (details.verification.length > 0) {
<div class="response-details-section">
<strong i18n>Verification</strong>
<ul class="mb-0 pl-3 response-details-list">
@for (check of details.verification; track $index) {
<li>
<span class="text-capitalize">{{ check.status }}</span>
-
{{ check.check }}:
{{ check.details }}
</li>
}
</ul>
</div>
}
@if (details.observability) {
<div class="response-details-section">
<strong i18n>Observability</strong>:
<span class="ml-1"
>{{ details.observability.latencyInMs }}ms, ~{{
details.observability.tokenEstimate.total
}}
tokens</span
>
</div>
}
} @else {
<span class="text-muted" i18n>No response details available.</span>
}
</div>
</mat-menu>
}
</mat-card-content>
</mat-card>
</div>
</div>
</div>

161
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;
}
}

304
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<HTMLElement>;
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<void>();
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;
}
}

15
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
}
];

96
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'
);
});
});

590
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<AiChatMessage, 'createdAt'> & {
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<string | undefined>(
undefined
);
private conversationsSubject = new BehaviorSubject<AiChatConversation[]>([]);
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<StoredAiChatConversation>;
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<StoredAiChatMessage>;
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;
}
}

402
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<HTMLElement>;
// ... 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
<div aria-live="polite" #chatLogContainer class="chat-log" role="log">
<!-- messages -->
</div>
```
### 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
Loading…
Cancel
Save