From d027df85cf24b7400163681c6a769bfa46dadd93 Mon Sep 17 00:00:00 2001 From: Dillan Laughlin Date: Tue, 23 Jun 2026 16:55:40 -0500 Subject: [PATCH 1/2] Add failing test for browser tab title resolver (RED) (#1055) Adds resolveBrowserTabTitle() plus its spec. The stub returns the raw value, so the tests fail on purpose: a blank/whitespace value should fall back to "KIP" and a set value should be trimmed. --- .../core/utils/browser-tab-title.util.spec.ts | 16 ++++++++++++++++ src/app/core/utils/browser-tab-title.util.ts | 8 ++++++++ 2 files changed, 24 insertions(+) create mode 100644 src/app/core/utils/browser-tab-title.util.spec.ts create mode 100644 src/app/core/utils/browser-tab-title.util.ts diff --git a/src/app/core/utils/browser-tab-title.util.spec.ts b/src/app/core/utils/browser-tab-title.util.spec.ts new file mode 100644 index 000000000..ae7fdedd9 --- /dev/null +++ b/src/app/core/utils/browser-tab-title.util.spec.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { resolveBrowserTabTitle } from './browser-tab-title.util'; + +describe('resolveBrowserTabTitle (#1055)', () => { + it('defaults to KIP when the value is missing or blank', () => { + expect(resolveBrowserTabTitle(undefined)).toBe('KIP'); + expect(resolveBrowserTabTitle(null)).toBe('KIP'); + expect(resolveBrowserTabTitle('')).toBe('KIP'); + expect(resolveBrowserTabTitle(' ')).toBe('KIP'); + }); + + it('uses the trimmed configured value when set', () => { + expect(resolveBrowserTabTitle('Mast-KIP')).toBe('Mast-KIP'); + expect(resolveBrowserTabTitle(' Port Engine ')).toBe('Port Engine'); + }); +}); diff --git a/src/app/core/utils/browser-tab-title.util.ts b/src/app/core/utils/browser-tab-title.util.ts new file mode 100644 index 000000000..ad189a36d --- /dev/null +++ b/src/app/core/utils/browser-tab-title.util.ts @@ -0,0 +1,8 @@ +/** + * Resolves the browser tab title (document.title) from the user-configured value. + * Falls back to 'KIP' when the value is empty/whitespace so the tab is never blank (#1055). + */ +export function resolveBrowserTabTitle(value?: string | null): string { + // RED stub: returns the raw value (no default / no trim) - replaced by the fix. + return (value ?? '') as string; +} From cbdd016f079abf6dbc37a7c3538bdc04c5581ce1 Mon Sep 17 00:00:00 2001 From: Dillan Laughlin Date: Tue, 23 Jun 2026 16:59:50 -0500 Subject: [PATCH 2/2] Add configurable browser tab title (GREEN) (#1055) resolveBrowserTabTitle() now trims the value and falls back to "KIP" when blank. Adds a persisted browserTabTitle app setting (mirrors the instanceName pattern in SettingsService, defaulting to "KIP"), a "Browser Tab" field in Settings > Display, and an effect in AppComponent that keeps document.title in sync via Angular's Title service. Lets users running several KIP instances tell their tabs apart. --- src/app/app.component.ts | 9 ++++++ .../options/display/display.component.html | 20 +++++++++++++ .../options/display/display.component.ts | 3 ++ .../interfaces/app-settings.interfaces.ts | 1 + src/app/core/services/settings.service.ts | 30 ++++++++++++++++++- src/app/core/utils/browser-tab-title.util.ts | 4 +-- src/default-config/config.blank.const.ts | 3 +- 7 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ca94ae454..237a24c63 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -28,6 +28,8 @@ import { RemoteDashboardsService } from './core/services/remote-dashboards.servi import { ToastService } from './core/services/toast.service'; import { AppNetworkInitService, IBootstrapIssue } from './core/services/app-initNetwork.service'; import { DashboardHistorySeriesSyncService } from './core/services/dashboard-history-series-sync.service'; +import { Title } from '@angular/platform-browser'; +import { resolveBrowserTabTitle } from './core/utils/browser-tab-title.util'; @Component({ selector: 'app-root', @@ -62,6 +64,8 @@ export class AppComponent implements OnInit, AfterViewInit, OnDestroy { private readonly _uiEvent = inject(uiEventService); private readonly _dialog = inject(DialogService); public readonly settings = inject(SettingsService); + private readonly _titleService = inject(Title); + private readonly _browserTabTitle = toSignal(this.settings.getBrowserTabTitleAsO(), { initialValue: 'KIP' }); private readonly _responsive = inject(BreakpointObserver); private readonly _destroyRef = inject(DestroyRef); private readonly _notificationOverlay = inject(NotificationOverlayService); @@ -89,6 +93,11 @@ export class AppComponent implements OnInit, AfterViewInit, OnDestroy { private readonly _hotkeyHandler = (key: string) => this.handleKeyDown(key); constructor() { + // Keep the browser tab title (document.title) in sync with the user setting (#1055). + effect(() => { + this._titleService.setTitle(resolveBrowserTabTitle(this._browserTabTitle())); + }); + effect(() => { if (this.settings.configUpgrade()) { const liveVersion = this.settings.getConfigVersion(); diff --git a/src/app/core/components/options/display/display.component.html b/src/app/core/components/options/display/display.component.html index 46a2927ce..f4ec6afdc 100644 --- a/src/app/core/components/options/display/display.component.html +++ b/src/app/core/components/options/display/display.component.html @@ -55,6 +55,26 @@ Enable light theme for brighter display settings. + + + + Browser Tab + + @if (!isPhonePortrait().matches) { + + Set the browser tab title to tell multiple KIP instances apart. + + } + + + Browser tab title + + + diff --git a/src/app/core/components/options/display/display.component.ts b/src/app/core/components/options/display/display.component.ts index 221b9f2d2..13dbe55f8 100644 --- a/src/app/core/components/options/display/display.component.ts +++ b/src/app/core/components/options/display/display.component.ts @@ -57,6 +57,7 @@ export class SettingsDisplayComponent implements OnInit { protected isLightTheme = model(false); protected isRemoteControl = model(false); protected instanceName = model(''); + protected browserTabTitle = model('KIP'); protected splitShellEnabled = model(false); protected splitShellSide = model<'left' | 'right'>('left'); protected splitShellSwipeDisabled = model(false); @@ -78,6 +79,7 @@ export class SettingsDisplayComponent implements OnInit { this.isRedNightMode.set(this.settings.getRedNightMode()); this.isRemoteControl.set(this.settings.getIsRemoteControl()); this.instanceName.set(this.settings.getInstanceName()); + this.browserTabTitle.set(this.settings.getBrowserTabTitle()); this.splitShellEnabled.set(this.settings.getSplitShellEnabled()); this.splitShellSide.set(this.settings.getSplitShellSide()); this.splitShellSwipeDisabled.set(this.settings.getSplitShellSwipeDisabled()); @@ -127,6 +129,7 @@ export class SettingsDisplayComponent implements OnInit { this.settings.setSplitShellSide(this.splitShellSide()); this.settings.setSplitShellSwipeDisabled(this.splitShellSwipeDisabled()); this.settings.setWidgetHistoryDisabled(this.widgetHistoryDisabled()); + this.settings.setBrowserTabTitle(this.browserTabTitle()); this.settings.setDisablePathValidation(this.isPathValidationDisabled()); if (!this.setKipPluginConfig()) { this.toast.show('Failed to save KIP plugin configuration on server.', 0, false, 'error'); diff --git a/src/app/core/interfaces/app-settings.interfaces.ts b/src/app/core/interfaces/app-settings.interfaces.ts index 840eda890..4fb3d0e42 100644 --- a/src/app/core/interfaces/app-settings.interfaces.ts +++ b/src/app/core/interfaces/app-settings.interfaces.ts @@ -35,6 +35,7 @@ export interface IAppConfig { splitShellSwipeDisabled?: boolean; splitShellWidth?: number; widgetHistoryDisabled?: boolean; + browserTabTitle?: string; } export interface IThemeConfig { diff --git a/src/app/core/services/settings.service.ts b/src/app/core/services/settings.service.ts index b7e85fd87..dd10b99eb 100644 --- a/src/app/core/services/settings.service.ts +++ b/src/app/core/services/settings.service.ts @@ -38,6 +38,7 @@ export class SettingsService { private nightModeBrightness: BehaviorSubject = new BehaviorSubject(1); private isRemoteControl: BehaviorSubject = new BehaviorSubject(false); private instanceName: BehaviorSubject = new BehaviorSubject(''); + private browserTabTitle: BehaviorSubject = new BehaviorSubject('KIP'); private splitShellEnabled: BehaviorSubject = new BehaviorSubject(false); private splitShellSide: BehaviorSubject<'left' | 'right'> = new BehaviorSubject<'left' | 'right'>('left'); private splitShellSwipeDisabled: BehaviorSubject = new BehaviorSubject(false); @@ -231,6 +232,12 @@ export class SettingsService { this.instanceName.next(this.activeConfig.app.instanceName); } + if (this.activeConfig.app.browserTabTitle === undefined) { + this.browserTabTitle.next('KIP'); + } else { + this.browserTabTitle.next(this.activeConfig.app.browserTabTitle); + } + if (this.activeConfig.app.splitShellEnabled === undefined) { this.setSplitShellEnabled(false); } else { @@ -430,6 +437,26 @@ export class SettingsService { } } + // Browser tab title (document.title) + public getBrowserTabTitleAsO() { + return this.browserTabTitle.asObservable(); + } + + public getBrowserTabTitle(): string { + return this.browserTabTitle.getValue(); + } + + public setBrowserTabTitle(title: string) { + this.browserTabTitle.next(title); + const appConf = this.buildAppStorageObject(); + + if (this.useSharedConfig) { + this.storage.patchConfig('IAppConfig', appConf); + } else { + this.saveAppConfigToLocalStorage(); + } + } + public getDisablePathValidation(): boolean { return this.disablePathValidation; } @@ -681,7 +708,8 @@ export class SettingsService { splitShellSide: this.splitShellSide.getValue() ?? 'right', splitShellWidth: this.splitShellWidth.getValue() ?? 0.5, splitShellSwipeDisabled: this.splitShellSwipeDisabled.getValue(), - widgetHistoryDisabled: this.widgetHistoryDisabled.getValue() + widgetHistoryDisabled: this.widgetHistoryDisabled.getValue(), + browserTabTitle: this.browserTabTitle.getValue() } return storageObject; } diff --git a/src/app/core/utils/browser-tab-title.util.ts b/src/app/core/utils/browser-tab-title.util.ts index ad189a36d..84f3ebe21 100644 --- a/src/app/core/utils/browser-tab-title.util.ts +++ b/src/app/core/utils/browser-tab-title.util.ts @@ -3,6 +3,6 @@ * Falls back to 'KIP' when the value is empty/whitespace so the tab is never blank (#1055). */ export function resolveBrowserTabTitle(value?: string | null): string { - // RED stub: returns the raw value (no default / no trim) - replaced by the fix. - return (value ?? '') as string; + const trimmed = (value ?? '').trim(); + return trimmed.length ? trimmed : 'KIP'; } diff --git a/src/default-config/config.blank.const.ts b/src/default-config/config.blank.const.ts index 0c2ea98ff..e7a66d836 100644 --- a/src/default-config/config.blank.const.ts +++ b/src/default-config/config.blank.const.ts @@ -16,7 +16,8 @@ export const DefaultAppConfig: IAppConfig = { "splitShellEnabled": true, "splitShellSide": "left", "splitShellSwipeDisabled": false, - "splitShellWidth": 0.5 + "splitShellWidth": 0.5, + "browserTabTitle": "KIP" } export const DefaultThemeConfig: IThemeConfig = {