From 50de8fd4e9ca1872cfa25f5c5b6b020a2816ea32 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 15 Apr 2026 17:16:22 +0100 Subject: [PATCH] Implement chat UI spec gaps --- ui/src/chat/chat-panel.component.ts | 10 +- ui/src/chat/chat-state.service.ts | 168 +++++++++++-- ui/src/chat/conversation-sidebar.component.ts | 69 +++-- ui/src/chat/message-list.component.ts | 236 ++++++++++++++---- ui/src/chat/model-selector.component.ts | 17 +- 5 files changed, 411 insertions(+), 89 deletions(-) diff --git a/ui/src/chat/chat-panel.component.ts b/ui/src/chat/chat-panel.component.ts index da743aab..4756131b 100644 --- a/ui/src/chat/chat-panel.component.ts +++ b/ui/src/chat/chat-panel.component.ts @@ -43,6 +43,7 @@ import { ChatStateService } from './chat-state.service'; @@ -59,7 +60,10 @@ import { ChatStateService } from './chat-state.service'; />
- +
([]); readonly activeConversation = signal(null); @@ -33,12 +34,13 @@ export class ChatStateService { max_tokens: 2048, context_window: 8192, system_prompt: 'You are a helpful assistant.', - default_model: 'local-default', + default_model: '', }); readonly draft = signal(''); readonly historyQuery = signal(''); readonly settingsOpen = signal(false); readonly sending = signal(false); + readonly modelSwitching = signal(false); readonly selectedModel = signal(''); constructor() { @@ -142,16 +144,26 @@ export class ChatStateService { if (!model) { return; } - const settings = await this.invoke('gui.chat.selectModel', { - model, - conversation_id: this.activeConversation()?.id, - }); - this.selectedModel.set(model); - if (settings) { - this.settings.set(settings); + this.modelSwitching.set(true); + try { + const settings = await this.invoke('gui.chat.selectModel', { + model, + conversation_id: this.activeConversation()?.id, + }); + this.selectedModel.set(model); + if (settings) { + this.settings.set(settings); + } + const currentId = this.activeConversation()?.id; + this.activeConversation.update((conversation) => (conversation ? { ...conversation, model } : conversation)); + if (currentId) { + this.conversations.update((items) => + items.map((item) => (item.id === currentId ? { ...item, model } : item)), + ); + } + } finally { + this.modelSwitching.set(false); } - this.activeConversation.update((conversation) => (conversation ? { ...conversation, model } : conversation)); - this.conversations.update((items) => items.map((item) => (item.id === this.activeConversation()?.id ? { ...item, model } : item))); } async queueImageFiles(files: FileList | File[]): Promise { @@ -457,27 +469,127 @@ export class ChatStateService { { name: 'lemma', architecture: 'qwen3', quant_bits: 8, size_bytes: 3200000000, loaded: false, backend: 'ollama' }, ] as T; } - if (route === 'gui.chat.settings.load' || route === 'gui.chat.settings.save' || route === 'gui.chat.settings.reset') { - return (payload ?? this.settings()) as T; + if (route === 'gui.chat.settings.load') { + return this.settings() as T; + } + if (route === 'gui.chat.settings.save') { + const settings = payload as ChatSettings; + this.settings.set(settings); + if (settings.default_model) { + this.selectedModel.set(settings.default_model); + } + return settings as T; + } + if (route === 'gui.chat.settings.reset') { + const defaults: ChatSettings = { + temperature: 1, + top_p: 0.95, + top_k: 64, + max_tokens: 2048, + context_window: 8192, + system_prompt: 'You are a helpful assistant.', + default_model: '', + }; + this.settings.set(defaults); + return defaults as T; } if (route === 'gui.chat.selectModel') { - return { + const model = (payload as { model?: string })?.model ?? this.settings().default_model; + const updated = { ...this.settings(), - default_model: (payload as { model?: string })?.model ?? this.settings().default_model, - } as T; + default_model: model, + }; + if (this.activeConversation()) { + const currentId = this.activeConversation()?.id; + this.activeConversation.update((conversation) => (conversation ? { ...conversation, model } : conversation)); + if (currentId) { + this.conversations.update((items) => + items.map((item) => (item.id === currentId ? { ...item, model } : item)), + ); + this.mockConversations = this.mockConversations.map((item) => (item.id === currentId ? { ...item, model } : item)); + } + } + return updated as T; + } + if (route === 'gui.chat.conversations.get') { + const id = (payload as { id?: string; conversation_id?: string })?.id ?? (payload as { id?: string; conversation_id?: string })?.conversation_id; + const found = this.mockConversations.find((item) => item.id === id); + if (found) { + return found as T; + } + if (this.activeConversation()?.id === id) { + return this.activeConversation() as T; + } + return null as T; + } + if (route === 'gui.chat.conversations.rename') { + const { id, title } = payload as { id?: string; title?: string }; + const titleValue = title?.trim() || 'New Chat'; + const currentId = this.activeConversation()?.id; + if (currentId === id) { + this.activeConversation.update((conversation) => (conversation ? { ...conversation, title: titleValue } : conversation)); + } + this.conversations.update((items) => + items.map((item) => (item.id === id ? { ...item, title: titleValue } : item)), + ); + this.mockConversations = this.mockConversations.map((item) => (item.id === id ? { ...item, title: titleValue } : item)); + const found = this.mockConversations.find((item) => item.id === id); + if (found) { + return found as T; + } + if (this.activeConversation()?.id === id) { + return this.activeConversation() as T; + } + return { id, title: titleValue } as T; + } + if (route === 'gui.chat.conversations.delete') { + const id = (payload as { id?: string; conversation_id?: string })?.id ?? (payload as { id?: string; conversation_id?: string })?.conversation_id; + this.conversations.update((items) => items.filter((item) => item.id !== id)); + this.mockConversations = this.mockConversations.filter((item) => item.id !== id); + if (this.activeConversation()?.id === id) { + this.activeConversation.set(null); + } + return undefined as T; } if (route === 'gui.chat.conversations.list' || route === 'gui.chat.conversations.search') { - return this.conversations() as T; + const query = ((payload as { q?: string })?.q ?? '').trim().toLowerCase(); + if (!query) { + return this.mockConversations.map((item) => this.toSummary(item)) as T; + } + return this.mockConversations + .filter((item) => + item.title.toLowerCase().includes(query) || + item.model.toLowerCase().includes(query) || + item.messages.some((message) => message.content.toLowerCase().includes(query)), + ) + .map((item) => this.toSummary(item)) as T; } if (route === 'gui.chat.conversations.export') { - return '# Exported Conversation\n' as T; + const id = (payload as { id?: string; conversation_id?: string })?.id ?? (payload as { id?: string; conversation_id?: string })?.conversation_id; + const found = this.mockConversations.find((item) => item.id === id); + const conversation = found ?? (this.activeConversation()?.id === id ? this.activeConversation() : null); + if (!conversation) { + return '# Exported Conversation\n' as T; + } + return [ + `# ${conversation.title}`, + '', + ...conversation.messages.map((message) => { + const heading = `## ${message.role.charAt(0).toUpperCase() + message.role.slice(1)}`; + const body = message.content ? `${message.content}\n` : ''; + return `${heading}\n\n${body}`.trimEnd(); + }), + '', + ].join('\n') as T; } if (route === 'gui.chat.attachImage') { + const attachment = payload as ImageAttachment; + this.queuedAttachments.update((items) => [...items, attachment]); return payload as T; } if (route === 'gui.chat.conversations.new') { const now = new Date().toISOString(); - return { + const conversation = { id: `conv-${Date.now().toString(36)}`, title: 'New Chat', model: this.selectedModel() || 'lemer', @@ -485,19 +597,24 @@ export class ChatStateService { updated_at: now, message_count: 0, messages: [], - } as T; + }; + this.activeConversation.set(conversation); + this.mockConversations = [conversation, ...this.mockConversations.filter((item) => item.id !== conversation.id)]; + this.upsertSummary(conversation); + return conversation as T; } if (route === 'gui.chat.send') { const conversation = this.activeConversation() ?? ((await this.mockInvoke('gui.chat.conversations.new')) as Conversation); const content = (payload as { content?: string })?.content ?? ''; const now = new Date().toISOString(); - return { + const attachments = this.queuedAttachments(); + const updated = { ...conversation, updated_at: now, title: conversation.title === 'New Chat' ? content.slice(0, 48) || 'New Chat' : conversation.title, messages: [ ...conversation.messages, - { id: `user-${Date.now()}`, role: 'user', content, created_at: now, model: this.selectedModel(), attachments: this.queuedAttachments() }, + { id: `user-${Date.now()}`, role: 'user', content, created_at: now, model: this.selectedModel(), attachments }, { id: `assistant-${Date.now() + 1}`, role: 'assistant', @@ -506,8 +623,15 @@ export class ChatStateService { model: this.selectedModel(), }, ], - } as T; + }; + this.draft.set(''); + this.queuedAttachments.set([]); + this.activeConversation.set(updated); + this.mockConversations = [updated, ...this.mockConversations.filter((item) => item.id !== updated.id)]; + this.upsertSummary(updated); + return updated as T; } return {} as T; } + } diff --git a/ui/src/chat/conversation-sidebar.component.ts b/ui/src/chat/conversation-sidebar.component.ts index 7cb9afca..464cc519 100644 --- a/ui/src/chat/conversation-sidebar.component.ts +++ b/ui/src/chat/conversation-sidebar.component.ts @@ -15,24 +15,40 @@ type ConversationGroup = { label: string; items: ConversationSummary[] }; + @@ -44,14 +60,23 @@ type ConversationGroup = { label: string; items: ConversationSummary[] }; .sidebar__list { display: grid; gap: 1rem; align-content: start; overflow: auto; } .group { display: grid; gap: 0.5rem; } .group__label { margin: 0; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.14em; font-size: 0.7rem; } - .ghost, input, .conversation__actions button { border-radius: 0.9rem; border: 1px solid rgba(124, 156, 191, 0.2); background: rgba(11, 27, 44, 0.7); color: #eaf4ff; padding: 0.8rem 0.9rem; } + .ghost, input, .conversation__actions button, .conversation__rename-actions button { + border-radius: 0.9rem; + border: 1px solid rgba(124, 156, 191, 0.2); + background: rgba(11, 27, 44, 0.7); + color: #eaf4ff; + padding: 0.8rem 0.9rem; + } .ghost { cursor: pointer; text-align: left; font-weight: 700; } - .conversation { display: grid; gap: 0.35rem; padding: 0.9rem; border: 0; border-radius: 1rem; background: rgba(8, 21, 35, 0.55); color: #dbeafe; text-align: left; cursor: pointer; } + .conversation { display: grid; gap: 0.7rem; padding: 0.9rem; border: 0; border-radius: 1rem; background: rgba(8, 21, 35, 0.55); color: #dbeafe; text-align: left; } .conversation--active { background: linear-gradient(135deg, rgba(14, 116, 144, 0.55), rgba(8, 47, 73, 0.82)); box-shadow: inset 0 0 0 1px rgba(125, 211, 252, 0.28); } + .conversation__select { display: grid; gap: 0.35rem; border: 0; padding: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; } .conversation__title { font-weight: 700; } .conversation__meta { color: #94a3b8; font-size: 0.8rem; } .conversation__actions { display: flex; gap: 0.45rem; flex-wrap: wrap; } - .conversation__actions button { padding: 0.35rem 0.6rem; font-size: 0.72rem; } + .conversation__actions button, .conversation__rename-actions button { padding: 0.35rem 0.6rem; font-size: 0.72rem; cursor: pointer; } + .conversation__rename { display: grid; gap: 0.5rem; } + .conversation__rename-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; } .danger { color: #fecaca; border-color: rgba(248, 113, 113, 0.28); } `, ], @@ -67,6 +92,9 @@ export class ConversationSidebarComponent { @Output() delete = new EventEmitter(); @Output() export = new EventEmitter(); + editingId: string | null = null; + draftTitle = ''; + get groupedConversations(): ConversationGroup[] { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); @@ -92,11 +120,22 @@ export class ConversationSidebarComponent { .map((label) => ({ label, items: groups.get(label) ?? [] })); } - renameConversation(item: ConversationSummary): void { - const title = window.prompt('Rename conversation', item.title)?.trim(); + beginRename(item: ConversationSummary): void { + this.editingId = item.id; + this.draftTitle = item.title; + } + + commitRename(id: string): void { + const title = this.draftTitle.trim(); if (title) { - this.rename.emit({ id: item.id, title }); + this.rename.emit({ id, title }); } + this.cancelRename(); + } + + cancelRename(): void { + this.editingId = null; + this.draftTitle = ''; } removeConversation(item: ConversationSummary): void { diff --git a/ui/src/chat/message-list.component.ts b/ui/src/chat/message-list.component.ts index de7b17f3..1287162b 100644 --- a/ui/src/chat/message-list.component.ts +++ b/ui/src/chat/message-list.component.ts @@ -1,74 +1,154 @@ import { CommonModule, DatePipe } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { + AfterViewChecked, + Component, + ElementRef, + Input, + OnChanges, + SimpleChanges, + ViewChild, +} from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { ChatMessage, ImageAttachment } from './chat.types'; +type RenderBlock = + | { kind: 'text'; html: SafeHtml } + | { kind: 'code'; code: string; language: string }; + @Component({ selector: 'chat-message-list', standalone: true, imports: [CommonModule, DatePipe], template: ` -
-
-
- {{ message.role }} - {{ message.model || 'local' }} -
-
-
-
- -
{{ attachment.filename }} · {{ attachment.width }}×{{ attachment.height }}
-
-
-
- Thinking · {{ thinkingDuration(message) }} -

{{ message.thinking?.content }}

-
-
- Tool calls -
-
{{ call.name }}
-
{{ call.arguments | json }}
-
-
-
- Tool results -
-
{{ result.tool_call_id }}
-
{{ result.content }}
-
-
-
{{ message.created_at | date: 'MMM d, HH:mm:ss' }}
-
+
+
+
+
+ {{ message.role }} + {{ message.model || 'local' }} +
+ + +
+
+
+ {{ block.language || 'code' }} + +
+
{{ block.code }}
+
+
+ +
+
+ +
{{ attachment.filename }} · {{ attachment.width }}×{{ attachment.height }}
+
+
+ +
+
+ Thinking... +
+
+ Thought for {{ thinkingDuration(message) }} +

{{ message.thinking.content || 'Thinking in progress' }}

+
+
+ +
+ Tool calls +
+
{{ call.name }}
+
{{ call.arguments | json }}
+
+
+ +
+ Tool results +
+
{{ result.tool_call_id }}
+
{{ result.content }}
+
+
+ +
+ {{ message.created_at | date: 'MMM d, HH:mm:ss' }} +
+
+
+ +
`, styles: [ ` - .thread { display: grid; gap: 1rem; align-content: start; } + .thread-shell { position: relative; min-height: 100%; height: 100%; } + .thread { display: grid; gap: 1rem; align-content: start; min-height: 0; max-height: 100%; overflow: auto; padding-right: 0.25rem; } .bubble { max-width: 54rem; padding: 1rem 1.1rem; border-radius: 1.25rem; background: rgba(11, 27, 44, 0.88); border: 1px solid rgba(125, 211, 252, 0.12); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); } .bubble--user { margin-left: auto; background: linear-gradient(135deg, rgba(8, 47, 73, 0.95), rgba(14, 116, 144, 0.7)); } header, footer { display: flex; justify-content: space-between; gap: 1rem; color: #7dd3fc; font-size: 0.72rem; } header { margin-bottom: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em; } - footer { margin-top: 0.9rem; color: #94a3b8; } + footer { margin-top: 0.9rem; color: #94a3b8; opacity: 0; transition: opacity 0.2s ease; } + .bubble:hover footer { opacity: 1; } .content { color: #ecfeff; line-height: 1.6; } .attachments { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.9rem; } .attachment { width: min(16rem, 100%); margin: 0; display: grid; gap: 0.4rem; } .attachment img { width: 100%; border-radius: 1rem; } .attachment figcaption { color: #cbd5e1; font-size: 0.76rem; } .thinking, .tool { margin-top: 0.85rem; padding-top: 0.85rem; border-top: 1px solid rgba(125, 211, 252, 0.12); color: #cbd5e1; } + .thinking { display: grid; gap: 0.5rem; } + .thinking__badge { display: inline-flex; width: fit-content; gap: 0.45rem; align-items: center; border-radius: 999px; padding: 0.3rem 0.65rem; background: rgba(15, 23, 42, 0.7); color: #f8fafc; } + .thinking__badge--active::before { content: ''; width: 0.55rem; height: 0.55rem; border-radius: 50%; background: #f59e0b; box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.5); animation: pulse 1.4s infinite; } + details summary { cursor: pointer; color: #e2e8f0; } .tool { display: grid; gap: 0.65rem; } .tool__block { display: grid; gap: 0.35rem; } .tool__title { color: #f8fafc; font-weight: 700; } - pre { margin: 0; overflow: auto; background: rgba(2, 6, 23, 0.6); padding: 0.85rem; border-radius: 0.8rem; white-space: pre-wrap; } + .code-block { margin-top: 0.9rem; overflow: hidden; border-radius: 0.95rem; border: 1px solid rgba(148, 163, 184, 0.16); background: rgba(2, 6, 23, 0.64); } + .code-block__bar { display: flex; justify-content: space-between; gap: 1rem; align-items: center; padding: 0.55rem 0.8rem; border-bottom: 1px solid rgba(148, 163, 184, 0.12); color: #cbd5e1; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; } + .code-block__copy { border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 999px; background: rgba(15, 23, 42, 0.8); color: #e2e8f0; padding: 0.3rem 0.7rem; cursor: pointer; text-transform: none; letter-spacing: normal; } + pre { margin: 0; overflow: auto; padding: 0.85rem; white-space: pre-wrap; color: #f8fafc; } + .scroll-pill { position: absolute; right: 0.75rem; bottom: 0.75rem; border: 1px solid rgba(251, 191, 36, 0.28); border-radius: 999px; background: rgba(124, 45, 18, 0.92); color: #fde68a; padding: 0.75rem 1rem; cursor: pointer; box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25); } + @keyframes pulse { + 0% { transform: scale(0.92); opacity: 0.75; } + 70% { transform: scale(1.08); opacity: 1; } + 100% { transform: scale(0.92); opacity: 0.75; } + } `, ], }) -export class MessageListComponent { +export class MessageListComponent implements OnChanges, AfterViewChecked { @Input() messages: ChatMessage[] = []; + @Input() streaming = false; + + @ViewChild('viewport') private readonly viewport?: ElementRef; + + private pendingScroll = false; + pinnedToBottom = true; constructor(private readonly sanitizer: DomSanitizer) {} + ngOnChanges(changes: SimpleChanges): void { + if (changes['messages'] || changes['streaming']) { + this.pendingScroll = true; + } + } + + ngAfterViewChecked(): void { + if (this.pendingScroll) { + this.pendingScroll = false; + if (this.pinnedToBottom) { + queueMicrotask(() => this.scrollToBottom()); + } + } + } + + trackByMessage(_index: number, message: ChatMessage): string { + return message.id; + } + attachmentSource(attachment: ImageAttachment): string { return `data:${attachment.mime_type};base64,${attachment.data}`; } @@ -78,13 +158,81 @@ export class MessageListComponent { return duration > 0 ? `${(duration / 1000).toFixed(1)}s` : 'in progress'; } - renderMarkdown(content: string): SafeHtml { + renderBlocks(content: string): RenderBlock[] { + const source = content ?? ''; + if (!source.trim()) { + return []; + } + + const blocks: RenderBlock[] = []; + const pattern = /```([\w-]+)?\n([\s\S]*?)```/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(source)) !== null) { + const before = source.slice(lastIndex, match.index).trim(); + if (before) { + blocks.push({ kind: 'text', html: this.renderInlineMarkdown(before) }); + } + blocks.push({ + kind: 'code', + language: match[1] ?? '', + code: match[2].replace(/\n+$/, ''), + }); + lastIndex = pattern.lastIndex; + } + + const after = source.slice(lastIndex).trim(); + if (after) { + blocks.push({ kind: 'text', html: this.renderInlineMarkdown(after) }); + } + + if (blocks.length === 0) { + blocks.push({ kind: 'text', html: this.renderInlineMarkdown(source) }); + } + return blocks; + } + + copyCode(code: string): void { + if (!code) { + return; + } + if (navigator.clipboard?.writeText) { + void navigator.clipboard.writeText(code); + return; + } + const textarea = document.createElement('textarea'); + textarea.value = code; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + + onScroll(): void { + const element = this.viewport?.nativeElement; + if (!element) { + return; + } + const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight; + this.pinnedToBottom = distanceFromBottom < 48; + } + + scrollToBottom(): void { + const element = this.viewport?.nativeElement; + if (!element) { + return; + } + element.scrollTop = element.scrollHeight; + this.pinnedToBottom = true; + } + + private renderInlineMarkdown(content: string): SafeHtml { const escaped = this.escapeHTML(content ?? ''); - const blocks = escaped.replace(/```([\w-]+)?\n([\s\S]*?)```/g, (_, language, code) => { - const label = language ? `
${language}
` : ''; - return `${label}
${code.trim()}
`; - }); - const inline = blocks + const inline = escaped .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(/`([^`]+)`/g, '$1') diff --git a/ui/src/chat/model-selector.component.ts b/ui/src/chat/model-selector.component.ts index ca5149d1..e3298b3d 100644 --- a/ui/src/chat/model-selector.component.ts +++ b/ui/src/chat/model-selector.component.ts @@ -10,22 +10,29 @@ import { ModelEntry } from './chat.types'; template: ` `, styles: [ ` .selector { display: grid; gap: 0.35rem; color: #cbd5e1; font-size: 0.82rem; } + .selector__row { display: flex; align-items: center; gap: 0.65rem; } select { min-width: 18rem; border-radius: 0.8rem; border: 1px solid rgba(124, 156, 191, 0.2); background: rgba(8, 21, 35, 0.8); color: #e2e8f0; padding: 0.72rem 0.9rem; } + .spinner { width: 1rem; height: 1rem; border-radius: 999px; border: 2px solid rgba(148, 163, 184, 0.24); border-top-color: #f59e0b; animation: spin 0.8s linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } `, ], }) export class ModelSelectorComponent { @Input() models: ModelEntry[] = []; @Input() value = ''; + @Input() loading = false; @Output() valueChange = new EventEmitter(); }