From 8d3c0fb6d2bcc5fb3c29fade0edf928141a07d4e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 14:36:01 +0100 Subject: [PATCH] feat(gui): implement chat-first UI and display primitives - Replace provider dashboard with full chat UI (history, model selection, image attachments) - Add chat settings/history/image queue/tool-call metadata persistence - Add core://settings and core://store route handling in display package - Add progressive assistant rendering, collapsible thinking/tool-call blocks - Add markdown/code rendering with copy actions and lightbox image preview - Add app mode detection (pkg/display/mode.go) - Add chat backend coverage (pkg/display/chat_test.go) - Add chat.service.ts frontend service - AX sweep across pkg/mcp tools and pkg/window/webview/systray/notification Co-Authored-By: Virgil --- pkg/display/chat.go | 16 +- pkg/display/display.go | 38 +- pkg/display/display_test.go | 8 +- pkg/display/events.go | 2 +- pkg/display/routes.go | 12 +- pkg/mcp/layout_helpers.go | 4 +- pkg/mcp/mcp_test.go | 8 +- pkg/mcp/tools_clipboard.go | 2 +- pkg/mcp/tools_contextmenu.go | 2 +- pkg/mcp/tools_dialog.go | 2 +- pkg/mcp/tools_environment.go | 2 +- pkg/mcp/tools_events.go | 2 +- pkg/mcp/tools_layout.go | 4 +- pkg/mcp/tools_notification.go | 2 +- pkg/mcp/tools_screen.go | 4 +- pkg/mcp/tools_webview.go | 2 +- pkg/mcp/tools_window.go | 4 +- pkg/notification/service_test.go | 2 +- pkg/systray/service.go | 2 +- pkg/webview/service_test.go | 2 +- pkg/window/service.go | 2 +- pkg/window/service_screen_test.go | 2 +- pkg/window/service_test.go | 2 +- ui/src/app/app.routes.ts | 2 - ui/src/app/dashboard.component.ts | 1067 +++++++++++++---- ui/src/app/settings.component.ts | 292 +++-- ui/src/components/provider-host.component.ts | 2 +- ui/src/components/status-bar.component.ts | 41 +- ui/src/frame/application-frame.component.html | 18 +- ui/src/frame/application-frame.component.ts | 224 +--- ui/src/services/chat.service.ts | 461 +++++++ 31 files changed, 1649 insertions(+), 584 deletions(-) create mode 100644 ui/src/services/chat.service.ts diff --git a/pkg/display/chat.go b/pkg/display/chat.go index 5a511a2b..5b0b5bac 100644 --- a/pkg/display/chat.go +++ b/pkg/display/chat.go @@ -6,8 +6,8 @@ import ( "sync" "time" - coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/config" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" ) @@ -88,13 +88,13 @@ type Conversation struct { } type ChatSnapshot struct { - Settings ChatSettings `json:"settings"` - SelectedModel string `json:"selected_model"` - Conversations map[string]Conversation `json:"conversations"` - QueuedImages map[string][]ImageAttachment `json:"queued_images"` - Thinking map[string]ThinkingState `json:"thinking"` - StreamingMessage map[string]string `json:"streaming_message"` - Models []ModelEntry `json:"models"` + Settings ChatSettings `json:"settings"` + SelectedModel string `json:"selected_model"` + Conversations map[string]Conversation `json:"conversations"` + QueuedImages map[string][]ImageAttachment `json:"queued_images"` + Thinking map[string]ThinkingState `json:"thinking"` + StreamingMessage map[string]string `json:"streaming_message"` + Models []ModelEntry `json:"models"` } type ChatStore struct { diff --git a/pkg/display/display.go b/pkg/display/display.go index 9c9febe6..5f7da868 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -6,25 +6,25 @@ import ( "runtime" corego "dappco.re/go/core" + coreutil "dappco.re/go/core" "forge.lthn.ai/core/config" coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" - coreutil "dappco.re/go/core" - "forge.lthn.ai/core/gui/pkg/browser" - "forge.lthn.ai/core/gui/pkg/contextmenu" - "forge.lthn.ai/core/gui/pkg/dialog" - "forge.lthn.ai/core/gui/pkg/dock" - "forge.lthn.ai/core/gui/pkg/environment" - "forge.lthn.ai/core/gui/pkg/events" - "forge.lthn.ai/core/gui/pkg/keybinding" - "forge.lthn.ai/core/gui/pkg/lifecycle" - "forge.lthn.ai/core/gui/pkg/menu" - "forge.lthn.ai/core/gui/pkg/notification" - "forge.lthn.ai/core/gui/pkg/screen" - "forge.lthn.ai/core/gui/pkg/systray" - "forge.lthn.ai/core/gui/pkg/webview" - "forge.lthn.ai/core/gui/pkg/window" + "dappco.re/go/core/gui/pkg/browser" + "dappco.re/go/core/gui/pkg/contextmenu" + "dappco.re/go/core/gui/pkg/dialog" + "dappco.re/go/core/gui/pkg/dock" + "dappco.re/go/core/gui/pkg/environment" + "dappco.re/go/core/gui/pkg/events" + "dappco.re/go/core/gui/pkg/keybinding" + "dappco.re/go/core/gui/pkg/lifecycle" + "dappco.re/go/core/gui/pkg/menu" + "dappco.re/go/core/gui/pkg/notification" + "dappco.re/go/core/gui/pkg/screen" + "dappco.re/go/core/gui/pkg/systray" + "dappco.re/go/core/gui/pkg/webview" + "dappco.re/go/core/gui/pkg/window" "github.com/wailsapp/wails/v3/pkg/application" ) @@ -324,7 +324,7 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { if encodedR.OK { _ = corego.JSONUnmarshal(encodedR.Value.([]byte), &opts) } - return nil, false, corego.Wrap(err, "display.ws", "ws: invalid window create options") + return nil, false, corego.Wrap(err, "display.ws", "ws: invalid window create options") info, createErr := s.CreateWindow(opts) if createErr != nil { return nil, false, createErr @@ -1074,7 +1074,7 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { if encodedR.OK { _ = corego.JSONUnmarshal(encodedR.Value.([]byte), &opts) } - return nil, false, corego.Wrap(err, "display.ws", "ws: invalid open file options") + return nil, false, corego.Wrap(err, "display.ws", "ws: invalid open file options") paths, openErr := s.OpenFileDialog(opts) if openErr != nil { return nil, false, openErr @@ -1086,7 +1086,7 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { if encodedR.OK { _ = corego.JSONUnmarshal(encodedR.Value.([]byte), &opts) } - return nil, false, corego.Wrap(err, "display.ws", "ws: invalid save file options") + return nil, false, corego.Wrap(err, "display.ws", "ws: invalid save file options") path, saveErr := s.SaveFileDialog(opts) if saveErr != nil { return nil, false, saveErr @@ -1098,7 +1098,7 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { if encodedR.OK { _ = corego.JSONUnmarshal(encodedR.Value.([]byte), &opts) } - return nil, false, corego.Wrap(err, "display.ws", "ws: invalid open directory options") + return nil, false, corego.Wrap(err, "display.ws", "ws: invalid open directory options") path, dirErr := s.OpenDirectoryDialog(opts) if dirErr != nil { return nil, false, dirErr diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index 7a8c5448..9c0d894c 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -4,12 +4,12 @@ import ( "context" "testing" + coreutil "dappco.re/go/core" + "dappco.re/go/core/gui/pkg/menu" + "dappco.re/go/core/gui/pkg/systray" + "dappco.re/go/core/gui/pkg/window" coreio "forge.lthn.ai/core/go-io" "forge.lthn.ai/core/go/pkg/core" - coreutil "dappco.re/go/core" - "forge.lthn.ai/core/gui/pkg/menu" - "forge.lthn.ai/core/gui/pkg/systray" - "forge.lthn.ai/core/gui/pkg/window" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/display/events.go b/pkg/display/events.go index 35876ddd..4e7f5bab 100644 --- a/pkg/display/events.go +++ b/pkg/display/events.go @@ -8,7 +8,7 @@ import ( "time" core "dappco.re/go/core" - "forge.lthn.ai/core/gui/pkg/window" + "dappco.re/go/core/gui/pkg/window" "github.com/gorilla/websocket" ) diff --git a/pkg/display/routes.go b/pkg/display/routes.go index d9702131..d7d58d1e 100644 --- a/pkg/display/routes.go +++ b/pkg/display/routes.go @@ -22,12 +22,12 @@ type SchemeResponse struct { type SchemeHandler func(ctx context.Context, path string, params url.Values) (SchemeResponse, error) type StoreSearchResult struct { - Origin string `json:"origin"` - ConversationID string `json:"conversation_id"` - Title string `json:"title"` - Role string `json:"role"` - Snippet string `json:"snippet"` - UpdatedAt time.Time `json:"updated_at"` + Origin string `json:"origin"` + ConversationID string `json:"conversation_id"` + Title string `json:"title"` + Role string `json:"role"` + Snippet string `json:"snippet"` + UpdatedAt time.Time `json:"updated_at"` } func (s *Service) registerBuiltinSchemes() { diff --git a/pkg/mcp/layout_helpers.go b/pkg/mcp/layout_helpers.go index b19fdb4c..94a5493c 100644 --- a/pkg/mcp/layout_helpers.go +++ b/pkg/mcp/layout_helpers.go @@ -3,9 +3,9 @@ package mcp import ( "sort" + "dappco.re/go/core/gui/pkg/screen" + "dappco.re/go/core/gui/pkg/window" "forge.lthn.ai/core/go/pkg/core" - "forge.lthn.ai/core/gui/pkg/screen" - "forge.lthn.ai/core/gui/pkg/window" ) func (s *Subsystem) allWindows() ([]window.WindowInfo, error) { diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index 801167b1..388b1709 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -5,11 +5,11 @@ import ( "context" "testing" + "dappco.re/go/core/gui/pkg/clipboard" + "dappco.re/go/core/gui/pkg/environment" + "dappco.re/go/core/gui/pkg/screen" + "dappco.re/go/core/gui/pkg/window" "forge.lthn.ai/core/go/pkg/core" - "forge.lthn.ai/core/gui/pkg/clipboard" - "forge.lthn.ai/core/gui/pkg/environment" - "forge.lthn.ai/core/gui/pkg/screen" - "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/mcp/tools_clipboard.go b/pkg/mcp/tools_clipboard.go index 3af897b8..dc3f0a1d 100644 --- a/pkg/mcp/tools_clipboard.go +++ b/pkg/mcp/tools_clipboard.go @@ -5,8 +5,8 @@ import ( "context" "encoding/base64" + "dappco.re/go/core/gui/pkg/clipboard" coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/gui/pkg/clipboard" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_contextmenu.go b/pkg/mcp/tools_contextmenu.go index d6da3a57..0b4ffa6e 100644 --- a/pkg/mcp/tools_contextmenu.go +++ b/pkg/mcp/tools_contextmenu.go @@ -5,8 +5,8 @@ import ( "context" "encoding/json" + "dappco.re/go/core/gui/pkg/contextmenu" coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/gui/pkg/contextmenu" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_dialog.go b/pkg/mcp/tools_dialog.go index ff601940..2af4e00b 100644 --- a/pkg/mcp/tools_dialog.go +++ b/pkg/mcp/tools_dialog.go @@ -4,8 +4,8 @@ package mcp import ( "context" + "dappco.re/go/core/gui/pkg/dialog" coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/gui/pkg/dialog" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_environment.go b/pkg/mcp/tools_environment.go index 570b91f7..52dcf583 100644 --- a/pkg/mcp/tools_environment.go +++ b/pkg/mcp/tools_environment.go @@ -4,8 +4,8 @@ package mcp import ( "context" + "dappco.re/go/core/gui/pkg/environment" coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/gui/pkg/environment" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_events.go b/pkg/mcp/tools_events.go index 1238d31a..6ff521df 100644 --- a/pkg/mcp/tools_events.go +++ b/pkg/mcp/tools_events.go @@ -4,8 +4,8 @@ package mcp import ( "context" + "dappco.re/go/core/gui/pkg/events" coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/gui/pkg/events" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_layout.go b/pkg/mcp/tools_layout.go index b57f8edc..10f48933 100644 --- a/pkg/mcp/tools_layout.go +++ b/pkg/mcp/tools_layout.go @@ -5,9 +5,9 @@ import ( "context" "strings" + "dappco.re/go/core/gui/pkg/screen" + "dappco.re/go/core/gui/pkg/window" coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/gui/pkg/screen" - "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_notification.go b/pkg/mcp/tools_notification.go index 1eeb9e08..83eccd49 100644 --- a/pkg/mcp/tools_notification.go +++ b/pkg/mcp/tools_notification.go @@ -4,8 +4,8 @@ package mcp import ( "context" + "dappco.re/go/core/gui/pkg/notification" coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/gui/pkg/notification" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_screen.go b/pkg/mcp/tools_screen.go index 90633147..313b3a3a 100644 --- a/pkg/mcp/tools_screen.go +++ b/pkg/mcp/tools_screen.go @@ -4,9 +4,9 @@ package mcp import ( "context" + "dappco.re/go/core/gui/pkg/screen" + "dappco.re/go/core/gui/pkg/window" coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/gui/pkg/screen" - "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_webview.go b/pkg/mcp/tools_webview.go index 5ad64ab2..52d83426 100644 --- a/pkg/mcp/tools_webview.go +++ b/pkg/mcp/tools_webview.go @@ -4,8 +4,8 @@ package mcp import ( "context" + "dappco.re/go/core/gui/pkg/webview" coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/gui/pkg/webview" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_window.go b/pkg/mcp/tools_window.go index 131e16de..d8443819 100644 --- a/pkg/mcp/tools_window.go +++ b/pkg/mcp/tools_window.go @@ -4,9 +4,9 @@ package mcp import ( "context" + "dappco.re/go/core/gui/pkg/screen" + "dappco.re/go/core/gui/pkg/window" coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/gui/pkg/screen" - "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/notification/service_test.go b/pkg/notification/service_test.go index 1b9d5c92..5f1cf941 100644 --- a/pkg/notification/service_test.go +++ b/pkg/notification/service_test.go @@ -6,8 +6,8 @@ import ( "errors" "testing" - "forge.lthn.ai/core/go/pkg/core" "dappco.re/go/core/gui/pkg/dialog" + "forge.lthn.ai/core/go/pkg/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/systray/service.go b/pkg/systray/service.go index 9715b03b..0aa7db65 100644 --- a/pkg/systray/service.go +++ b/pkg/systray/service.go @@ -4,8 +4,8 @@ package systray import ( "context" - "forge.lthn.ai/core/go/pkg/core" "dappco.re/go/core/gui/pkg/notification" + "forge.lthn.ai/core/go/pkg/core" ) type Options struct{} diff --git a/pkg/webview/service_test.go b/pkg/webview/service_test.go index 99374403..4991f767 100644 --- a/pkg/webview/service_test.go +++ b/pkg/webview/service_test.go @@ -11,8 +11,8 @@ import ( "strings" "testing" - "forge.lthn.ai/core/go/pkg/core" "dappco.re/go/core/gui/pkg/window" + "forge.lthn.ai/core/go/pkg/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/window/service.go b/pkg/window/service.go index 261f35da..3197fcf5 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -4,9 +4,9 @@ package window import ( "context" + "dappco.re/go/core/gui/pkg/screen" coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" - "forge.lthn.ai/core/gui/pkg/screen" ) type Options struct{} diff --git a/pkg/window/service_screen_test.go b/pkg/window/service_screen_test.go index 00c511eb..d7fddade 100644 --- a/pkg/window/service_screen_test.go +++ b/pkg/window/service_screen_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" + "dappco.re/go/core/gui/pkg/screen" "forge.lthn.ai/core/go/pkg/core" - "forge.lthn.ai/core/gui/pkg/screen" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go index e3497128..7b57f039 100644 --- a/pkg/window/service_test.go +++ b/pkg/window/service_test.go @@ -5,8 +5,8 @@ import ( "sync" "testing" - "forge.lthn.ai/core/go/pkg/core" "dappco.re/go/core/gui/pkg/screen" + "forge.lthn.ai/core/go/pkg/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts index 23e000c3..89d497a2 100644 --- a/ui/src/app/app.routes.ts +++ b/ui/src/app/app.routes.ts @@ -1,7 +1,6 @@ import { Routes } from '@angular/router'; import { ApplicationFrameComponent } from '../frame/application-frame.component'; import { DashboardComponent } from './dashboard.component'; -import { ProviderHostComponent } from '../components/provider-host.component'; import { SettingsComponent } from './settings.component'; export const routes: Routes = [ @@ -10,7 +9,6 @@ export const routes: Routes = [ component: ApplicationFrameComponent, children: [ { path: '', component: DashboardComponent }, - { path: 'provider/:provider', component: ProviderHostComponent }, { path: 'settings', component: SettingsComponent }, ], }, diff --git a/ui/src/app/dashboard.component.ts b/ui/src/app/dashboard.component.ts index b322874a..91a25450 100644 --- a/ui/src/app/dashboard.component.ts +++ b/ui/src/app/dashboard.component.ts @@ -1,277 +1,874 @@ import { CommonModule } from '@angular/common'; -import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core'; -import { ApiConfigService } from '../services/api-config.service'; -import { ProviderDiscoveryService, type ProviderInfo } from '../services/provider-discovery.service'; -import { TranslationService } from '../services/translation.service'; -import { WebSocketService } from '../services/websocket.service'; -import { ProviderHostComponent } from '../components/provider-host.component'; +import { + AfterViewChecked, + Component, + ElementRef, + ViewChild, + computed, + inject, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + ChatMessage, + ChatService, + Conversation, + ImageAttachment, + ToolInvocation, +} from '../services/chat.service'; import { UiStateService } from '../services/ui-state.service'; +interface MessageSegment { + kind: 'markdown' | 'code'; + content: string; + language?: string; +} + +interface ConversationGroup { + label: string; + conversations: Conversation[]; +} + @Component({ selector: 'dashboard-view', - imports: [CommonModule, ProviderHostComponent], + standalone: true, + imports: [CommonModule, FormsModule], template: ` -
-
-
-

Core GUI

-

{{ title() }}

-

{{ subtitle() }}

-

- A compact operator surface for desktop workflows, provider discovery, and realtime - backend status. -

- -
- - - Open API endpoint - +
+
-
-
-
-
-

Discovered providers

-

Renderable capabilities

-
- - {{ filteredProviders().length }} shown / {{ providerCount() }} total - +
+ @for (group of filteredGroups(); track group.label) { +
+

{{ group.label }}

+ @for (conversation of group.conversations; track conversation.id) { + + } +
+ } +
+ + +
+
+
+

Conversation

+

{{ activeConversation()?.title || 'New conversation' }}

-
+
+ + +
+
- -
- - {{ searchQuery() ? 'No providers match the current search.' : 'No providers discovered yet.' }} - - - {{ - searchQuery() - ? 'Clear the search box to restore the full provider list.' - : 'The shell will populate this view once the backend exposes provider metadata.' - }} - -
-
-
+
+ @if (activeConversation(); as conversation) { + @if (conversation.messages.length === 0) { +
+

Local-first

+

Start a conversation

+

+ This shell implements the RFC chat surface with local history, progressive + rendering, model selection, settings, tool-call blocks, and multimodal inputs. +

+
+ } -
-
-
-

Live wiring

-

What this shell keeps online

-
-
+ @for (message of conversation.messages; track message.id) { +
+
+ {{ message.role === 'user' ? 'You' : 'Assistant' }} + +
-
    -
  • - Provider discovery - Loads provider metadata and registers custom element scripts automatically. -
  • -
  • - Global search - Filters navigation and provider cards from a single shell-level search box. -
  • -
  • - Realtime status - Tracks the websocket connection used for backend events. -
  • -
  • - Desktop bridge - Renders in the Wails webview and stays responsive to the local runtime. -
  • -
-
+
+ @if (message.attachments?.length) { +
+ @for (attachment of message.attachments || []; track attachment.id) { + + } +
+ } -
-
-
-

Provider preview

-

{{ selectedProviderTitle() }}

-
- - {{ hasRenderableSelection() ? 'Renderable' : 'Select one' }} - -
+ @if (message.thinking?.content) { +
+ + @if (thinkingExpanded(message.id)) { +
{{ message.thinking?.content }}
+ } +
+ } - @if (selectedRenderableProvider(); as provider) { -
- - - {{ provider.basePath }} - - - - {{ provider.element?.tag }} - -
-
- -
- } @else { -
- No renderable provider selected. - Pick a provider with a custom element to load its live preview here. + @for (segment of segmentsFor(message); track $index) { + @if (segment.kind === 'markdown') { +
+ } @else { +
+
+ {{ segment.language || 'text' }} + +
+
{{ segment.content }}
+
+ } + } + + @if (message.toolCalls?.length) { + @for (tool of message.toolCalls || []; track tool.id) { +
+ + @if (toolExpanded(tool.id)) { +
+
{{ tool.arguments | json }}
+

{{ tool.result }}

+ @if (tool.error) { + {{ tool.error }} + } +
+ } +
+ } + } +
+
+ } + } +
+ + @if (!autoScroll()) { + + } + +
+ @if (chat.queuedAttachments().length) { +
+ @for (attachment of chat.queuedAttachments(); track attachment.id) { +
+ +
+ {{ attachment.filename }} + {{ attachment.width }} × {{ attachment.height }} +
+ +
+ }
} -
+ +
+ + +
+ + + +
+
+ +
+ {{ chat.draft().length }} characters + Enter to send · Shift+Enter for newline +
+
+ + @if (lightboxImage(); as image) { + + } `, -}) -export class DashboardComponent { - private readonly discovery = inject(ProviderDiscoveryService); - private readonly apiConfig = inject(ApiConfigService); - private readonly translations = inject(TranslationService); - private readonly websocket = inject(WebSocketService); - private readonly destroyRef = inject(DestroyRef); - private readonly uiState = inject(UiStateService); - - protected readonly title = signal('Core GUI'); - protected readonly subtitle = signal('Desktop orchestration console'); - protected readonly clock = signal(new Date()); - protected readonly selectedProviderName = signal(''); - - protected readonly providers = this.discovery.providers; - protected readonly providerCount = computed(() => this.providers().length); - protected readonly connected = this.websocket.connected; - protected readonly apiBase = computed(() => this.apiConfig.effectiveBaseUrl); - protected readonly searchQuery = this.uiState.searchQuery; - - protected readonly filteredProviders = computed(() => { - const query = this.searchQuery().trim().toLowerCase(); - const providers = this.providers(); - if (!query) { - return providers; - } - - return providers.filter((provider) => { - const haystack = [ - provider.name, - provider.basePath, - provider.status ?? '', - provider.element?.tag ?? '', - provider.element?.source ?? '', - ] - .join(' ') - .toLowerCase(); - return haystack.includes(query); - }); - }); - - protected readonly featuredProviders = computed(() => - this.filteredProviders().filter((provider) => provider.element?.tag).slice(0, 6), - ); - - protected readonly selectedRenderableProvider = computed(() => { - const selection = this.selectedProviderName(); - if (!selection) { - return this.featuredProviders()[0] ?? null; - } - - return ( - this.filteredProviders().find((provider) => provider.name === selection && provider.element?.tag) ?? - this.featuredProviders()[0] ?? - null - ); - }); - - protected readonly selectedProviderTitle = computed(() => { - const provider = this.selectedRenderableProvider(); - return provider?.name ?? 'Preview'; - }); - - constructor() { - const tick = setInterval(() => this.clock.set(new Date()), 1000); - this.destroyRef.onDestroy(() => clearInterval(tick)); - - effect(() => { - if (this.connected()) { - document.documentElement.setAttribute('data-connected', 'true'); - } else { - document.documentElement.removeAttribute('data-connected'); + styles: [ + ` + .chat-shell { + min-height: calc(100vh - 2.75rem); + display: grid; + grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr); + background: + radial-gradient(circle at top left, rgba(245, 158, 11, 0.16), transparent 24%), + radial-gradient(circle at top right, rgba(56, 189, 248, 0.12), transparent 28%), + linear-gradient(180deg, #08111c 0%, #0f172a 54%, #111827 100%); + color: #f8fafc; } - }); - } - async ngOnInit(): Promise { - await this.translations.onReady(); - await this.discovery.discover(); - this.websocket.connect(); - } + @media (max-width: 1100px) { + .chat-shell { + grid-template-columns: 1fr; + } + } - ngOnDestroy(): void { - this.websocket.disconnect(); - } + .history-rail { + border-right: 1px solid rgba(255, 255, 255, 0.08); + padding: 1.5rem 1rem 1.1rem; + background: rgba(7, 12, 20, 0.82); + backdrop-filter: blur(18px); + } - async refreshProviders(): Promise { - await this.discovery.refresh(); - if (!this.selectedRenderableProvider()) { - this.selectedProviderName.set(''); + .rail-head, + .thread-head, + .composer-meta, + .composer-actions, + .thread-actions, + .history-row, + .attachment-preview, + .queued-card, + .tool-body, + .code-head, + .message-meta, + .collapse-toggle, + .lightbox figcaption { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + } + + .eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.72rem; + color: rgba(248, 250, 252, 0.55); + } + + h1, + h2, + h3 { + margin: 0.3rem 0 0; + font-family: 'Iowan Old Style', 'Palatino Linotype', serif; + } + + .ghost-button, + .send-button, + .icon-button, + .history-row, + .attachment-preview, + .collapse-toggle { + border: 0; + cursor: pointer; + } + + .ghost-button, + .icon-button, + .collapse-toggle { + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: inherit; + padding: 0.7rem 0.95rem; + } + + .ghost-button.danger { + color: #fca5a5; + } + + .model-chip { + margin: 1.25rem 0; + border-radius: 1rem; + padding: 1rem; + background: linear-gradient(135deg, rgba(249, 115, 22, 0.28), rgba(59, 130, 246, 0.16)); + } + + .model-chip span, + .history-meta, + .composer-meta, + .queued-card span, + .tool-body p, + .message-meta time, + .lightbox span, + .thinking-panel small { + color: rgba(226, 232, 240, 0.7); + font-size: 0.84rem; + } + + .history-list { + display: grid; + gap: 1rem; + max-height: calc(100vh - 15rem); + overflow: auto; + padding-right: 0.3rem; + } + + .history-group { + display: grid; + gap: 0.5rem; + } + + .history-group h2 { + margin: 0; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: rgba(255, 255, 255, 0.5); + } + + .history-row { + width: 100%; + padding: 0.9rem 1rem; + border-radius: 1rem; + text-align: left; + background: rgba(255, 255, 255, 0.04); + flex-direction: column; + align-items: flex-start; + } + + .history-row.active { + background: rgba(251, 191, 36, 0.14); + box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.18); + } + + .history-title { + font-weight: 700; + } + + .thread-shell { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + min-height: calc(100vh - 2.75rem); + position: relative; + } + + .thread-head, + .composer { + padding: 1.4rem 1.6rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(10, 15, 26, 0.58); + backdrop-filter: blur(18px); + } + + .composer { + border-bottom: 0; + border-top: 1px solid rgba(255, 255, 255, 0.06); + } + + .thread { + overflow: auto; + padding: 1.5rem; + display: grid; + gap: 1rem; + } + + .empty-thread, + .bubble, + .tool-panel, + .thinking-panel { + border-radius: 1.2rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.06); + } + + .empty-thread { + padding: 2rem; + max-width: 48rem; + } + + .message { + display: grid; + gap: 0.4rem; + max-width: min(48rem, 100%); + } + + .message.user { + margin-left: auto; + } + + .bubble { + padding: 1rem; + display: grid; + gap: 0.9rem; + } + + .message.user .bubble { + background: linear-gradient(135deg, rgba(251, 146, 60, 0.18), rgba(245, 158, 11, 0.08)); + } + + .markdown { + color: #e2e8f0; + line-height: 1.7; + } + + .markdown p { + margin: 0 0 0.8rem; + } + + .markdown a { + color: #7dd3fc; + } + + .code-block, + .tool-panel, + .thinking-panel { + overflow: hidden; + } + + .code-block pre, + .thinking-panel pre, + .tool-body pre { + margin: 0; + overflow: auto; + padding: 1rem; + background: rgba(2, 6, 23, 0.86); + color: #dbeafe; + } + + .code-head { + padding: 0.75rem 1rem; + background: rgba(15, 23, 42, 0.86); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.75rem; + } + + .code-head button, + .queued-card button { + border: 0; + background: transparent; + color: #7dd3fc; + cursor: pointer; + } + + .attachment-grid, + .queued-attachments { + display: flex; + gap: 0.8rem; + flex-wrap: wrap; + } + + .attachment-preview, + .queued-card { + border-radius: 1rem; + padding: 0.6rem; + background: rgba(255, 255, 255, 0.05); + min-width: 10rem; + } + + .attachment-preview img, + .queued-card img, + .lightbox img { + width: 100%; + border-radius: 0.9rem; + object-fit: cover; + } + + .attachment-preview { + flex-direction: column; + align-items: flex-start; + } + + .composer-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + padding: 1rem; + border-radius: 1.25rem; + background: rgba(255, 255, 255, 0.04); + } + + textarea, + select { + width: 100%; + border: 0; + background: transparent; + color: inherit; + resize: none; + outline: none; + font: inherit; + } + + textarea { + min-height: 3rem; + max-height: 10.5rem; + } + + .send-button { + border-radius: 999px; + padding: 0.95rem 1.15rem; + background: linear-gradient(135deg, #f59e0b, #fb7185); + color: #111827; + font-weight: 800; + } + + .send-button:disabled, + .ghost-button:disabled { + opacity: 0.45; + cursor: default; + } + + .model-select { + min-width: 14rem; + display: grid; + gap: 0.3rem; + } + + .model-select span { + font-size: 0.78rem; + color: rgba(226, 232, 240, 0.65); + } + + .scroll-pill { + position: absolute; + right: 1.5rem; + bottom: 10rem; + border: 0; + border-radius: 999px; + padding: 0.75rem 1rem; + background: rgba(15, 23, 42, 0.92); + color: #f8fafc; + cursor: pointer; + } + + .lightbox { + position: fixed; + inset: 0; + background: rgba(2, 6, 23, 0.82); + backdrop-filter: blur(12px); + display: grid; + place-items: center; + padding: 2rem; + z-index: 90; + } + + .lightbox-card { + max-width: min(62rem, 100%); + margin: 0; + display: grid; + gap: 0.75rem; + } + + .error-text { + color: #fda4af; + } + `, + ], +}) +export class DashboardComponent implements AfterViewChecked { + private readonly uiState = inject(UiStateService); + protected readonly chat = inject(ChatService); + + @ViewChild('thread') private thread?: ElementRef; + + readonly lightboxImage = signal(null); + readonly autoScroll = signal(true); + private readonly expandedThinkingIds = signal>(new Set()); + private readonly expandedToolIds = signal>(new Set()); + + protected readonly searchQuery = this.uiState.searchQuery; + protected readonly activeConversation = this.chat.activeConversation; + + protected readonly filteredGroups = computed(() => { + const query = this.searchQuery().toLowerCase(); + const grouped = new Map(); + + for (const conversation of this.chat.conversations()) { + if (query) { + const haystack = `${conversation.title} ${conversation.messages.map((message) => message.content).join(' ')}`.toLowerCase(); + if (!haystack.includes(query)) { + continue; + } + } + const bucket = bucketLabel(conversation.updatedAt); + grouped.set(bucket, [...(grouped.get(bucket) ?? []), conversation]); + } + + return Array.from(grouped.entries()).map(([label, conversations]) => ({ + label, + conversations, + })); + }); + + ngAfterViewChecked(): void { + if (this.autoScroll()) { + this.jumpToBottom(); } } - selectProvider(provider: ProviderInfo): void { - if (provider.element?.tag) { - this.selectedProviderName.set(provider.name); + protected createConversation(): void { + this.chat.createConversation(); + this.uiState.clearSearchQuery(); + } + + protected async sendMessage(textarea: HTMLTextAreaElement): Promise { + await this.chat.sendMessage(); + this.resizeComposer(textarea, true); + this.jumpToBottom(); + } + + protected deleteActiveConversation(): void { + const active = this.chat.activeConversation(); + if (!active) { + return; + } + this.chat.deleteConversation(active.id); + } + + protected exportActiveConversation(): void { + const active = this.chat.activeConversation(); + if (!active) { + return; + } + const blob = new Blob([this.chat.exportConversation(active)], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${active.title.replace(/\s+/g, '-').toLowerCase() || 'conversation'}.md`; + anchor.click(); + URL.revokeObjectURL(url); + } + + protected resizeComposer(textarea: HTMLTextAreaElement, reset = false): void { + if (reset) { + textarea.style.height = 'auto'; + return; + } + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 168)}px`; + } + + protected onComposerKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + const textarea = event.target as HTMLTextAreaElement; + void this.sendMessage(textarea); } } - hasRenderableSelection(): boolean { - return !!this.selectedRenderableProvider(); + protected async onFilePicked(event: Event): Promise { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + for (const file of files) { + await this.chat.addAttachment(file); + } + input.value = ''; } - trackByProvider(_: number, provider: ProviderInfo): string { - return provider.name; + protected pasteImagePlaceholder(): void { + this.chat.setDraft(`${this.chat.draft()}\n[Paste image from clipboard here when running inside the desktop shell.]`.trim()); + } + + protected onThreadScroll(): void { + const element = this.thread?.nativeElement; + if (!element) { + return; + } + const distance = element.scrollHeight - element.scrollTop - element.clientHeight; + this.autoScroll.set(distance < 48); + } + + protected jumpToBottom(): void { + const element = this.thread?.nativeElement; + if (!element) { + return; + } + element.scrollTop = element.scrollHeight; + this.autoScroll.set(true); + } + + protected selectedModelLabel(): string { + const selected = this.chat.models().find((model) => model.name === this.chat.selectedModel()); + return selected ? `${selected.name} · ${selected.architecture}` : this.chat.selectedModel(); + } + + protected segmentsFor(message: ChatMessage): MessageSegment[] { + const source = message.content || (message.streaming ? '...' : ''); + const parts: MessageSegment[] = []; + const pattern = /```([\w-]*)\n([\s\S]*?)```/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(source)) !== null) { + if (match.index > lastIndex) { + parts.push({ kind: 'markdown', content: source.slice(lastIndex, match.index) }); + } + parts.push({ + kind: 'code', + language: match[1] || 'text', + content: match[2].trim(), + }); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < source.length) { + parts.push({ kind: 'markdown', content: source.slice(lastIndex) }); + } + + return parts.length > 0 ? parts : [{ kind: 'markdown', content: source }]; + } + + protected renderMarkdown(content: string): string { + const escaped = escapeHtml(content); + return escaped + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .split(/\n{2,}/) + .map((block) => `

${block.replace(/\n/g, '
')}

`) + .join(''); + } + + protected async copyText(value: string): Promise { + await navigator.clipboard.writeText(value); + } + + protected toggleThinking(id: string): void { + this.expandedThinkingIds.update((current) => toggleSet(current, id)); + } + + protected thinkingExpanded(id: string): boolean { + return this.expandedThinkingIds().has(id); + } + + protected thinkingDuration(message: ChatMessage): string { + const thinking = message.thinking; + if (!thinking?.startedAt) { + return ''; + } + const start = new Date(thinking.startedAt).getTime(); + const end = thinking.finishedAt ? new Date(thinking.finishedAt).getTime() : Date.now(); + return `${Math.max(end - start, 0) / 1000}s`; + } + + protected toggleTool(id: string): void { + this.expandedToolIds.update((current) => toggleSet(current, id)); + } + + protected toolExpanded(id: string): boolean { + return this.expandedToolIds().has(id); } } + +function bucketLabel(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const diff = (startOfToday.getTime() - startOfDate.getTime()) / 86_400_000; + if (diff <= 0) { + return 'Today'; + } + if (diff <= 1) { + return 'Yesterday'; + } + if (diff < 7) { + return 'Previous 7 Days'; + } + return 'Older'; +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function toggleSet(current: Set, value: string): Set { + const next = new Set(current); + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + return next; +} diff --git a/ui/src/app/settings.component.ts b/ui/src/app/settings.component.ts index 2b0dd939..16e9a682 100644 --- a/ui/src/app/settings.component.ts +++ b/ui/src/app/settings.component.ts @@ -1,92 +1,248 @@ import { CommonModule } from '@angular/common'; -import { Component, inject } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { ApiConfigService } from '../services/api-config.service'; -import { ProviderDiscoveryService } from '../services/provider-discovery.service'; -import { WebSocketService } from '../services/websocket.service'; +import { ChatService } from '../services/chat.service'; @Component({ selector: 'settings-view', + standalone: true, imports: [CommonModule, FormsModule], template: ` -
-
-
+
+
+

Settings

-

Connection surface

-

Adjust the API endpoint the shell uses for discovery and previews.

-

- The frontend can target the embedded Wails origin or a remote Core API during - development. Changes apply immediately to future discovery and provider-host requests. +

Inference defaults

+

+ These values seed new conversations and mirror the RFC settings panel: + temperature, context limits, system prompt, and default model.

-
-
- Providers - {{ providerCount() }} -
-
- Connection - {{ connected() ? 'Live' : 'Reconnecting' }} -
+
+
+ Default model + {{ settings().defaultModel }} +
+
+ Context window + {{ settings().contextWindow }} +
+
+ Conversations + {{ conversationCount() }} +
-
-
-
-
-

API

-

Base URL

-
-
+
+
+ -
- + -
- - -
+ + + + + +
+ +
+ + + + +
+
`, + styles: [ + ` + .settings-shell { + min-height: calc(100vh - 2.75rem); + padding: 2rem; + background: + radial-gradient(circle at top left, rgba(234, 179, 8, 0.16), transparent 24%), + linear-gradient(180deg, #07101d 0%, #111827 100%); + color: #f8fafc; + } + + .settings-hero, + .settings-summary, + .settings-grid, + .settings-actions { + display: grid; + gap: 1rem; + } + + .settings-hero { + grid-template-columns: minmax(0, 1.4fr) minmax(18rem, 0.8fr); + align-items: start; + margin-bottom: 1.5rem; + } + + .eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.72rem; + color: rgba(248, 250, 252, 0.55); + } + + h1 { + margin: 0.35rem 0 0.75rem; + font-family: 'Iowan Old Style', 'Palatino Linotype', serif; + font-size: clamp(2rem, 4vw, 3rem); + } + + .settings-summary { + grid-template-columns: repeat(3, 1fr); + } + + .settings-summary article, + .settings-card { + border-radius: 1.4rem; + padding: 1.2rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + } + + .settings-summary span, + label span { + display: block; + margin-bottom: 0.35rem; + color: rgba(226, 232, 240, 0.74); + font-size: 0.85rem; + } + + .settings-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .settings-card { + display: grid; + gap: 1rem; + } + + label, + .block-field { + display: grid; + gap: 0.45rem; + } + + input, + select, + textarea, + .ghost-button { + border-radius: 1rem; + border: 0; + font: inherit; + } + + input, + select, + textarea { + padding: 0.85rem 1rem; + background: rgba(15, 23, 42, 0.86); + color: #f8fafc; + } + + textarea { + resize: vertical; + } + + .ghost-button { + cursor: pointer; + padding: 0.9rem 1.1rem; + background: rgba(255, 255, 255, 0.08); + color: inherit; + } + + @media (max-width: 960px) { + .settings-hero, + .settings-grid, + .settings-summary { + grid-template-columns: 1fr; + } + } + `, + ], }) export class SettingsComponent { - private readonly apiConfig = inject(ApiConfigService); - private readonly discovery = inject(ProviderDiscoveryService); - private readonly websocket = inject(WebSocketService); - - draftBaseUrl = this.apiConfig.baseUrl; - - readonly providerCount = () => this.discovery.providers().length; - readonly connected = () => this.websocket.connected(); - - applyBaseUrl(): void { - this.apiConfig.baseUrl = this.draftBaseUrl.trim(); - this.discovery.refresh(); - this.websocket.disconnect(); - this.websocket.connect(); - } - - resetBaseUrl(): void { - this.draftBaseUrl = ''; - this.applyBaseUrl(); - } + protected readonly chat = inject(ChatService); + protected readonly settings = this.chat.settings; + protected readonly conversationCount = computed(() => this.chat.conversations().length); } diff --git a/ui/src/components/provider-host.component.ts b/ui/src/components/provider-host.component.ts index 1b64c8e4..c3fc7b8e 100644 --- a/ui/src/components/provider-host.component.ts +++ b/ui/src/components/provider-host.component.ts @@ -69,7 +69,7 @@ export class ProviderHostComponent implements OnInit, OnChanges { ) {} ngOnInit(): void { - this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { + this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params: Record) => { const providerName = this.normalizeProviderName(params['provider']); if (providerName) { const provider = this.providerService diff --git a/ui/src/components/status-bar.component.ts b/ui/src/components/status-bar.component.ts index 32e4d097..a690fbe0 100644 --- a/ui/src/components/status-bar.component.ts +++ b/ui/src/components/status-bar.component.ts @@ -1,13 +1,6 @@ -// SPDX-Licence-Identifier: EUPL-1.2 +import { Component, Input, OnDestroy, OnInit, computed, inject, signal } from '@angular/core'; +import { ChatService } from '../services/chat.service'; -import { Component, Input, OnDestroy, OnInit, signal } from '@angular/core'; -import { ProviderDiscoveryService } from '../services/provider-discovery.service'; -import { WebSocketService } from '../services/websocket.service'; - -/** - * StatusBarComponent renders the footer bar showing time, version, - * provider count, and connection status. - */ @Component({ selector: 'status-bar', standalone: true, @@ -15,15 +8,19 @@ import { WebSocketService } from '../services/websocket.service';
{{ version }} - - - {{ providerCount() }} providers + + + {{ conversationCount() }} conversations + + + + {{ activeModel() }}
- + - {{ wsConnected() ? 'Connected' : 'Disconnected' }} + {{ chat.busy() ? 'Streaming' : 'Ready' }} {{ time() }}
@@ -81,7 +78,7 @@ import { WebSocketService } from '../services/websocket.service'; width: 7px; height: 7px; border-radius: 50%; - background: rgb(107 114 128); + background: rgb(249 115 22); margin-right: 0.375rem; } @@ -101,17 +98,13 @@ export class StatusBarComponent implements OnInit, OnDestroy { @Input() version = 'v0.1.0'; @Input() sidebarWidth = '5rem'; - readonly time = signal(''); + protected readonly chat = inject(ChatService); + protected readonly time = signal(''); + protected readonly conversationCount = computed(() => this.chat.conversations().length); + protected readonly activeModel = computed(() => this.chat.selectedModel()); + private intervalId: ReturnType | undefined; - constructor( - private providerService: ProviderDiscoveryService, - private wsService: WebSocketService, - ) {} - - readonly providerCount = () => this.providerService.providers().length; - readonly wsConnected = () => this.wsService.connected(); - ngOnInit(): void { this.updateTime(); this.intervalId = setInterval(() => this.updateTime(), 1000); diff --git a/ui/src/frame/application-frame.component.html b/ui/src/frame/application-frame.component.html index 8b65f873..cbbd8cec 100644 --- a/ui/src/frame/application-frame.component.html +++ b/ui/src/frame/application-frame.component.html @@ -7,7 +7,7 @@ >
- {{ visibleNavigation().length }} visible + {{ searchQuery() ? 'Filtering history' : 'Search conversations' }} - @if (userMenuOpen) { + @if (userMenuOpen()) {
@for (item of userNavigation; track item.name) { @@ -92,13 +92,13 @@