From f2eb9f03c4835776e0397751d860ed180d6eface Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 19:41:35 +0000 Subject: [PATCH] feat(gui): wire shell routes and provider previews --- ui/src/app/app-module.ts | 4 +- ui/src/app/app.html | 99 -------- ui/src/app/app.routes.ts | 17 ++ ui/src/app/app.ts | 64 +---- ui/src/app/dashboard.component.ts | 235 ++++++++++++++++++ ui/src/app/settings.component.ts | 92 +++++++ ui/src/components/provider-host.component.ts | 60 ++++- ui/src/components/provider-nav.component.ts | 2 +- ui/src/components/status-bar.component.ts | 12 +- ui/src/frame/application-frame.component.html | 20 +- ui/src/frame/application-frame.component.ts | 72 ++---- ui/src/styles.css | 108 ++++++++ 12 files changed, 549 insertions(+), 236 deletions(-) delete mode 100644 ui/src/app/app.html create mode 100644 ui/src/app/app.routes.ts create mode 100644 ui/src/app/dashboard.component.ts create mode 100644 ui/src/app/settings.component.ts diff --git a/ui/src/app/app-module.ts b/ui/src/app/app-module.ts index bf50b48..23d1ac6 100644 --- a/ui/src/app/app-module.ts +++ b/ui/src/app/app-module.ts @@ -3,11 +3,13 @@ import { DoBootstrap, Injector, NgModule, provideBrowserGlobalErrorListeners } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { createCustomElement } from '@angular/elements'; +import { RouterModule } from '@angular/router'; import { App } from './app'; +import { routes } from './app.routes'; @NgModule({ - imports: [BrowserModule, App], + imports: [BrowserModule, App, RouterModule.forRoot(routes)], providers: [provideBrowserGlobalErrorListeners()], }) export class AppModule implements DoBootstrap { diff --git a/ui/src/app/app.html b/ui/src/app/app.html deleted file mode 100644 index c9ad8fc..0000000 --- a/ui/src/app/app.html +++ /dev/null @@ -1,99 +0,0 @@ -
-
-
-

Core GUI

-

{{ title() }}

-

{{ subtitle() }}

-

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

- -
- - - Open API endpoint - -
-
- -
-
- Connection - {{ connected() ? 'Live' : 'Reconnecting' }} -
-
- Providers - {{ providerCount() }} -
-
- Local time - {{ clock() | date: 'mediumTime' }} -
-
- API base - {{ apiBase() }} -
-
-
- -
-
-
-
-

Discovered providers

-

Renderable capabilities

-
- {{ providerCount() }} total -
- -
-
-
- {{ provider.name.slice(0, 1).toUpperCase() }} -
-
- {{ provider.name }} - {{ provider.basePath }} - - {{ provider.element?.tag }} · {{ provider.element?.source }} - -
-
-
- - -
- No providers discovered yet. - The shell will populate this view once the backend exposes provider metadata. -
-
-
- -
-
-
-

Live wiring

-

What this shell keeps online

-
-
- -
    -
  • - Provider discovery - Loads provider metadata and registers custom element scripts automatically. -
  • -
  • - Realtime status - Tracks the websocket connection used for backend events. -
  • -
  • - Desktop bridge - Renders in the Wails webview and stays responsive to the local runtime. -
  • -
-
-
-
diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts new file mode 100644 index 0000000..23e000c --- /dev/null +++ b/ui/src/app/app.routes.ts @@ -0,0 +1,17 @@ +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 = [ + { + path: '', + component: ApplicationFrameComponent, + children: [ + { path: '', component: DashboardComponent }, + { path: 'provider/:provider', component: ProviderHostComponent }, + { path: 'settings', component: SettingsComponent }, + ], + }, +]; diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index e18f504..f4ecbc3 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -1,64 +1,10 @@ -import { CommonModule } from '@angular/common'; -import { Component, DestroyRef, OnDestroy, 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 { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; @Component({ selector: 'core-display', - imports: [CommonModule], - templateUrl: './app.html', + imports: [RouterOutlet], + template: '', standalone: true, }) -export class App implements OnDestroy { - 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); - - protected readonly title = signal('Core GUI'); - protected readonly subtitle = signal('Desktop orchestration console'); - protected readonly clock = signal(new Date()); - - 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.baseUrl || window.location.origin); - - protected readonly featuredProviders = computed(() => - this.providers().filter((provider) => provider.element?.tag).slice(0, 6), - ); - - 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'); - } - }); - } - - async ngOnInit(): Promise { - await this.translations.onReady(); - await this.discovery.discover(); - this.websocket.connect(); - } - - ngOnDestroy(): void { - this.websocket.disconnect(); - } - - refreshProviders(): Promise { - return this.discovery.refresh(); - } - - trackByProvider(_: number, provider: ProviderInfo): string { - return provider.name; - } -} +export class App {} diff --git a/ui/src/app/dashboard.component.ts b/ui/src/app/dashboard.component.ts new file mode 100644 index 0000000..43d60f8 --- /dev/null +++ b/ui/src/app/dashboard.component.ts @@ -0,0 +1,235 @@ +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'; + +@Component({ + selector: 'dashboard-view', + imports: [CommonModule, ProviderHostComponent], + template: ` +
+
+
+

Core GUI

+

{{ title() }}

+

{{ subtitle() }}

+

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

+ +
+ + + Open API endpoint + +
+
+ +
+
+ Connection + {{ connected() ? 'Live' : 'Reconnecting' }} +
+
+ Providers + {{ providerCount() }} +
+
+ Local time + {{ clock() | date: 'mediumTime' }} +
+
+ API base + {{ apiBase() }} +
+
+
+ +
+
+
+
+

Discovered providers

+

Renderable capabilities

+
+ {{ providerCount() }} total +
+ +
+ +
+ + +
+ No providers discovered yet. + The shell will populate this view once the backend exposes provider metadata. +
+
+
+ +
+
+
+

Live wiring

+

What this shell keeps online

+
+
+ +
    +
  • + Provider discovery + Loads provider metadata and registers custom element scripts automatically. +
  • +
  • + Realtime status + Tracks the websocket connection used for backend events. +
  • +
  • + Desktop bridge + Renders in the Wails webview and stays responsive to the local runtime. +
  • +
+
+ +
+
+
+

Provider preview

+

{{ selectedProviderTitle() }}

+
+ + {{ hasRenderableSelection() ? 'Renderable' : 'Select one' }} + +
+ + @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. +
+ } +
+
+
+ `, +}) +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); + + 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.baseUrl || window.location.origin); + + protected readonly featuredProviders = computed(() => + this.providers().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.providers().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'); + } + }); + } + + async ngOnInit(): Promise { + await this.translations.onReady(); + await this.discovery.discover(); + this.websocket.connect(); + } + + ngOnDestroy(): void { + this.websocket.disconnect(); + } + + async refreshProviders(): Promise { + await this.discovery.refresh(); + if (!this.selectedRenderableProvider()) { + this.selectedProviderName.set(''); + } + } + + selectProvider(provider: ProviderInfo): void { + if (provider.element?.tag) { + this.selectedProviderName.set(provider.name); + } + } + + hasRenderableSelection(): boolean { + return !!this.selectedRenderableProvider(); + } + + trackByProvider(_: number, provider: ProviderInfo): string { + return provider.name; + } +} diff --git a/ui/src/app/settings.component.ts b/ui/src/app/settings.component.ts new file mode 100644 index 0000000..2b0dd93 --- /dev/null +++ b/ui/src/app/settings.component.ts @@ -0,0 +1,92 @@ +import { CommonModule } from '@angular/common'; +import { Component, 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'; + +@Component({ + selector: 'settings-view', + 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. +

+
+ +
+
+ Providers + {{ providerCount() }} +
+
+ Connection + {{ connected() ? 'Live' : 'Reconnecting' }} +
+
+
+ +
+
+
+
+

API

+

Base URL

+
+
+ +
+ + +
+ + +
+
+
+
+
+ `, +}) +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(); + } +} diff --git a/ui/src/components/provider-host.component.ts b/ui/src/components/provider-host.component.ts index 6527cb2..6d75af2 100644 --- a/ui/src/components/provider-host.component.ts +++ b/ui/src/components/provider-host.component.ts @@ -4,13 +4,16 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, + DestroyRef, Input, OnChanges, OnInit, + inject, Renderer2, ViewChild, } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ApiConfigService } from '../services/api-config.service'; import { ProviderDiscoveryService } from '../services/provider-discovery.service'; @@ -35,6 +38,16 @@ import { ProviderDiscoveryService } from '../services/provider-discovery.service width: 100%; height: 100%; } + + .provider-host-empty { + display: grid; + place-items: center; + min-height: 100%; + padding: 1.5rem; + color: rgb(156 163 175); + background: rgba(255, 255, 255, 0.02); + text-align: center; + } `, ], }) @@ -46,6 +59,7 @@ export class ProviderHostComponent implements OnInit, OnChanges { @Input() apiUrl = ''; @ViewChild('container', { static: true }) container!: ElementRef; + private readonly destroyRef = inject(DestroyRef); constructor( private renderer: Renderer2, @@ -55,8 +69,8 @@ export class ProviderHostComponent implements OnInit, OnChanges { ) {} ngOnInit(): void { - this.route.params.subscribe((params) => { - const providerName = params['provider']; + this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { + const providerName = this.normalizeProviderName(params['provider']); if (providerName) { const provider = this.providerService .providers() @@ -64,8 +78,12 @@ export class ProviderHostComponent implements OnInit, OnChanges { if (provider?.element?.tag) { this.tag = provider.element.tag; this.renderElement(); + return; } } + + this.tag = ''; + this.renderEmptyState(); }); } @@ -74,7 +92,12 @@ export class ProviderHostComponent implements OnInit, OnChanges { } private renderElement(): void { - if (!this.tag || !this.container) { + if (!this.container) { + return; + } + + if (!this.tag) { + this.renderEmptyState(); return; } @@ -93,4 +116,35 @@ export class ProviderHostComponent implements OnInit, OnChanges { } this.renderer.appendChild(native, el); } + + private renderEmptyState(): void { + if (!this.container) { + return; + } + + const native = this.container.nativeElement; + while (native.firstChild) { + this.renderer.removeChild(native, native.firstChild); + } + + const empty = this.renderer.createElement('div'); + this.renderer.addClass(empty, 'provider-host-empty'); + this.renderer.appendChild( + empty, + this.renderer.createText('Select a renderable provider to preview its custom element.'), + ); + this.renderer.appendChild(native, empty); + } + + private normalizeProviderName(value: unknown): string { + if (typeof value !== 'string') { + return ''; + } + + try { + return decodeURIComponent(value).trim(); + } catch { + return value.trim(); + } + } } diff --git a/ui/src/components/provider-nav.component.ts b/ui/src/components/provider-nav.component.ts index ee66143..c76c60c 100644 --- a/ui/src/components/provider-nav.component.ts +++ b/ui/src/components/provider-nav.component.ts @@ -117,7 +117,7 @@ export class ProviderNavComponent { .filter((p: ProviderInfo) => p.element) .map((p: ProviderInfo) => ({ name: p.name, - href: p.name.toLowerCase(), + href: `/provider/${encodeURIComponent(p.name.toLowerCase())}`, icon: 'fa-regular fa-puzzle-piece fa-2xl', element: p.element, })); diff --git a/ui/src/components/status-bar.component.ts b/ui/src/components/status-bar.component.ts index 705be71..3db9393 100644 --- a/ui/src/components/status-bar.component.ts +++ b/ui/src/components/status-bar.component.ts @@ -12,7 +12,7 @@ import { WebSocketService } from '../services/websocket.service'; selector: 'status-bar', standalone: true, template: ` -