From addb1bd8442bf40899ebac63811ca37f24234373 Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Sat, 6 Jun 2026 12:35:16 -0700 Subject: [PATCH 1/8] feat: add tab session storage contract --- .../services/application/tab-session-state.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/app/services/application/tab-session-state.ts diff --git a/src/app/services/application/tab-session-state.ts b/src/app/services/application/tab-session-state.ts new file mode 100644 index 0000000..8b4d8e1 --- /dev/null +++ b/src/app/services/application/tab-session-state.ts @@ -0,0 +1,109 @@ +import { isPlatformBrowser } from '@angular/common'; + +export const TAB_SESSION_STORAGE_KEY = 'csss-tab-session'; +const TAB_SESSION_VERSION = 1; + +/** + * Persisted tab session stored in local storage. + */ +export interface TabSessionState { + version: typeof TAB_SESSION_VERSION; + applicationIds: number[]; +} + +const buildTabSessionState = (applicationIds: number[]): TabSessionState => ({ + version: TAB_SESSION_VERSION, + applicationIds +}); + +const isValidApplicationIds = (value: unknown): value is number[] => + Array.isArray(value) && value.every(id => typeof id === 'number' && Number.isInteger(id)); + +/** + * Checks whether a parsed JSON value has the expected tab session shape and values. + */ +const isTabSessionState = (value: unknown): value is TabSessionState => { + if (typeof value !== 'object' || value === null) { + return false; + } + + if (!('version' in value) || !('applicationIds' in value)) { + return false; + } + + const { version, applicationIds } = value as { + version: unknown; + applicationIds: unknown; + }; + + return version === TAB_SESSION_VERSION && isValidApplicationIds(applicationIds); +}; + +/** + * Serializes open application IDs to a JSON string for local storage. + * + * @param applicationIds - Open application IDs in tab order. + */ +export const serializeTabSession = (applicationIds: number[]): string => { + return JSON.stringify(buildTabSessionState(applicationIds)); +}; + +/** + * Parses a stored tab session JSON string. + * + * @param raw - Raw JSON string from local storage. + * @returns Parsed state, or null if the value is invalid. + */ +export const parseTabSession = (raw: string): TabSessionState | null => { + try { + const parsed: unknown = JSON.parse(raw); + + if (!isTabSessionState(parsed)) { + return null; + } + + return buildTabSessionState(parsed.applicationIds); + } catch { + return null; + } +}; + +/** + * Reads the stored tab session from local storage. + * + * @param platformId - Angular platform ID used to guard browser-only access. + */ +export const readTabSession = (platformId: object): TabSessionState | null => { + if (!isPlatformBrowser(platformId)) { + return null; + } + + try { + const raw = localStorage.getItem(TAB_SESSION_STORAGE_KEY); + if (raw === null) { + return null; + } + + return parseTabSession(raw); + } catch { + return null; + } +}; + +/** + * Writes open application IDs to local storage. + * + * @param applicationIds - Open application IDs in tab order. + * @param platformId - Angular platform ID used to guard browser-only access. + */ +export const writeTabSession = (applicationIds: number[], platformId: object): void => { + if (!isPlatformBrowser(platformId)) { + return; + } + + try { + localStorage.setItem(TAB_SESSION_STORAGE_KEY, serializeTabSession(applicationIds)); + } catch { + // Ignore errors. + } +}; From 2e0985342ef5b7d86d567e00d0d4c06a5ef7e66e Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Sat, 6 Jun 2026 16:53:21 -0700 Subject: [PATCH 2/8] feat: wire ApplicationService to restore tab bar from localStorage --- .../application/application.service.ts | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/src/app/services/application/application.service.ts b/src/app/services/application/application.service.ts index 30d314e..4750e95 100644 --- a/src/app/services/application/application.service.ts +++ b/src/app/services/application/application.service.ts @@ -1,8 +1,10 @@ -import { inject, Injectable, signal, WritableSignal } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID, signal, WritableSignal } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { filter } from 'rxjs'; import { addToSignalMap, removeFromSignalMap } from 'utils/signal-utils'; -import { AppInfo, getApplicationByRoute } from './applications'; +import { AppInfo, getApplicationById, getApplicationByRoute } from './applications'; +import { readTabSession } from './tab-session-state'; /** * Service that handles updating what applications are currently running. @@ -26,7 +28,11 @@ export class ApplicationService { router = inject(Router); + private platformId = inject(PLATFORM_ID); + constructor() { + this.restoreSession(); + // Observable that emits when navigating to a new URL has completed. // Used to check if the route should launch an application or change the content of an application. this.router.events @@ -48,6 +54,43 @@ export class ApplicationService { }); } + /** + * Rebuilds open tabs from the tab session saved in local storage. + * Invalid or removed application IDs are skipped. + */ + private restoreSession(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const session = readTabSession(this.platformId); + if (!session) { + return; + } + + const restoredApplications = new Map(); + + for (const id of session.applicationIds) { + const application = getApplicationById(id); + if (!application) { + continue; + } + + const hasConflictingActivity = [...restoredApplications.values()].some( + app => app.activityKey === application.activityKey + ); + if (hasConflictingActivity) { + continue; + } + + restoredApplications.set(application.id, application); + } + + if (restoredApplications.size) { + this.runningApplications.set(restoredApplications); + } + } + /** * Try to open the application. * From fea173b597ec425b6f38cce87081540f9c36dcab Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Sat, 6 Jun 2026 17:10:43 -0700 Subject: [PATCH 3/8] feat: persist open applications across page loads --- .../application/application.service.spec.ts | 86 ++++++++++- .../application/application.service.ts | 136 +++++++++++++----- src/app/services/application/applications.ts | 33 ++++- 3 files changed, 214 insertions(+), 41 deletions(-) diff --git a/src/app/services/application/application.service.spec.ts b/src/app/services/application/application.service.spec.ts index d0bf0f1..a88e9b0 100644 --- a/src/app/services/application/application.service.spec.ts +++ b/src/app/services/application/application.service.spec.ts @@ -1,16 +1,98 @@ +import { PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; - +import { NavigationEnd, Router } from '@angular/router'; +import { Subject } from 'rxjs'; +import { vi } from 'vitest'; +import { TAB_SESSION_STORAGE_KEY } from './tab-session-state'; import { ApplicationService } from './application.service'; describe('ApplicationService', () => { let service: ApplicationService; + let routerEvents: Subject; + let navigateSpy: ReturnType; + + const getStoredApplicationIds = (): number[] => { + const raw = localStorage.getItem(TAB_SESSION_STORAGE_KEY); + if (!raw) { + return []; + } + + return JSON.parse(raw).applicationIds as number[]; + }; + + const navigateTo = (url: string): void => { + routerEvents.next(new NavigationEnd(1, url, url)); + }; beforeEach(() => { - TestBed.configureTestingModule({}); + localStorage.clear(); + routerEvents = new Subject(); + navigateSpy = vi.fn(); + + TestBed.configureTestingModule({ + providers: [ + { provide: Router, useValue: { events: routerEvents.asObservable(), navigate: navigateSpy } }, + { provide: PLATFORM_ID, useValue: 'browser' } + ] + }); + service = TestBed.inject(ApplicationService); }); it('should be created', () => { expect(service).toBeTruthy(); }); + + it('should restore valid application IDs from local storage', () => { + localStorage.setItem( + TAB_SESSION_STORAGE_KEY, + JSON.stringify({ version: 1, applicationIds: [0, 1, 999] }) + ); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: Router, useValue: { events: routerEvents.asObservable(), navigate: navigateSpy } }, + { provide: PLATFORM_ID, useValue: 'browser' } + ] + }); + service = TestBed.inject(ApplicationService); + + expect([...service.runningApplications().keys()]).toEqual([0, 1]); + }); + + it('should persist when a new application is opened', () => { + navigateTo('/readme'); + + expect(getStoredApplicationIds()).toEqual([0]); + }); + + it('should not persist when switching focus between open applications', () => { + navigateTo('/readme'); + navigateTo('/officers'); + const persistedAfterBothOpen = localStorage.getItem(TAB_SESSION_STORAGE_KEY); + + navigateTo('/readme'); + + expect(localStorage.getItem(TAB_SESSION_STORAGE_KEY)).toBe(persistedAfterBothOpen); + expect([...service.runningApplications().keys()]).toEqual([0, 1]); + }); + + it('should persist when an application is closed', () => { + navigateTo('/readme'); + navigateTo('/officers'); + + service.closeApplication(0); + + expect(getStoredApplicationIds()).toEqual([1]); + }); + + it('should persist an empty tab session when the last application is closed', () => { + navigateTo('/readme'); + + service.closeApplication(0); + + expect(getStoredApplicationIds()).toEqual([]); + expect(navigateSpy).toHaveBeenCalledWith(['/']); + }); }); diff --git a/src/app/services/application/application.service.ts b/src/app/services/application/application.service.ts index 4750e95..43f44d8 100644 --- a/src/app/services/application/application.service.ts +++ b/src/app/services/application/application.service.ts @@ -3,8 +3,13 @@ import { inject, Injectable, PLATFORM_ID, signal, WritableSignal } from '@angula import { NavigationEnd, Router } from '@angular/router'; import { filter } from 'rxjs'; import { addToSignalMap, removeFromSignalMap } from 'utils/signal-utils'; -import { AppInfo, getApplicationById, getApplicationByRoute } from './applications'; -import { readTabSession } from './tab-session-state'; +import { + AppInfo, + buildRunningApplicationsFromIds, + getApplicationByRoute, + shareSameActivityGroup +} from './applications'; +import { readTabSession, serializeTabSession, writeTabSession } from './tab-session-state'; /** * Service that handles updating what applications are currently running. @@ -30,6 +35,8 @@ export class ApplicationService { private platformId = inject(PLATFORM_ID); + private lastPersisted: string | null = null; + constructor() { this.restoreSession(); @@ -54,6 +61,29 @@ export class ApplicationService { }); } + /** + * Closes the application based on the unique ID. + * + * @param id - The ID of the application to close. + */ + closeApplication(id: number): void { + if (!this.isApplicationRunning(id)) { + return; + } + + this.removeRunningApplication(id); + + if (this.runningApplications().size) { + const nextApp = this.runningApplications().entries().next().value; + if (nextApp) { + this.focusApplication(nextApp[1]); + } + } else { + this.focusedApplication.set(null); + this.router.navigate(['/']); + } + } + /** * Rebuilds open tabs from the tab session saved in local storage. * Invalid or removed application IDs are skipped. @@ -68,27 +98,31 @@ export class ApplicationService { return; } - const restoredApplications = new Map(); - - for (const id of session.applicationIds) { - const application = getApplicationById(id); - if (!application) { - continue; - } + const restoredApplications = buildRunningApplicationsFromIds(session.applicationIds); - const hasConflictingActivity = [...restoredApplications.values()].some( - app => app.activityKey === application.activityKey - ); - if (hasConflictingActivity) { - continue; - } + if (restoredApplications.size) { + this.runningApplications.set(restoredApplications); + this.lastPersisted = serializeTabSession([...restoredApplications.keys()]); + } + } - restoredApplications.set(application.id, application); + /** + * Saves the current open applications to local storage. + */ + private persistRunningApplications(): void { + if (!isPlatformBrowser(this.platformId)) { + return; } - if (restoredApplications.size) { - this.runningApplications.set(restoredApplications); + const applicationIds = [...this.runningApplications().keys()]; + const serialized = serializeTabSession(applicationIds); + + if (serialized === this.lastPersisted) { + return; } + + writeTabSession(applicationIds, this.platformId); + this.lastPersisted = serialized; } /** @@ -97,38 +131,64 @@ export class ApplicationService { * @param application - Application to try and launch. */ private openApplication(application: AppInfo): void { - const focusedApp = this.focusedApplication(); + if (this.isAlreadyFocused(application)) { + return; + } - if (focusedApp?.id === application.id) { + if (this.isApplicationRunning(application.id)) { + this.focusApplication(application); return; } - addToSignalMap(this.runningApplications, application.id, application); - this.focusedApplication.set(application); + this.addRunningApplication(application); + } - // The activity for this app might be open, but not focused. Try and remove it. - for (const app of this.runningApplications().values()) { - if (app.activityKey === application.activityKey && app.id !== application.id) { - this.closeApplication(app.id); - break; - } + /** + * Updates focus without changing which applications are open. + */ + private focusApplication(application: AppInfo): void { + if (this.isAlreadyFocused(application)) { + return; } + + this.focusedApplication.set(application); } /** - * Closes the application based on the unique ID. - * - * @param id - The ID of the application to close. + * Adds an application to the running set and persists the update. */ - closeApplication(id: number): void { + private addRunningApplication(application: AppInfo): void { + addToSignalMap(this.runningApplications, application.id, application); + this.focusApplication(application); + this.removeConflictingApplications(application); + this.persistRunningApplications(); + } + + /** + * Removes an application from the running set and persists the update. + */ + private removeRunningApplication(id: number): void { removeFromSignalMap(this.runningApplications, id); - if (this.runningApplications().size) { - const nextApp = this.runningApplications().entries().next().value; - if (nextApp) { - this.openApplication(nextApp[1]); + this.persistRunningApplications(); + } + + /** + * Removes applications that cannot run alongside the given application. + */ + private removeConflictingApplications(application: AppInfo): void { + for (const app of this.runningApplications().values()) { + if (shareSameActivityGroup(app, application) && app.id !== application.id) { + removeFromSignalMap(this.runningApplications, app.id); + break; } - } else { - this.router.navigate(['/']); } } + + private isAlreadyFocused(application: AppInfo): boolean { + return this.focusedApplication()?.id === application.id; + } + + private isApplicationRunning(id: number): boolean { + return this.runningApplications().has(id); + } } diff --git a/src/app/services/application/applications.ts b/src/app/services/application/applications.ts index 39cabad..f7e8c4f 100644 --- a/src/app/services/application/applications.ts +++ b/src/app/services/application/applications.ts @@ -18,7 +18,8 @@ export interface AppInfo { /** * The key activity this applications will use. - * Applications that have the same key can't run alongside each other. + * Applications that share the same non-empty key can't run alongside each other. + * An empty key means the application is standalone. */ activityKey: string; @@ -158,3 +159,33 @@ export const getApplicationByRoute = (route: string): AppInfo | undefined => { } return; }; + +export const isStandaloneActivity = (activityKey: string): boolean => activityKey === ''; + +export const shareSameActivityGroup = (first: AppInfo, second: AppInfo): boolean => { + return !isStandaloneActivity(first.activityKey) && first.activityKey === second.activityKey; +}; + +export const buildRunningApplicationsFromIds = ( + applicationIds: number[] +): Map => { + const runningApplications = new Map(); + + for (const id of applicationIds) { + const application = getApplicationById(id); + if (!application) { + continue; + } + + const hasConflictingActivity = [...runningApplications.values()].some(app => + shareSameActivityGroup(app, application) + ); + if (hasConflictingActivity) { + continue; + } + + runningApplications.set(application.id, application); + } + + return runningApplications; +}; From edd511c8aed28d4aed6c1643eaf91cff9b9544bb Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Sat, 6 Jun 2026 18:05:57 -0700 Subject: [PATCH 4/8] feat: resize tabs based on size and number of tabs --- .../components/tab-bar/tab-bar.component.html | 14 ++++++- .../components/tab-bar/tab-bar.component.scss | 37 ++++++++++++++++- .../components/tab-bar/tab-bar.component.ts | 41 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/app/components/tab-bar/tab-bar.component.html b/src/app/components/tab-bar/tab-bar.component.html index a3747ff..1f5841a 100644 --- a/src/app/components/tab-bar/tab-bar.component.html +++ b/src/app/components/tab-bar/tab-bar.component.html @@ -1,5 +1,10 @@ @for (tab of tabs(); track $index) { -
+
{{ tab.label }}
@@ -9,3 +14,10 @@
} + +
{{ tooltipData().text }}
diff --git a/src/app/components/tab-bar/tab-bar.component.scss b/src/app/components/tab-bar/tab-bar.component.scss index d118c63..5499711 100644 --- a/src/app/components/tab-bar/tab-bar.component.scss +++ b/src/app/components/tab-bar/tab-bar.component.scss @@ -6,6 +6,12 @@ width: 100%; height: 100%; background-color: theme.$bg3; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } } .label { @@ -22,13 +28,15 @@ .tab { @include g.no-select; + flex: 1 1 0; + min-width: 5rem; + max-width: 12rem; display: grid; grid-template-columns: 5fr 2rem; align-content: center; background-color: theme.$bg2; box-shadow: 0px -1px 0px theme.$outline inset; - width: clamp(9rem, 20vw, 12rem); & > .x-container { text-align: center; @@ -40,6 +48,33 @@ } } +.tab-tooltip { + position: fixed; + transform: translateX(-50%); + z-index: 9999; + pointer-events: none; + + background-color: oklch(10% 0 0); + color: theme.$code-text; + font-size: 0.72rem; + white-space: nowrap; + padding: 3px 8px; + border-radius: 3px; + border: 1px solid theme.$outline; + + opacity: 0; + visibility: hidden; + transition: + opacity 0.15s ease, + transform 0.12s ease, + visibility 0.15s ease; + + &.visible { + opacity: 1; + visibility: visible; + } +} + .tab.focused { background-color: theme.$bg4; border: none; diff --git a/src/app/components/tab-bar/tab-bar.component.ts b/src/app/components/tab-bar/tab-bar.component.ts index 39f8a62..a21611c 100644 --- a/src/app/components/tab-bar/tab-bar.component.ts +++ b/src/app/components/tab-bar/tab-bar.component.ts @@ -4,12 +4,20 @@ import { computed, HostBinding, inject, + signal, Signal } from '@angular/core'; import { ApplicationService } from 'services/application/application.service'; import { UiService } from 'services/ui/ui.service'; import { STRUCTURE_MAP } from 'styles/structure'; +interface TooltipData { + text: string; + x: number; + y: number; + visible: boolean; +} + export interface TabBarItem { label: string; id: number; @@ -56,6 +64,13 @@ export class TabBarComponent { return result; }); + tooltipData = signal({ + text: '', + x: 0, + y: 0, + visible: false + }); + /** * Hide the tabs bar if the screen size is small and there are no applications running. */ @@ -64,6 +79,32 @@ export class TabBarComponent { !this.uiService.isLargeViewport() && this.applicationService.runningApplications().size === 0 ); + /** + * Shows a popup of label when tab is hovered and smaller tabs. + */ + showTooltip(tab: TabBarItem, event: MouseEvent): void { + const el = event.currentTarget as HTMLElement; + const rect = el.getBoundingClientRect(); + const tabWidth = rect.width; + + // Only show when the tab label is actually clipped (~7rem = 112px) + if (tabWidth >= 112) return; + + this.tooltipData.set({ + text: tab.label, + x: rect.left + rect.width / 2, + y: rect.bottom + 6, + visible: true + }); + } + + hideTooltip(): void { + this.tooltipData.update(state => ({ + ...state, + visible: false + })); + } + focusTab(tab: TabBarItem): void { this.applicationService.router.navigateByUrl(tab.route); } From 815893218e44fd41a0a1677e2324342fe8d8fd54 Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Sat, 6 Jun 2026 18:34:22 -0700 Subject: [PATCH 5/8] fix: closing tabs should go to the recent tabs --- .../components/tab-bar/tab-bar.component.html | 21 ++++++++-- .../components/tab-bar/tab-bar.component.ts | 22 ++++++++--- .../application/application.service.spec.ts | 39 ++++++++++++++++++- .../application/application.service.ts | 35 ++++++++++++----- .../services/application/tab-session-state.ts | 16 ++------ 5 files changed, 102 insertions(+), 31 deletions(-) diff --git a/src/app/components/tab-bar/tab-bar.component.html b/src/app/components/tab-bar/tab-bar.component.html index 1f5841a..18cb0bf 100644 --- a/src/app/components/tab-bar/tab-bar.component.html +++ b/src/app/components/tab-bar/tab-bar.component.html @@ -4,13 +4,26 @@ [class.focused]="tab.focused" (mouseenter)="showTooltip(tab, $event)" (mouseleave)="hideTooltip()" + [id]="'tab-item-' + tab.id" > -
+
{{ tab.label }}
- +
} @@ -20,4 +33,6 @@ [class.visible]="tooltipData().visible" [style.left.px]="tooltipData().x" [style.top.px]="tooltipData().y" ->{{ tooltipData().text }}
+> + {{ tooltipData().text }} + diff --git a/src/app/components/tab-bar/tab-bar.component.ts b/src/app/components/tab-bar/tab-bar.component.ts index a21611c..360110d 100644 --- a/src/app/components/tab-bar/tab-bar.component.ts +++ b/src/app/components/tab-bar/tab-bar.component.ts @@ -2,10 +2,12 @@ import { ChangeDetectionStrategy, Component, computed, + effect, HostBinding, inject, signal, - Signal + Signal, + untracked } from '@angular/core'; import { ApplicationService } from 'services/application/application.service'; import { UiService } from 'services/ui/ui.service'; @@ -37,6 +39,13 @@ export class TabBarComponent { return this.isHidden() ? '0px' : STRUCTURE_MAP['tab-bar-h']; } + constructor() { + effect(() => { + this.tabs(); + untracked(() => this.hideTooltip()); + }); + } + /** * Gives the tabs-bar the applications to display. */ @@ -84,12 +93,14 @@ export class TabBarComponent { */ showTooltip(tab: TabBarItem, event: MouseEvent): void { const el = event.currentTarget as HTMLElement; - const rect = el.getBoundingClientRect(); - const tabWidth = rect.width; + const label = el.querySelector('.label'); - // Only show when the tab label is actually clipped (~7rem = 112px) - if (tabWidth >= 112) return; + // Only show the tooltip when the label text is actually being clipped. + if (!label || label.scrollWidth <= label.clientWidth) { + return; + } + const rect = el.getBoundingClientRect(); this.tooltipData.set({ text: tab.label, x: rect.left + rect.width / 2, @@ -115,6 +126,7 @@ export class TabBarComponent { * @param index - The index of the tabs to close. */ closeTab(index: number): void { + this.hideTooltip(); this.applicationService.closeApplication(index); } } diff --git a/src/app/services/application/application.service.spec.ts b/src/app/services/application/application.service.spec.ts index a88e9b0..5550844 100644 --- a/src/app/services/application/application.service.spec.ts +++ b/src/app/services/application/application.service.spec.ts @@ -10,6 +10,7 @@ describe('ApplicationService', () => { let service: ApplicationService; let routerEvents: Subject; let navigateSpy: ReturnType; + let navigateByUrlSpy: ReturnType; const getStoredApplicationIds = (): number[] => { const raw = localStorage.getItem(TAB_SESSION_STORAGE_KEY); @@ -28,10 +29,11 @@ describe('ApplicationService', () => { localStorage.clear(); routerEvents = new Subject(); navigateSpy = vi.fn(); + navigateByUrlSpy = vi.fn(); TestBed.configureTestingModule({ providers: [ - { provide: Router, useValue: { events: routerEvents.asObservable(), navigate: navigateSpy } }, + { provide: Router, useValue: { events: routerEvents.asObservable(), navigate: navigateSpy, navigateByUrl: navigateByUrlSpy } }, { provide: PLATFORM_ID, useValue: 'browser' } ] }); @@ -52,7 +54,7 @@ describe('ApplicationService', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ - { provide: Router, useValue: { events: routerEvents.asObservable(), navigate: navigateSpy } }, + { provide: Router, useValue: { events: routerEvents.asObservable(), navigate: navigateSpy, navigateByUrl: navigateByUrlSpy } }, { provide: PLATFORM_ID, useValue: 'browser' } ] }); @@ -95,4 +97,37 @@ describe('ApplicationService', () => { expect(getStoredApplicationIds()).toEqual([]); expect(navigateSpy).toHaveBeenCalledWith(['/']); }); + + it('should navigate to the left neighbor when closing a focused tab', () => { + navigateTo('/readme'); + navigateTo('/officers'); + navigateTo('/committees'); + + service.closeApplication(2); + + expect(navigateByUrlSpy).toHaveBeenCalledWith('/officers'); + }); + + it('should navigate to the right neighbor when there is no left neighbor', () => { + navigateTo('/readme'); + navigateTo('/officers'); + + // Focus the leftmost tab so it has no left neighbor. + navigateTo('/readme'); + + service.closeApplication(0); + + expect(navigateByUrlSpy).toHaveBeenCalledWith('/officers'); + }); + + it('should not navigate when closing a background tab', () => { + navigateTo('/readme'); + navigateTo('/officers'); + + service.closeApplication(0); + + expect(navigateByUrlSpy).not.toHaveBeenCalled(); + expect(navigateSpy).not.toHaveBeenCalled(); + expect(service.focusedApplication()?.id).toBe(1); + }); }); diff --git a/src/app/services/application/application.service.ts b/src/app/services/application/application.service.ts index 43f44d8..6cad275 100644 --- a/src/app/services/application/application.service.ts +++ b/src/app/services/application/application.service.ts @@ -63,6 +63,7 @@ export class ApplicationService { /** * Closes the application based on the unique ID. + * When the closed tab was focused, switches to the nearest neighbor. * * @param id - The ID of the application to close. */ @@ -71,16 +72,32 @@ export class ApplicationService { return; } + const wasFocused = this.focusedApplication()?.id === id; + + // Determine the adjacent tab before removal so we can switch to it. + let nextRoute: string | null = null; + if (wasFocused) { + const entries = [...this.runningApplications().entries()]; + const index = entries.findIndex(([appId]) => appId === id); + + if (index > 0) { + // Prefer the tab to the left. + nextRoute = entries[index - 1][1].route; + } else if (index < entries.length - 1) { + // Fall back to the tab on the right. + nextRoute = entries[index + 1][1].route; + } + } + this.removeRunningApplication(id); - if (this.runningApplications().size) { - const nextApp = this.runningApplications().entries().next().value; - if (nextApp) { - this.focusApplication(nextApp[1]); + if (wasFocused) { + if (nextRoute) { + this.router.navigateByUrl(nextRoute); + } else { + this.focusedApplication.set(null); + this.router.navigate(['/']); } - } else { - this.focusedApplication.set(null); - this.router.navigate(['/']); } } @@ -93,7 +110,7 @@ export class ApplicationService { return; } - const session = readTabSession(this.platformId); + const session = readTabSession(); if (!session) { return; } @@ -121,7 +138,7 @@ export class ApplicationService { return; } - writeTabSession(applicationIds, this.platformId); + writeTabSession(applicationIds); this.lastPersisted = serialized; } diff --git a/src/app/services/application/tab-session-state.ts b/src/app/services/application/tab-session-state.ts index 8b4d8e1..5cc85f1 100644 --- a/src/app/services/application/tab-session-state.ts +++ b/src/app/services/application/tab-session-state.ts @@ -1,5 +1,3 @@ -import { isPlatformBrowser } from '@angular/common'; - export const TAB_SESSION_STORAGE_KEY = 'csss-tab-session'; const TAB_SESSION_VERSION = 1; @@ -68,13 +66,8 @@ export const parseTabSession = (raw: string): TabSessionState | null => { } }; -/** - * Reads the stored tab session from local storage. - * - * @param platformId - Angular platform ID used to guard browser-only access. - */ -export const readTabSession = (platformId: object): TabSessionState | null => { - if (!isPlatformBrowser(platformId)) { +export const readTabSession = (): TabSessionState | null => { + if (typeof window === 'undefined' || typeof localStorage === 'undefined') { return null; } @@ -94,10 +87,9 @@ export const readTabSession = (platformId: object): TabSessionState | null => { * Writes open application IDs to local storage. * * @param applicationIds - Open application IDs in tab order. - * @param platformId - Angular platform ID used to guard browser-only access. */ -export const writeTabSession = (applicationIds: number[], platformId: object): void => { - if (!isPlatformBrowser(platformId)) { +export const writeTabSession = (applicationIds: number[]): void => { + if (typeof window === 'undefined' || typeof localStorage === 'undefined') { return; } From e521ebad33e4ee6dbe4d3cee606ef369e6beadd8 Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Sat, 6 Jun 2026 20:30:44 -0700 Subject: [PATCH 6/8] refactor: optimize layout costraints, loop performance --- src/app/app.component.scss | 2 +- src/app/components/tab-bar/tab-bar.component.scss | 1 + src/app/services/application/applications.ts | 14 +++++++++++--- src/app/services/application/tab-session-state.ts | 11 ++++++++++- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 0bc63ae..c52b71c 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -19,7 +19,7 @@ /* Change the grid layout when we hit a certain size. */ @media (min-width: g.$breakpoint-large) { :host { - grid-template-columns: min-content auto; + grid-template-columns: min-content 1fr; grid-template-rows: #{g.$tab-bar-h} auto #{g.$status-line-h}; } diff --git a/src/app/components/tab-bar/tab-bar.component.scss b/src/app/components/tab-bar/tab-bar.component.scss index 5499711..0d6d376 100644 --- a/src/app/components/tab-bar/tab-bar.component.scss +++ b/src/app/components/tab-bar/tab-bar.component.scss @@ -14,6 +14,7 @@ } } + .label { overflow: hidden; text-overflow: ellipsis; diff --git a/src/app/services/application/applications.ts b/src/app/services/application/applications.ts index f7e8c4f..50bd5be 100644 --- a/src/app/services/application/applications.ts +++ b/src/app/services/application/applications.ts @@ -172,14 +172,22 @@ export const buildRunningApplicationsFromIds = ( const runningApplications = new Map(); for (const id of applicationIds) { + if (runningApplications.has(id)) { + continue; + } + const application = getApplicationById(id); if (!application) { continue; } - const hasConflictingActivity = [...runningApplications.values()].some(app => - shareSameActivityGroup(app, application) - ); + let hasConflictingActivity = false; + for (const existing of runningApplications.values()) { + if (shareSameActivityGroup(existing, application)) { + hasConflictingActivity = true; + break; + } + } if (hasConflictingActivity) { continue; } diff --git a/src/app/services/application/tab-session-state.ts b/src/app/services/application/tab-session-state.ts index 5cc85f1..fb4f120 100644 --- a/src/app/services/application/tab-session-state.ts +++ b/src/app/services/application/tab-session-state.ts @@ -66,6 +66,11 @@ export const parseTabSession = (raw: string): TabSessionState | null => { } }; +/** + * Reads the stored tab session from local storage. + * If the stored value is corrupt or outdated, the key is removed so it + * does not cause repeated parse failures on every subsequent boot. + */ export const readTabSession = (): TabSessionState | null => { if (typeof window === 'undefined' || typeof localStorage === 'undefined') { return null; @@ -77,7 +82,11 @@ export const readTabSession = (): TabSessionState | null => { return null; } - return parseTabSession(raw); + const parsed = parseTabSession(raw); + if (parsed === null) { + localStorage.removeItem(TAB_SESSION_STORAGE_KEY); + } + return parsed; } catch { return null; } From 7db32fb350ba060d4c0fadc9f7924a1b4aadb6f8 Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Sat, 6 Jun 2026 20:57:37 -0700 Subject: [PATCH 7/8] style: consistency with font size fix error message --- src/app/components/tab-bar/tab-bar.component.scss | 5 +++-- src/app/services/application/tab-session-state.ts | 6 ++++-- src/styles/_fonts.scss | 1 + 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/components/tab-bar/tab-bar.component.scss b/src/app/components/tab-bar/tab-bar.component.scss index 0d6d376..21fca6f 100644 --- a/src/app/components/tab-bar/tab-bar.component.scss +++ b/src/app/components/tab-bar/tab-bar.component.scss @@ -55,9 +55,10 @@ z-index: 9999; pointer-events: none; - background-color: oklch(10% 0 0); + background-color: theme.$bg1; color: theme.$code-text; - font-size: 0.72rem; + font-family: g.$main-font; + font-size: g.$small-font-size; white-space: nowrap; padding: 3px 8px; border-radius: 3px; diff --git a/src/app/services/application/tab-session-state.ts b/src/app/services/application/tab-session-state.ts index fb4f120..4fc0a67 100644 --- a/src/app/services/application/tab-session-state.ts +++ b/src/app/services/application/tab-session-state.ts @@ -1,3 +1,5 @@ +import { error } from "console"; + export const TAB_SESSION_STORAGE_KEY = 'csss-tab-session'; const TAB_SESSION_VERSION = 1; @@ -104,7 +106,7 @@ export const writeTabSession = (applicationIds: number[]): void => { try { localStorage.setItem(TAB_SESSION_STORAGE_KEY, serializeTabSession(applicationIds)); - } catch { - // Ignore errors. + } catch (error) { + console.warn('Failed to write tab session to local storage', error); } }; diff --git a/src/styles/_fonts.scss b/src/styles/_fonts.scss index 0305e09..3cf7f4e 100644 --- a/src/styles/_fonts.scss +++ b/src/styles/_fonts.scss @@ -6,3 +6,4 @@ $h1-font-size: clamp(1.8rem, 2.5vw, 2.2rem); $h2-font-size: clamp(1.4rem, 2.2vw, 1.8rem); $h3-font-size: clamp(1.2rem, 2vw, 1.4rem); $normal-font-size: clamp(1rem, 1.4vw, 1.2rem); +$small-font-size: clamp(0.8rem, 1.2vw, 1rem); \ No newline at end of file From 574726df684ea0277a92ee4ea45826f58495ad13 Mon Sep 17 00:00:00 2001 From: Karen Yao Date: Sat, 6 Jun 2026 21:17:13 -0700 Subject: [PATCH 8/8] style: use imported fontawesome icon --- src/app/components/tab-bar/tab-bar.component.html | 2 +- src/app/components/tab-bar/tab-bar.component.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/components/tab-bar/tab-bar.component.html b/src/app/components/tab-bar/tab-bar.component.html index 18cb0bf..89b6346 100644 --- a/src/app/components/tab-bar/tab-bar.component.html +++ b/src/app/components/tab-bar/tab-bar.component.html @@ -22,7 +22,7 @@ (click)="closeTab(tab.id); $event.stopPropagation()" [id]="'tab-close-' + tab.id" > - ✕ + diff --git a/src/app/components/tab-bar/tab-bar.component.ts b/src/app/components/tab-bar/tab-bar.component.ts index 360110d..f06c7ac 100644 --- a/src/app/components/tab-bar/tab-bar.component.ts +++ b/src/app/components/tab-bar/tab-bar.component.ts @@ -12,6 +12,8 @@ import { import { ApplicationService } from 'services/application/application.service'; import { UiService } from 'services/ui/ui.service'; import { STRUCTURE_MAP } from 'styles/structure'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; interface TooltipData { text: string; @@ -29,11 +31,14 @@ export interface TabBarItem { @Component({ selector: 'cs-tab-bar', + imports: [FontAwesomeModule], templateUrl: './tab-bar.component.html', styleUrl: './tab-bar.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class TabBarComponent { + protected closeIcon = faXmark; + @HostBinding('style.height') get height(): string { return this.isHidden() ? '0px' : STRUCTURE_MAP['tab-bar-h'];