* fix(search): keep in-flight typing when the page rewrites its query params `WorkspaceShellSearchSyncService` re-read `q` on every `NavigationEnd` and unconditionally called `setSearchState(...)`, which cancels the pending debounce and overwrites the search box. Any same-page navigation that carried no search intent — a downloads filter chip writing `?filter=…`, a refresh bump, or the router echoing back our own `q` — therefore ate whatever had been typed since the last applied term. While input is debouncing, ignore a navigation that stays on the same page and carries the term already applied. Real search intent (route change, back/forward, a different `q`) still syncs as before. This is the race behind the flaky `@downloads @electron keeps global and scoped libraries truthful …` e2e test: it clicks a filter chip and fills the searchbox with nothing awaited in between, so under CI load the chip's navigation lands after the keystroke, wipes the term, and `q` is never written. Reproduced deterministically by dispatching the chip click and the `input` event in the same page task; the searchbox value goes to `""` and `q` stays `null`. The new spec fails on the old code for the two regression cases and passes for the three guard cases on both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(search): keep history authoritative and retire the debounce on Enter Two follow-ups from review of the same-page navigation guard. Greptile: the guard could not tell an app-initiated `q` echo from back/forward landing on a history entry that carries the same term. Key the exemption on `Navigation.trigger === 'imperative'` instead, so browser history always wins over in-flight typing. `lastSuccessfulNavigation` is set immediately before `NavigationEnd` is emitted, so it describes the navigation being handled. Codex: with the guard in place, an Enter commit no longer had its queued debounce cancelled as a side effect of the resulting `NavigationEnd`. Typing "Beta " and pressing Enter before the debounce expired applied the trimmed term, then the stale timeout reapplied the untrimmed one — leaving the box and URL on "Beta" while the provider store searched "Beta ". `applySearchQuery()` now cancels the pending debounce itself, which is the correct owner of that rule rather than relying on a navigation side effect. Both new tests were mutation-checked: dropping either sub-fix fails exactly its own test and no other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
19 KiB
Workspace Shell
This document records the current workspace-first shell contract. It is the stable replacement for the older UI refactor summary.
Related:
Summary
/workspaceis the primary app surface.WorkspaceShellComponentowns the persistent frame: rail, header, optional context panel, content outlet, and external playback footer.- Descendant workspace pages inherit
layout = 'workspace'from the/workspaceroot route. - Provider route trees now bootstrap through route-scoped session providers instead of nested provider shell components.
Core implementation:
apps/web/src/app/app.routes.tslibs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.tslibs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.htmllibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.tslibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.tslibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.tslibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.tslibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.tslibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-command-palette.service.tslibs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.tslibs/portal/shared/util/src/lib/navigation/portal-route.utils.tslibs/portal/shared/util/src/lib/navigation/portal-rail-links.tslibs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts
Route Contract
Current workspace routes:
/->/workspace/workspace-> functional redirectworkspaceEntryRedirect(WorkspaceStartupPreferencesService.resolveInitialWorkspacePath();/workspace/dashboardby default,/workspace/sourceswhen the dashboard is disabled, or the last restorable route underStartupBehavior.RestoreLastView—dashboarditself is guarded bydashboardAccessGuard)/workspace/dashboard/workspace/sources/workspace/playlists/:id/:view(plusfavoritesandrecentsiblings)/workspace/global-favorites/workspace/global-recent/workspace/search/workspace/downloads/workspace/settings/:section(/workspace/settingsredirects togeneral; the settings context panel links each section page)/workspace/xtreams/:id/.../workspace/stalker/:id/...
Compatibility redirect:
/settings->/workspace/settings
Provider route integration:
apps/web/src/app/app.routes.tsmarks the/workspaceroot route withdata.layout = 'workspace'.isWorkspaceLayoutRoute(...)treats that layout marker as inherited route state for all descendants.- Xtream and Stalker parent routes attach route-scoped session providers that bootstrap the active playlist, sync provider section state, and clean up provider-local state when the route is destroyed.
- Xtream route bootstrap is DB-first for already imported Electron playlists: if the requested section has persisted categories and content, the route hydrates from SQLite even when the portal status probe reports unavailable, expired, or inactive. Fresh/no-cache Xtream routes still use the status probe to block remote imports before the loading overlay starts.
- Workspace routes no longer rely on nested provider shell components for hidden local chrome.
Shell Structure
The shell is intentionally split into four persistent regions:
- Left rail:
- Static workspace links for dashboard, sources, global favorites, and recently viewed. The routed global-search rail link is Electron-only because its data source is the SQLite worker bridge.
- Provider-aware context links derived from the active or current playlist.
- Settings remains a persistent footer shortcut in the rail.
- Top header:
- Playlist switcher.
- Route-aware search input and command palette trigger.
- Add source action.
- Optional playlist refresh and route-specific shortcut actions.
- Downloads shortcut in Electron.
- Main body:
- Optional left context panel.
- Main router outlet content.
- Optional footer:
- External playback session bar when a docked session is visible.
WorkspaceShellComponent binds only to WorkspaceShellFacade. The facade is
kept as a thin template-facing API and delegates ownership to component-scoped
services:
WorkspaceShellRouteStateServiceowns current route parsing, rail links, context-panel state, dashboard startup preference, and playlist source signals.WorkspaceShellSearchServiceowns the route-aware header search capability and public search actions.WorkspaceShellSearchSyncServiceowns the search query signals, debounced application, provider-store synchronization, and query-param sync.WorkspaceShellHeaderServiceowns playlist title/subtitle, account/info actions, refresh action state, and recent-items bulk cleanup.WorkspaceShellCommandPaletteServiceowns command-palette dialog lifecycle and recent-command recording.WorkspaceShellXtreamImportServiceowns Xtream import/refresh overlay state and labels.
When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep WorkspaceShellFacade as a stable re-export layer
for the template unless the template contract itself intentionally changes.
Context Panel Rules
The shell decides which secondary panel to show from the current route:
/workspace/sourcesWorkspaceSourcesFiltersPanelComponent
- Xtream category sections (
live,vod,series)WorkspaceContextPanelComponent
- Stalker category sections (
itv,radio,vod,series)WorkspaceContextPanelComponent
/workspace/settings/:sectionWorkspaceSettingsContextPanelComponent
- Downloads sections
WorkspaceCollectionContextPanelComponent
The context panel is part of the shell contract. New workspace-level routes should explicitly decide whether they need one rather than adding local sidebars inside feature pages.
Xtream and Stalker category panels preserve provider/server category order by
default. The panel header exposes a sort menu next to category search with
Server sorting, A-Z, and Z-A; when alphabetical sorting is active,
synthetic "all categories" entries stay pinned before sorted provider
categories.
Search And Navigation Rules
Search is shell-owned and route-aware:
- Disabled on settings routes.
- Enabled on sources routes.
- Enabled for
/workspace/search, which is the Electron-only routed global-search view.Ctrl/Cmd+Fin Electron opens this route and focuses/selects the header search input instead of opening a fullscreen dialog. - Enabled for supported Xtream and Stalker content/search views.
- Placeholder text and search handling vary by provider and section.
- Input changes are debounced before route/store updates are applied.
- Global search uses the header input as its primary input and writes the
search phrase to the
qquery parameter, so history/back-forward behavior matches the rest of the workspace. - The URL is authoritative for the search box only when it carries search
intent.
WorkspaceShellSearchSyncServicere-readsqon everyNavigationEnd, but an app-initiated navigation that stays on the same page and carries the term already applied is ignored while input is still debouncing — otherwise a page writing an unrelated query param (a downloads filter chip, a refresh bump) or the router echoing back our ownqwould cancel the pending debounce and reset the box, eating everything typed since. Pages are free to write their own query params while the user types; they must not assume the shell will re-apply the search afterwards. - Browser history overrides that guard. The exemption is keyed on
Navigation.trigger === 'imperative', so back/forward always re-applies what the history entry carries, even mid-typing. - Applying a term explicitly supersedes a queued one.
applySearchQuery()cancels any pending debounce, so the Enter key committing a trimmed term cannot be overwritten a moment later by the untrimmed keystroke still waiting behind it.
Rail navigation is also shell-owned:
- Workspace-global entries are static.
- Provider entries come from
buildPortalRailLinks(...). - On dashboard, sources, settings, global search, global favorites, and global recent, the shell falls back to the currently selected playlist so provider navigation remains available even outside a provider route.
Command palette behavior is shell-owned but view-extensible:
- The shell resolves commands into three groups in fixed order: current view, this playlist, then global.
- Shell-owned commands are derived from route context and current playlist state; empty groups are omitted instead of rendering disabled placeholders.
- Workspace features contribute current-view commands through
WorkspaceViewCommandService. - Header shortcut actions can opt into palette exposure by attaching palette
metadata through
WorkspaceHeaderContextService. - Filtering matches command labels, descriptions, and keywords, and keyboard selection always lands on the first enabled command.
- A "Recently used" section is rendered above the standard groups when the
query is empty and at least one stored id resolves to a visible+enabled
command; ids are persisted via
RecentCommandsService(capped at 5, stored atSTORE_KEY.RecentCommands). Storage is not pruned by route visibility — a navigation command likeOpen sourcesis invisible while the user is on/workspace/sourcesbut the id stays in storage so it reappears in the recent section after navigating away. - Six "Switch player to X" commands are registered globally by
WorkspacePlayerCommandsContributor(VideoJS, HTML5, ArtPlayer, Embedded MPV, MPV, VLC). Each command carries arequiresflag gating its visibility: the MPV/VLC ("managed-external") entries are visible only whenRuntimeCapabilitiesService.supportsManagedExternalPlayersis true, and the Embedded MPV ("embedded-mpv") entry is visible only after the command palette lazily preloads an asyncwindow.electron.getEmbeddedMpvSupport()check and it resolves tosupported(mirroring the Settings dropdown gate). Do not run this Embedded MPV support check from workspace shell bootstrap: supported desktop builds may load the native addon while resolving capabilities. The entry matching the currentSettingsStore.player()value is disabled. The new player setting applies to the next playback session; an existing stream is not re-mounted.
Keyboard shortcut help is shell-owned:
WorkspaceKeyboardShortcutsServiceis provided byWorkspaceShellComponent. It owns the workspace-scopeddocument:keydownlistener for?/Shift+/.- The listener ignores events from inputs, textareas, selects, and
content-editable elements via
isTypingInInput(...). libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.tsis the metadata registry for shortcuts shown in the help dialog and documented in README.keyboard-shortcuts.tsowns the display transformation and help trigger detection. Shortcuts that only work through the Electron bridge, such as embedded MPV controls, must setelectronOnly: trueso the PWA dialog does not advertise unavailable commands.- New custom shortcuts should be added to that registry when the handler is
added. Do not include native browser/editor behavior such as
Tabor platform text editing shortcuts.
Window Chrome And Custom Title Bar
The Electron window hides the native title bar on all desktop platforms
(titleBarStyle: 'hidden' in apps/electron-backend/src/app/app.ts):
- macOS keeps the native traffic lights (
titleBarOverlay: true,trafficLightPosition); the renderer draws no window buttons. - Windows and Linux use renderer-drawn window controls
(
app-window-controls,libs/ui/components/src/lib/window-controls/).frameis intentionally left untouched so native resize borders and window snapping keep working.
The controls are mounted once in app-root (not inside the workspace
header) as a position: fixed top-right overlay so they stay clickable
above full-window content such as the multi-EPG cdk overlay and Material
dialog backdrops — the same behavior as the macOS traffic lights. Because
CDK overlays render as popovers in the browser top layer (above any
z-index), the component host is itself a popover="manual" element: it
enters the top layer on init and re-enters it (hide + show) whenever
another popover opens, so the controls always paint last. The
z-index: 10000 remains only as a fallback when the popover API is
unavailable. They render only when
RuntimeCapabilitiesService.usesCustomWindowControls is true (Windows/Linux
Electron with the window-control bridge methods available); the PWA and
macOS never mount them.
IPC contract (constants in libs/shared/interfaces/src/lib/ipc-commands.ts,
handlers in apps/electron-backend/src/app/events/window.events.ts):
-
WINDOW:MINIMIZE,WINDOW:TOGGLE_MAXIMIZE,WINDOW:CLOSE,WINDOW:GET_STATEareipcMain.handlechannels resolved from the sender WebContents. Close goes throughwin.close()so the existing window-bounds persistence inapp.tsstill runs. -
WINDOW:STATE_CHANGEDis pushed main → renderer on maximize/unmaximize and on the fullscreen events —enter/leave-full-screenplus theenter/leave-html-full-screenvariants emitted for HTML-element fullscreen (video player fullscreen) — so the maximize/restore glyph stays correct for externally triggered changes (double-click on a drag region, OS snap, F11). The controls hide themselves while the window is fullscreen.Window state is never re-read at event time.
attachWindowStateEventsseeds{ isMaximized, isFullScreen }once at window creation and each event patches only the flag it names; every push carries a copy of that tracked state. On Windows both getters can still report the pre-transition value while the matching event fires —isFullScreen()staystrueduring an HTML fullscreen exit, andisMaximized()readsfalsewhile the window is fullscreen. Because the renderer replaces both flags on every push and no later event corrects a stale one, polling left the controls hidden forever after leaving fullscreen and stuck the maximize/restore glyph on the wrong icon. Regression coverage:app-window-state.spec.tsandwindow-controls.e2e.ts.
Layout integration:
document.bodygets aframeless-platformclass (set inAppComponent, same mechanism asdark-theme) — body-level so rules also reach cdk-overlay content rendered outsideapp-root.apps/web/src/styles.scssreservespadding-right: 150pxin top-aligned drag regions (.workspace-header, multi-EPG#epg-navigation) for the 3 × 46px button strip.- Button colors follow the theme via CSS variables (
--app-on-surface,--app-hover-overlay); the close button uses the Windows-style red hover (#e81123). No theme IPC is involved.
Window decorations on Linux (shadows, corners):
- Hiding the title bar removes the window manager's decorations, so the shadow/rounded corners must come from client-side decorations (CSD). Electron 43 enables rounded corners by default when the Linux desktop environment supports CSD. GTK drop shadows and extended resize boundaries remain environment-dependent; Electron selects native Wayland automatically on Wayland sessions.
- Linux environments without CSD support can still show a square, undecorated window. Windows 11 keeps its DWM rounded corners and shadow because the standard frame is retained.
Toolchain notes for the Electron 43 baseline:
better-sqlite3remains pinned exactly so native dependency updates happen deliberately with database-worker, packaging, and Electron E2E validation. Version 13 uses Node-API and ships its supported platform binaries in the package. It must not be listed in pnpm'sonlyBuiltDependencies: forcing an implicitnode-gyp rebuildbypasses those binaries and makes installation depend on the host compiler toolchain. The oldnode-abioverride belonged to v12's removedprebuild-installpath and is no longer required.- Local development supports Node 22.13–22.x or Node >= 24, declared in
enginesas^22.13.0 || >=24.0.0. The direct@faker-js/fakerdependency and current lint tooling require that floor.electron-builder26.15.7 also pulls@electron/rebuild4, which requires Node 22.12 or newer, and the rootpostinstallrunsinstall-app-depson everypnpm install. The 26.15.7 minimum also carries the v26 backport that fully extracts the Snap template's.tar.7zpayload; 26.15.0–26.15.6 can produce a Snap that is missingdesktop-init.sh. CI already runs Node 22. - Dependabot keeps Electron, native database, packaging, EPG parser, and version-locked Shaka/mpegts updates out of the shared npm minor/patch group. Those dependencies require standalone PRs so their dedicated package, worker, playback, and diagnostic-contract validation cannot be hidden by an unrelated grouped update.
- Electron 42 and newer download their development binary on the first
Electron command instead of during package
postinstall, soelectronmust not remain in pnpm'sonlyBuiltDependenciesallowlist. The first localpnpm run serve:backendcan include a one-time download; usepnpm exec electron --versionto prewarm it before an offline run.
Known caveats:
- DIY buttons cannot show the Windows 11 Snap Layouts flyout (only native caption buttons or the Window Controls Overlay get that).
- Double-click-to-maximize on drag regions is handled natively by Electron/Chromium; on Linux the exact behavior depends on the window manager.
Maintenance Guidance
Use this document as the source of truth when changing workspace shell behavior.
- New top-level user destinations should default to child routes under
/workspace. - Shared provider navigation logic belongs in portal-shared util/UI libraries, not duplicated inside the shell.
- If a provider route changes how playlist/session bootstrap works, update the route-session provider and shell-facing route contract together.
- When adding a non-native keyboard shortcut, update the shared shortcuts registry, the help dialog tests, README, and the closest behavior test.
- Historical migration notes, cleanup lists, and one-off refactor steps should stay out of this file; track them in issues or PR notes instead.